SkateSpots/src/lib/coordinator.ts
SkateSpots a8e411faa0 feat(cordn): embedded coordinator on the relay, crews default to it, disclosure
Newlay runs its embedded Cordn coordinator (identity cordn.key, uid 10001). SKATESPOTS_COORDINATOR is the provided default: Settings -> Coordinator shows a 'Use SkateSpots coordinator' block and CrewForm prefills it for new crews; BYO coordinator stays client-side. Disclosure copy (coordinators see ciphertext + metadata, never contents) in socialUi.coordinator.ourDefault* + existing privacyNote.
2026-10-12 16:36:56 +07:00

173 lines
6.2 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;
}
/** SkateSpots' own coordinator — embedded in our relay at wss://relay.skate-spots.org.
* Provided as the default for crews and Settings → Coordinator; users may always
* run their own (a coordinator only ever sees ciphertext + metadata, never message
* contents). Pubkey = the relay's `cordn.key`. */
export const SKATESPOTS_COORDINATOR: Pick<CoordinatorConfig, 'pubkey' | 'relays'> = {
pubkey: '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693',
relays: ['wss://relay.skate-spots.org'],
};
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;
}