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).
164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
import { nip19 } from 'nostr-tools';
|
|
|
|
/**
|
|
* Cordn coordinator support — mostly *formats*, taken from Cordn itself so we
|
|
* don't invent anything interoperable.
|
|
*
|
|
* - A coordinator is identified by a Nostr pubkey plus the ContextVM relays
|
|
* that reach it (see `nprofile`).
|
|
* - Cordn's own server prints the canonical "add me" link as
|
|
* `https://cordn.net/chat/coordinators?c=<nprofile>`
|
|
* (`packages/server/src/main.ts`), and defaults to `wss://relay.contextvm.org`
|
|
* (`packages/server/src/coordinatorServer.ts`).
|
|
* - Cordn is a ContextVM server, so a coordinator can be probed the same way
|
|
* our own CVM server is: look for events it authored on that relay.
|
|
*/
|
|
|
|
/** Cordn's default ContextVM relay. */
|
|
export const CORDN_RELAY_URL = 'wss://relay.contextvm.org';
|
|
|
|
/** Cordn's "add this coordinator" URL base. */
|
|
export const CORDN_ADD_BASE = 'https://cordn.net/chat/coordinators';
|
|
|
|
/** Cordn's home — where the docs/one-liner/phone app live. */
|
|
export const CORDN_HOME = 'https://cordn.net';
|
|
|
|
/** The self-host one-liner advertised on cordn.net. */
|
|
export const CORDN_DOCKER_COMMAND = 'docker run --rm ghcr.io/cordn-msg/cordn:latest';
|
|
|
|
/** Where a user gets a coordinator they can run on a phone. */
|
|
export const CORDN_APP_URL = 'https://cordn.net';
|
|
|
|
/** Device-local: which coordinator this user points at. */
|
|
export const COORDINATOR_STORAGE_KEY = 'skatespots:coordinator';
|
|
|
|
export interface CoordinatorConfig {
|
|
/** Coordinator server pubkey (hex). */
|
|
pubkey: string;
|
|
/** ContextVM relays that reach it (deduped, sorted-ish). */
|
|
relays: string[];
|
|
addedAt: number;
|
|
}
|
|
|
|
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
|
|
function uniqueRelays(relays: Iterable<string>): string[] {
|
|
return [...new Set([...relays].map((r) => r.trim()).filter((r) => /^wss?:\/\//i.test(r)))];
|
|
}
|
|
|
|
/** Pull the `c=` nprofile out of a cordn.net add URL. */
|
|
function extractFromUrl(input: string): string | null {
|
|
try {
|
|
return new URL(input).searchParams.get('c');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse anything a user might paste: an `nprofile`, an `npub`, a bare hex
|
|
* pubkey, or a `cordn.net/chat/coordinators?c=<nprofile>` link. Returns null
|
|
* when it isn't a usable coordinator pointer.
|
|
*/
|
|
export function parseCoordinatorInput(raw: string): Omit<CoordinatorConfig, 'addedAt'> | null {
|
|
const input = raw.trim();
|
|
if (!input) return null;
|
|
|
|
const candidate = input.includes('://') ? extractFromUrl(input) ?? input : input;
|
|
|
|
try {
|
|
const decoded = nip19.decode(candidate);
|
|
if (decoded.type === 'nprofile') {
|
|
return { pubkey: decoded.data.pubkey, relays: uniqueRelays(decoded.data.relays ?? []) };
|
|
}
|
|
if (decoded.type === 'npub') {
|
|
return { pubkey: decoded.data, relays: [] };
|
|
}
|
|
} catch {
|
|
// Not bech32 — try a raw hex pubkey below.
|
|
}
|
|
|
|
if (HEX64.test(candidate)) return { pubkey: candidate.toLowerCase(), relays: [] };
|
|
return null;
|
|
}
|
|
|
|
/** The canonical cordn.net link that adds this coordinator. */
|
|
export function coordinatorAddUrl(config: Pick<CoordinatorConfig, 'pubkey' | 'relays'>): string {
|
|
const nprofile = nip19.nprofileEncode({ pubkey: config.pubkey, relays: config.relays });
|
|
return `${CORDN_ADD_BASE}?c=${nprofile}`;
|
|
}
|
|
|
|
/** `npub1abcd…wxyz` for display; falls back to a hex prefix. */
|
|
export function shortNpub(pubkey: string): string {
|
|
try {
|
|
const npub = nip19.npubEncode(pubkey);
|
|
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
|
|
} catch {
|
|
return `${pubkey.slice(0, 12)}…`;
|
|
}
|
|
}
|
|
|
|
/** 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
|
|
* here — so we look in the obvious places and give up gracefully.
|
|
*/
|
|
export function coordinatorNameFromEvent(event: {
|
|
tags: string[][];
|
|
content: string;
|
|
}): string | null {
|
|
const tagName = event.tags.find(([t]) => t === 'name')?.[1];
|
|
if (tagName?.trim()) return tagName.trim().slice(0, 40);
|
|
|
|
try {
|
|
const parsed = JSON.parse(event.content) as {
|
|
name?: unknown;
|
|
serverInfo?: { name?: unknown };
|
|
};
|
|
const candidates = [parsed.serverInfo?.name, parsed.name];
|
|
for (const value of candidates) {
|
|
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 40);
|
|
}
|
|
} catch {
|
|
// content isn't JSON — no name to show.
|
|
}
|
|
return null;
|
|
}
|