From 50d8d9e614ecb17bee73430e387dd9a262615338 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 00:15:22 +0700 Subject: [PATCH] Wire live Cordn encrypted crew chat over the coordinator (ContextVM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crew coordinator is no longer just a pointer — when a crew configures one, the Chat tab runs through it for real. - src/lib/cordn: clean transport/crypto split so an MLS (ts-mls) upgrade is a drop-in swap of the crypto layer only. - types.ts: CordnMessage/CordnEndpoint + envelope kind 4497 + app tag. - crypto.ts: sealGroupMessage/openGroupMessage — real NIP-44 group encryption (per-recipient sealing), the exact seam MLS encrypt-to-epoch slots into. - client.ts: buildEnvelope/publishEnvelope/fetchGroupMessages with per-group cursor ordering and ciphertext→author sender binding (anti-spoof). - endpoint.ts: resolve a crew's `coordinator` to a usable relay endpoint (wss/ws direct; https falls back to the ContextVM relay). - useCordnChat: fetch-then-poll hook mirroring the NIP-17 chat API. - CrewChat: uses Cordn when the crew has a coordinator, NIP-17 fallback otherwise; header badge shows the active transport (Cordn vs Private). - CrewCoordinatorCard: reflects that private chat is live. - NIP.md: document envelope kind 4497, delivery (fetch-then-poll + cursors), the nip44-group sealed content, and the honest crypto→MLS seam. Real encryption, real coordinator routing, non-breaking (fallback intact), with the MLS ratchet as a clearly-marked next step. Co-authored-by: shakespeare.diy --- NIP.md | 53 ++++++++++ src/components/CrewChat.tsx | 103 ++++++++++++++++--- src/components/crew/CrewCoordinatorCard.tsx | 14 ++- src/hooks/useCordnChat.ts | 95 +++++++++++++++++ src/lib/cordn/client.ts | 107 ++++++++++++++++++++ src/lib/cordn/crypto.ts | 106 +++++++++++++++++++ src/lib/cordn/endpoint.ts | 46 +++++++++ src/lib/cordn/types.ts | 50 +++++++++ src/pages/CrewDetailPage.tsx | 1 + 9 files changed, 556 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useCordnChat.ts create mode 100644 src/lib/cordn/client.ts create mode 100644 src/lib/cordn/crypto.ts create mode 100644 src/lib/cordn/endpoint.ts create mode 100644 src/lib/cordn/types.ts diff --git a/NIP.md b/NIP.md index 20612f1..1dda1fe 100644 --- a/NIP.md +++ b/NIP.md @@ -2274,6 +2274,59 @@ the private chat un-fingerprintable next to an already-public, geolocated crew identity. Until a crew configures a coordinator, the built-in NIP-17 group-DM chat remains the fallback. +##### Message envelope (kind `4497`) & delivery + +When a crew has a `coordinator`, chat runs over it. Each message is a **sealed +envelope** — a regular event (kind `4497`) published to the coordinator's +ContextVM relay (a `wss://`/`ws://` coordinator URL is used directly; an +`https://` coordinator falls back to the ContextVM relay, addressed by the +coordinator pubkey): + +```json +{ + "kind": 4497, + "content": "", + "tags": [ + ["a", "35006::"], // group id = crew coordinate + ["t", "skatespots.cordn.crew-chat"], // app envelope marker + ["cursor", "1730000000000"], // per-group monotonic cursor (ms) + ["p", ""], // optional — coordinator addressing + ["alt", "Encrypted crew chat message (Cordn)"] + ] +} +``` + +Clients do **fetch-then-poll**: a bounded backlog query filtered by +`{ kinds:[4497], "#a":[groupId], "#t":["skatespots.cordn.crew-chat"] }`, then a +light poll for live delivery. Messages are ordered by the `cursor` tag (event id +as tiebreaker). The ciphertext-declared sender is bound to the event author and +mismatches are rejected (no spoofing). + +**Sealed content** (`content` field), JSON: + +```json +{ + "v": 1, + "alg": "nip44-group", + "recipients": { "": "", "...": "..." } +} +``` + +The inner plaintext (`{ t: body, s: senderPubkey, ts }`) is encrypted **once per +roster recipient** (and to self) with the sender's real **NIP-44**. Any roster +member opens the entry addressed to them. This gives genuine end-to-end +confidentiality against the coordinator and relays. + +> **Crypto seam — honest status.** The `alg` marker is `nip44-group` today: +> real E2E encryption, but **not yet MLS** — no forward secrecy / post-compromise +> security, and O(n) recipient sealing per message. The client is deliberately +> split into a **transport** layer (envelope publish/fetch/cursor) and a **crypto** +> layer (`sealGroupMessage` / `openGroupMessage`). Upgrading to a full RFC 9420 +> ratchet via `ts-mls` swaps only the crypto layer (`alg: "mls"`, +> encrypt-to-epoch / decrypt-from-epoch) — transport, envelope, cursors and UI are +> unchanged. Clients must treat unknown `alg` values as unreadable rather than +> erroring. + ### NIP-05 Verification The optional `nip05` tag is a crew handle on **any domain the crew controls** diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx index 5806328..9bac48e 100644 --- a/src/components/CrewChat.tsx +++ b/src/components/CrewChat.tsx @@ -21,9 +21,14 @@ import { useSendDirectMessage, type DirectMessage, } from '@/hooks/useDirectMessages'; +import { useCordnChat } from '@/hooks/useCordnChat'; +import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; +import type { CordnMessage } from '@/lib/cordn/types'; import { genUserName } from '@/lib/genUserName'; import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration'; import { cn } from '@/lib/utils'; +import { Server } from 'lucide-react'; +import type { SkateCrew } from '@/lib/types'; interface CrewChatProps { crewName: string; @@ -37,6 +42,23 @@ interface CrewChatProps { crewCoord?: string; /** Whether the viewer can moderate (founder or admin). */ canModerate?: boolean; + /** + * Full crew — when present AND it has a `coordinator` configured, chat runs + * over the Cordn coordinator (encrypted, coordinator-routed). Otherwise it + * falls back to the built-in NIP-17 group DM. + */ + crew?: SkateCrew; +} + +/** Normalise a Cordn message into the shared DirectMessage bubble shape. */ +function cordnToDirect(m: CordnMessage, groupId: string): DirectMessage { + return { + id: m.id, + pubkey: m.pubkey, + content: m.content, + createdAt: m.createdAt, + conversationWith: groupId, + }; } /** One message bubble, resolving the author's profile. */ @@ -112,6 +134,7 @@ export function CrewChat({ admins = [], crewCoord = '', canModerate = false, + crew, }: CrewChatProps) { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); @@ -119,6 +142,13 @@ export function CrewChat({ const [draft, setDraft] = useState(''); const scrollRef = useRef(null); + // Cordn transport when the crew advertises a coordinator; else NIP-17. + const cordnEndpoint = useMemo( + () => (crew ? resolveCordnEndpoint(crew) : null), + [crew], + ); + const usingCordn = !!cordnEndpoint; + // Moderation — founder + admins can hide chat messages (client-side, since // NIP-17 messages are E2EE and can't be deleted from relays). const moderators = useMemo( @@ -152,11 +182,28 @@ export function CrewChat({ ); // Only crew members (founder or a listed member) can read/write the chat, - // because NIP-17 group messages are end-to-end encrypted to the participants. + // because group messages are end-to-end encrypted to the participants. const isMember = !!user && participants.includes(user.pubkey); - const { data: messages, isLoading } = useGroupConversation(isMember ? participants : []); - const { mutateAsync: sendMessage, isPending } = useSendDirectMessage(); + // ── NIP-17 fallback transport ── + const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation( + !usingCordn && isMember ? participants : [], + ); + const { mutateAsync: sendNip17, isPending: nip17Sending } = useSendDirectMessage(); + + // ── Cordn coordinator transport ── + const cordn = useCordnChat({ + endpoint: cordnEndpoint, + roster: participants, + enabled: isMember, + }); + + // Unify whichever transport is active into one message list + send + state. + const messages: DirectMessage[] = usingCordn + ? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId)) + : (nip17Messages ?? []); + const isLoading = usingCordn ? cordn.isLoading : nip17Loading; + const isPending = usingCordn ? cordn.isSending : nip17Sending; // Auto-scroll to newest message when the list grows. const lastCount = useRef(0); @@ -184,7 +231,22 @@ export function CrewChat({ return; } - // Recipients = everyone except yourself (the hook also wraps a copy to self). + // Cordn transport: seal to the roster and route via the coordinator. + if (usingCordn) { + try { + await cordn.send(text); + setDraft(''); + } catch (err) { + toast({ + title: 'Message failed', + description: err instanceof Error ? err.message : 'Could not send message.', + variant: 'destructive', + }); + } + return; + } + + // NIP-17 fallback: recipients = everyone except yourself (hook wraps self too). const recipients = participants.filter((p) => p !== user.pubkey); if (recipients.length === 0) { toast({ @@ -195,7 +257,7 @@ export function CrewChat({ } try { - await sendMessage({ + await sendNip17({ recipients, content: text, subject: `${crewName} crew chat`, @@ -217,18 +279,31 @@ export function CrewChat({ Crew Chat - - - Private - + {usingCordn ? ( + + + Cordn + + ) : ( + + + Private + + )}

- Encrypted group chat for the founder & all listed crew members. + {usingCordn + ? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.' + : 'Encrypted group chat for the founder & all listed crew members.'} {canModerate && ' You can hide messages from the crew as a moderator.'}

diff --git a/src/components/crew/CrewCoordinatorCard.tsx b/src/components/crew/CrewCoordinatorCard.tsx index d054ab1..06679ff 100644 --- a/src/components/crew/CrewCoordinatorCard.tsx +++ b/src/components/crew/CrewCoordinatorCard.tsx @@ -69,11 +69,15 @@ export function CrewCoordinatorCard({ {coordinator.operator && } -
-

- Messages are end-to-end encrypted with MLS ratchets. The coordinator only routes sealed - traffic over ContextVM — it never sees message content, your IP, or who is talking, and - nothing crew-specific is published to public relays. +

+

+ Private chat is live +

+

+ The Chat tab now runs through this + coordinator: messages are end-to-end encrypted and routed over ContextVM — the + coordinator only ever handles sealed traffic, and nothing crew-specific is published to + public relays.

diff --git a/src/hooks/useCordnChat.ts b/src/hooks/useCordnChat.ts new file mode 100644 index 0000000..e8989d6 --- /dev/null +++ b/src/hooks/useCordnChat.ts @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { useNostrLogin } from '@nostrify/react/login'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; +import { + buildEnvelope, + fetchGroupMessages, + publishEnvelope, + type CordnRelay, +} from '@/lib/cordn/client'; +import { sealGroupMessage, type Nip44Signer } from '@/lib/cordn/crypto'; +import type { CordnEndpoint, CordnMessage } from '@/lib/cordn/types'; +import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; + +interface UseCordnChatParams { + /** Resolved coordinator endpoint (relay + group id). `null` disables the chat. */ + endpoint: CordnEndpoint | null; + /** Full roster (founder + members) — recipients for group encryption. */ + roster: string[]; + /** Whether the viewer is a member (only members query/decrypt). */ + enabled: boolean; +} + +/** + * Crew encrypted group chat over a Cordn coordinator. + * + * Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live + * delivery (5s) — robust in a browser PWA and coordinator-agnostic. Mirrors the + * NIP-17 `useGroupConversation` / `useSendDirectMessage` API so `CrewChat` can + * swap transports with no UI change. + */ +export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + const queryClient = useQueryClient(); + + const canEncrypt = checkNip44(user, logins); + const active = !!endpoint && !!user && enabled && canEncrypt; + + const query = useQuery({ + queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey], + enabled: active, + refetchInterval: active ? 5000 : false, + staleTime: 2000, + queryFn: async (): Promise => { + if (!endpoint || !user) return []; + const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; + const signer = user.signer as unknown as Nip44Signer; + const signal = AbortSignal.timeout(10000); + return fetchGroupMessages({ + relay, + endpoint, + signer, + viewerPk: user.pubkey, + signal, + }); + }, + }); + + const send = useMutation({ + mutationFn: async (text: string) => { + const body = text.trim(); + if (!body) return; + if (!endpoint || !user) throw new Error('Crew chat is not available.'); + if (!canEncrypt) throw new Error('Your signer does not support NIP-44 encryption.'); + + const signer = user.signer as unknown as Nip44Signer; + const sealed = await sealGroupMessage(signer, user.pubkey, roster, body); + const envelope: NostrEvent = await buildEnvelope( + user.signer as unknown as NostrSigner, + endpoint, + JSON.stringify(sealed), + ); + + const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay; + await publishEnvelope(relay, envelope, AbortSignal.timeout(15000)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey], + }); + }, + }); + + return { + messages: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + canEncrypt, + send: send.mutateAsync, + isSending: send.isPending, + }; +} diff --git a/src/lib/cordn/client.ts b/src/lib/cordn/client.ts new file mode 100644 index 0000000..1a6033d --- /dev/null +++ b/src/lib/cordn/client.ts @@ -0,0 +1,107 @@ +/** + * Cordn transport layer. + * ───────────────────────────────────────────────────────────────────────────── + * Publishes and fetches crew group messages through the crew's coordinator, + * on the coordinator's ContextVM relay. Messages are addressed to the group by + * the crew coordinate (`a` tag) and carry a per-group monotonic cursor for + * bounded catch-up + live delivery (fetch-then-poll). + * + * Every payload is already sealed by the crypto layer — this file never sees + * plaintext. The relay/coordinator only ever routes ciphertext. + */ + +import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; +import { CORDN_APP_TAG, CORDN_MESSAGE_KIND, type CordnEndpoint, type CordnMessage } from './types'; +import { openGroupMessage, parseSealed, type Nip44Signer } from './crypto'; + +/** Minimal relay handle (the object returned by `nostr.relay(url)`). */ +export interface CordnRelay { + query: (filters: unknown[], opts: { signal: AbortSignal }) => Promise; + event: (event: NostrEvent, opts?: { signal: AbortSignal }) => Promise; +} + +/** + * Build the sealed envelope event for a group message and sign it with the + * sender's signer. The `cursor` is set to the current time (ms) so ordering is + * stable across coordinators (per-group monotonic; ties broken by event id). + */ +export async function buildEnvelope( + signer: NostrSigner, + endpoint: CordnEndpoint, + sealedContent: string, +): Promise { + const cursor = Date.now(); + const tmpl = { + kind: CORDN_MESSAGE_KIND, + content: sealedContent, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['a', endpoint.groupId], // group = crew coordinate 35006:pubkey:d + ['t', CORDN_APP_TAG], + ['cursor', String(cursor)], + ...(endpoint.coordinatorPubkey ? [['p', endpoint.coordinatorPubkey]] : []), + ['alt', 'Encrypted crew chat message (Cordn)'], + ] as string[][], + }; + return signer.signEvent(tmpl); +} + +/** Publish a pre-built, signed envelope to the coordinator's relay. */ +export async function publishEnvelope( + relay: CordnRelay, + envelope: NostrEvent, + signal: AbortSignal, +): Promise { + await relay.event(envelope, { signal }); +} + +/** + * Fetch the group's message backlog from the coordinator, newest-capped by + * `limit`, and decrypt everything this viewer can open. Returns messages sorted + * oldest→newest by cursor. + */ +export async function fetchGroupMessages(params: { + relay: CordnRelay; + endpoint: CordnEndpoint; + signer: Nip44Signer; + viewerPk: string; + limit?: number; + /** Only fetch events created at/after this unix-seconds bound (catch-up window). */ + sinceSeconds?: number; + signal: AbortSignal; +}): Promise { + const { relay, endpoint, signer, viewerPk, limit = 200, sinceSeconds, signal } = params; + + const filter: Record = { + kinds: [CORDN_MESSAGE_KIND], + '#a': [endpoint.groupId], + '#t': [CORDN_APP_TAG], + limit, + }; + if (sinceSeconds) filter.since = sinceSeconds; + + const events = await relay.query([filter], { signal }); + + const out: CordnMessage[] = []; + for (const ev of events) { + const sealed = parseSealed(ev.content); + if (!sealed) continue; + const opened = await openGroupMessage(signer, viewerPk, sealed); + if (!opened) continue; + // Bind the ciphertext-declared sender to the event author — reject spoofing. + if (opened.sender !== ev.pubkey.toLowerCase()) continue; + const cursorTag = ev.tags.find(([t]) => t === 'cursor')?.[1]; + const cursor = cursorTag ? Number(cursorTag) : ev.created_at * 1000; + out.push({ + id: ev.id, + pubkey: ev.pubkey, + content: opened.content, + createdAt: opened.ts || ev.created_at, + cursor: Number.isFinite(cursor) ? cursor : ev.created_at * 1000, + }); + } + + // Oldest → newest by cursor, id as tiebreaker for determinism. + out.sort((a, b) => (a.cursor - b.cursor) || a.id.localeCompare(b.id)); + return out; +} diff --git a/src/lib/cordn/crypto.ts b/src/lib/cordn/crypto.ts new file mode 100644 index 0000000..469fef0 --- /dev/null +++ b/src/lib/cordn/crypto.ts @@ -0,0 +1,106 @@ +/** + * Cordn crypto layer. + * ───────────────────────────────────────────────────────────────────────────── + * Seals / opens a crew group message. This is the ONLY place that touches + * ciphertext, so upgrading to a full MLS ratchet (`ts-mls`: encrypt-to-epoch / + * decrypt-from-epoch) is a drop-in replacement that leaves transport + UI intact. + * + * CURRENT scheme (pre-MLS, honest): NIP-44 group delivery. The sender encrypts + * the plaintext once per roster recipient (and once to self) using their signer's + * real NIP-44, producing a `recipients` map on the envelope. Any roster member + * opens the message by decrypting the entry addressed to them. This gives real + * end-to-end confidentiality against the coordinator and relays; it does NOT yet + * provide MLS forward secrecy / post-compromise security — that arrives with the + * ts-mls upgrade. The seam (this file's two functions) does not change. + */ + +/** The subset of a NIP-44-capable signer we need. Matches window.nostr / NUser. */ +export interface Nip44Signer { + nip44: { + encrypt: (pubkey: string, plaintext: string) => Promise; + decrypt: (pubkey: string, ciphertext: string) => Promise; + }; +} + +/** Inner plaintext payload before encryption. */ +interface CordnPlaintext { + /** Message body. */ + t: string; + /** Sender pubkey (hex) — bound inside the ciphertext so it can't be spoofed. */ + s: string; + /** Client timestamp (unix seconds). */ + ts: number; +} + +/** The sealed content shape stored in the envelope event's `content`. */ +export interface CordnSealed { + v: 1; + /** Encryption scheme marker. `nip44-group` today; `mls` after the upgrade. */ + alg: 'nip44-group'; + /** recipientPubkey → NIP-44 ciphertext of the same CordnPlaintext. */ + recipients: Record; +} + +/** + * Seal a plaintext message for the whole roster. + * + * @param signer the sender's NIP-44 signer + * @param senderPk sender pubkey (hex) + * @param roster all recipient pubkeys (hex); the sender is added automatically + * @param text message body + */ +export async function sealGroupMessage( + signer: Nip44Signer, + senderPk: string, + roster: string[], + text: string, +): Promise { + const plaintext: CordnPlaintext = { t: text, s: senderPk, ts: Math.floor(Date.now() / 1000) }; + const json = JSON.stringify(plaintext); + + // Encrypt once per unique recipient (roster ∪ {sender}). + const targets = Array.from(new Set([senderPk.toLowerCase(), ...roster.map((r) => r.toLowerCase())])); + const recipients: Record = {}; + await Promise.all( + targets.map(async (pk) => { + recipients[pk] = await signer.nip44.encrypt(pk, json); + }), + ); + + return { v: 1, alg: 'nip44-group', recipients }; +} + +/** + * Open a sealed group message as `viewerPk`. Returns the plaintext body + the + * (verified) sender, or null if this viewer isn't a recipient / decryption fails. + */ +export async function openGroupMessage( + signer: Nip44Signer, + viewerPk: string, + sealed: CordnSealed, +): Promise<{ content: string; sender: string; ts: number } | null> { + if (sealed.v !== 1 || sealed.alg !== 'nip44-group') return null; + const ct = sealed.recipients[viewerPk.toLowerCase()]; + if (!ct) return null; + try { + const json = await signer.nip44.decrypt(viewerPk, ct); + const parsed = JSON.parse(json) as Partial; + if (typeof parsed.t !== 'string' || typeof parsed.s !== 'string') return null; + return { content: parsed.t, sender: parsed.s.toLowerCase(), ts: parsed.ts ?? Math.floor(Date.now() / 1000) }; + } catch { + return null; + } +} + +/** Safely parse an envelope's content into a CordnSealed, or null. */ +export function parseSealed(content: string): CordnSealed | null { + try { + const obj = JSON.parse(content) as Partial; + if (obj.v !== 1 || obj.alg !== 'nip44-group' || typeof obj.recipients !== 'object' || !obj.recipients) { + return null; + } + return obj as CordnSealed; + } catch { + return null; + } +} diff --git a/src/lib/cordn/endpoint.ts b/src/lib/cordn/endpoint.ts new file mode 100644 index 0000000..bfafeae --- /dev/null +++ b/src/lib/cordn/endpoint.ts @@ -0,0 +1,46 @@ +import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types'; +import type { CordnEndpoint } from './types'; + +/** Default ContextVM relay a coordinator listens on when only an https URL is given. */ +const DEFAULT_CVM_RELAY = 'wss://relay.contextvm.org'; + +/** + * Resolve a crew's `coordinator` config into a usable {@link CordnEndpoint}. + * + * Rules: + * - `wss://` / `ws://` URL → used directly as the coordinator's relay. + * - `https://` URL → the coordinator is addressed over the default + * ContextVM relay (messages are routed by the + * coordinator pubkey); the https URL remains the + * human/ops endpoint. We still need a relay to move + * ciphertext, so we fall back to the CVM relay. + * + * Returns null when the crew has no coordinator configured. + */ +export function resolveCordnEndpoint(crew: SkateCrew): CordnEndpoint | null { + const coord = crew.coordinator; + if (!coord?.url) return null; + + const groupId = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`; + let relayUrl: string; + + try { + const u = new URL(coord.url); + if (u.protocol === 'wss:' || u.protocol === 'ws:') { + relayUrl = u.href; + } else if (u.protocol === 'https:' || u.protocol === 'http:') { + // Coordinator exposed over http(s): route ciphertext via the CVM relay. + relayUrl = DEFAULT_CVM_RELAY; + } else { + return null; + } + } catch { + return null; + } + + return { + relayUrl, + coordinatorPubkey: coord.pubkey, + groupId, + }; +} diff --git a/src/lib/cordn/types.ts b/src/lib/cordn/types.ts new file mode 100644 index 0000000..ee6afba --- /dev/null +++ b/src/lib/cordn/types.ts @@ -0,0 +1,50 @@ +/** + * Cordn — crew encrypted group chat over an MLS Delivery Service coordinator. + * ───────────────────────────────────────────────────────────────────────────── + * A crew's PUBLIC identity stays on Nostr (kind 35006). Its PRIVATE group chat + * runs through a **Cordn coordinator**: a per-group delivery service reached over + * ContextVM. See NIP.md ("Private Crew Chat (Cordn / MLS)") and the crew's + * `coordinator` tag. + * + * This module is split into two clean layers so the crypto can be upgraded to a + * full RFC 9420 MLS ratchet (via `ts-mls`) WITHOUT touching transport or UI: + * + * 1. TRANSPORT (`client.ts`) — publish / fetch (with per-group cursor) / + * subscribe for group messages through the coordinator's ContextVM relay. + * 2. CRYPTO (`crypto.ts`) — seal/open a group message. Today this is + * NIP-44 group encryption keyed to the roster; the interface is exactly the + * seam an MLS engine slots into (encrypt-to-epoch / decrypt-from-epoch). + * + * Nothing here is a mock: messages really are encrypted, really are routed + * through the configured coordinator's relay, and really carry per-group cursors + * for bounded catch-up + live delivery. The one honest caveat is the crypto + * layer (group-key vs. MLS ratchet), surfaced explicitly in code and UI. + */ + +/** A decrypted crew chat message (matches the NIP-17 DirectMessage shape). */ +export interface CordnMessage { + id: string; + /** Sender pubkey (hex). */ + pubkey: string; + content: string; + /** Unix seconds. */ + createdAt: number; + /** Per-group monotonic cursor value for ordering / catch-up. */ + cursor: number; +} + +/** Resolved coordinator endpoint for a crew group. */ +export interface CordnEndpoint { + /** Coordinator relay URL (wss/https ContextVM relay the coordinator listens on). */ + relayUrl: string; + /** Coordinator service pubkey (hex), when advertised. */ + coordinatorPubkey?: string; + /** The crew coordinate `35006:pubkey:d` — the group id. */ + groupId: string; +} + +/** Kind used for Cordn group-message app envelopes routed via the coordinator. */ +export const CORDN_MESSAGE_KIND = 4497 as const; // regular event — a permanent message-stream item + +/** Namespace tag value marking an event as a Cordn crew-chat envelope. */ +export const CORDN_APP_TAG = 'skatespots.cordn.crew-chat'; diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index a851037..d587087 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -391,6 +391,7 @@ export default function CrewDetailPage() { {/* Chat */}