/** * 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 } from '@nostrify/nostrify'; import { finalizeEvent, generateSecretKey } from 'nostr-tools'; 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. * * The envelope is signed with a **fresh ephemeral key** (NIP-59 style), never * the sender's real key — so the coordinator/relay never learns who is talking. * Authorship is proven from *inside* the ciphertext (v2 signed author proof), * so hiding the outer identity costs nothing in trust. * * 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( endpoint: CordnEndpoint, sealedContent: string, ): Promise { const cursor = Date.now(); const ephemeralSk = generateSecretKey(); 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 finalizeEvent(tmpl, ephemeralSk) as NostrEvent; } /** 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) { // Never keep opening Amber after the chat query has been cancelled. if (signal.aborted) break; const sealed = parseSealed(ev.content); if (!sealed) continue; const opened = await openGroupMessage(signer, viewerPk, sealed, endpoint.groupId); if (!opened) continue; // Establish the true sender without trusting the coordinator: // - v2 (`verified`): the crypto layer already checked a signed author proof // inside the ciphertext, so the outer envelope key is an anonymous // ephemeral and we simply trust `opened.sender`. // - v1 (legacy): authorship was only bound to the outer envelope pubkey, so // we keep the original guard and reject mismatches. if (!opened.verified && 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: opened.sender, // real sender (proven), not the ephemeral envelope key 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; }