feat(crew): accept a phone-run Cordn coordinator in the crew form

Crew-level Cordn chat already existed (lib/cordn, useCordnChat, CrewChat,
CrewCoordinatorCard, the crew 'coordinator' tag). The real gap: the crew form
accepted a URL only, so a coordinator run from Cordn's phone app — which yields
an nprofile, not a URL — could not be entered at all.

- lib/coordinator.ts: parseCrewCoordinatorInput / crewCoordinatorInputValue —
  the same pointer formats as Settings -> Coordinator (nprofile / cordn.net
  link / npub / hex) plus a plain endpoint URL.
- CrewForm: parses the pointer, shows live valid/invalid feedback, sets
  ['coordinator', url, pubkey?] (schema unchanged), and gains a one-tap
  'Use my coordinator' prefill from Settings.
- NIP.md: note the accepted pointer formats on the coordinator tag.
- AGENTS.md: record that crew-level Cordn chat already exists (the lesson:
  grep src before building).
This commit is contained in:
SkateSpots 2026-09-29 16:02:09 +07:00
parent 7b33d544b1
commit ee380a5c74
4 changed files with 103 additions and 15 deletions

View file

@ -595,8 +595,22 @@ session with **no other memory of this project**. So:
(link to Cordn + copy Docker + scan its QR), never an embedded runtime — the
phone-hosting story belongs to Cordn's own app. ⚠️ `CORDN_APP_URL` currently
points at `cordn.net`; the exact Android-app deep link is unconfirmed — fill
it in when known. **Not built (future):** publishing the coordinator on the
profile (discovery) and crew-level coordinators.
it in when known.
**⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it
(2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` +
`useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by
a crew's **`coordinator` tag** `['coordinator', <url>, <coordinatorPubkey?>,
<operatorPubkey?>]` (`SkateCrew.coordinator`, parsed in `types.ts`,
`resolveCordnEndpoint()` → `CordnEndpoint`), documented in NIP.md ("Private
Crew Chat (Cordn / MLS)"). Every crew already has built-in NIP-17 E2E chat; the
tag *upgrades* it to sealed NIP-44 over ContextVM. **The only gap bridged this
session:** the crew form accepted a URL only, so a coordinator run from
Cordn's phone app (an nprofile, not a URL) was un-enterable — `CrewForm` now
uses the shared `parseCrewCoordinatorInput` / `crewCoordinatorInputValue`
(`lib/coordinator.ts`) and has a **"Use my coordinator"** button that prefills
from Settings → Coordinator. **Still future:** profile discovery, and real MLS
(`ts-mls`) instead of group-key crypto. **Lesson: grep `src` for the feature
before building it — this was nearly duplicated.**
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
allLocations.length > 0`) and a new "No spots mapped yet — be the first" card

2
NIP.md
View file

@ -2241,7 +2241,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
| `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. |
| `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. |
| `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. Clients that accept a pasted pointer **MUST** also accept the formats a coordinator run from Cordn's own app yields: an `nprofile`, or a `https://cordn.net/chat/coordinators?c=<nprofile>` link — deriving `<url>` from the nprofile's relay hints (falling back to the ContextVM relay) and `<coordinatorPubkey>` from its pubkey. A plain `http(s)`/`wss` endpoint remains valid. |
| `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. |
| `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", <memberPubkey>, <role>]`, repeatable (a member may hold several). `<role>` ∈ the full Lab-craft set — `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `graffiti`, `tattoo`, `trainer`, `musician`, `judge` — plus the crew-specific `manager` and `og`. The Lab-craft roles mirror the Lab profession kinds (35001–35012), so a crew's "filmer" speaks the same language as a Lab Filmer profile. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. |

View file

@ -22,6 +22,8 @@ import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator } from '@/hooks/useCoordinator';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react';
@ -278,9 +280,15 @@ export function CrewForm({ existing }: CrewFormProps) {
const toggleLookingFor = (slug: string) =>
setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]));
// Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator URL.
// Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator pointer.
const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? '');
const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? '');
const [coordinatorInput, setCoordinatorInput] = useState(
crewCoordinatorInputValue(existing?.coordinator),
);
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
const { coordinator: myCoordinator } = useCoordinator();
// Live parse so the founder gets feedback (and the publish path reuses it).
const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput);
// Crew video / montage — YouTube or Instagram URL ("The Part").
const [videoUrl, setVideoUrl] = useState(existing?.videoUrl ?? '');
@ -513,10 +521,14 @@ export function CrewForm({ existing }: CrewFormProps) {
tags.push(['sponsor', sponsorCoord.coord]);
}
// Cordn / MLS coordinator for the crew's encrypted chat (https/wss URL only).
const coordUrl = coordinatorUrl.trim();
if (coordUrl && /^(https?|wss?):\/\//i.test(coordUrl)) {
tags.push(['coordinator', coordUrl]);
// Cordn / MLS coordinator for the crew's encrypted chat. Accepts the same
// pointer formats as Settings → Coordinator (nprofile / cordn.net link /
// npub / hex) as well as a plain endpoint URL, so a coordinator run from
// Cordn's phone app works too. Tag: ['coordinator', url, pubkey?].
if (parsedCoordinator) {
const coordinatorTag = ['coordinator', parsedCoordinator.url];
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
tags.push(coordinatorTag);
}
// Crew video / montage — only stored if it's a valid YouTube/Instagram URL.
@ -1088,16 +1100,40 @@ export function CrewForm({ existing }: CrewFormProps) {
>
Cordn coordinator
</a>{' '}
(a small self-hosted MLS delivery service). Paste its URL to route chat through it instead. Leave blank
to use the built-in chat.
— even on an old phone. Paste its <span className="font-mono">nprofile</span>, a cordn.net link, or an
endpoint URL. Leave blank to use the built-in chat.
</p>
<Input
id="crew-coordinator"
placeholder="https://your-coordinator.example or wss://…"
value={coordinatorUrl}
onChange={(e) => setCoordinatorUrl(e.target.value)}
placeholder="nprofile1… · cordn.net/chat/coordinators?c=… · https://…"
value={coordinatorInput}
onChange={(e) => setCoordinatorInput(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
spellCheck={false}
/>
{coordinatorInput.trim() !== '' &&
(parsedCoordinator ? (
<p className="break-all text-[11px] text-teal-400">
✓ {parsedCoordinator.pubkey ? `${shortNpub(parsedCoordinator.pubkey)} · ` : ''}
{parsedCoordinator.url}
</p>
) : (
<p className="text-[11px] text-amber-400">
That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.
</p>
))}
{myCoordinator && (
<Button
type="button"
variant="outline"
size="sm"
className="border-teal-500/40 text-teal-300 hover:bg-teal-500/10"
onClick={() => setCoordinatorInput(coordinatorAddUrl(myCoordinator))}
>
<Server className="mr-1.5 h-3.5 w-3.5" />
Use my coordinator
</Button>
)}
</div>
);
@ -1284,7 +1320,7 @@ export function CrewForm({ existing }: CrewFormProps) {
const hasExtras =
!!videoUrl.trim() || !!sponsorRef.trim() || labRefs.length > 0 ||
socials.length > 0 || !!website.trim() || !!instagram.trim() ||
!!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorUrl.trim() ||
!!bandName.trim() || !!nip05.trim() || agentEnabled || !!coordinatorInput.trim() ||
(goalEnabled && !!goalTitle.trim());
const steps: { title: string; short: string; icon: typeof Users; hint: string; filled: boolean; content: ReactNode }[] = [

View file

@ -98,6 +98,44 @@ export function shortNpub(pubkey: string): string {
}
}
/** A crew coordinator as stored on the crew event: `['coordinator', url, pubkey?, operator?]`. */
export interface CrewCoordinatorPointer {
/** Endpoint: a `wss://` ContextVM relay, or an http(s) coordinator URL. */
url: string;
/** Coordinator service pubkey (hex), when addressed by identity. */
pubkey?: string;
}
/**
* Parse what a crew founder pastes into the crew's coordinator field. Accepts
* everything Settings → Coordinator does (nprofile / npub / hex / cordn.net
* link) **plus** a plain endpoint URL — so a coordinator run from Cordn's phone
* app (which yields an nprofile, not a URL) is enterable, not just a
* self-hosted endpoint.
*/
export function parseCrewCoordinatorInput(raw: string): CrewCoordinatorPointer | null {
const input = raw.trim();
if (!input) return null;
const pointer = parseCoordinatorInput(input);
if (pointer) {
return { url: pointer.relays[0] ?? CORDN_RELAY_URL, pubkey: pointer.pubkey };
}
if (/^(https?|wss?):\/\//i.test(input)) return { url: input };
return null;
}
/** Rebuild a paste-able input from a stored crew coordinator (round-trips). */
export function crewCoordinatorInputValue(
coordinator: { url: string; pubkey?: string } | undefined,
): string {
if (!coordinator?.url) return '';
if (coordinator.pubkey && /^wss?:\/\//i.test(coordinator.url)) {
return nip19.nprofileEncode({ pubkey: coordinator.pubkey, relays: [coordinator.url] });
}
return coordinator.url;
}
/**
* Best-effort coordinator display name from an announcement event. Cordn relies
* on the ContextVM transport announcement, whose exact shape we don't hard-code