SkateSpots/src/lib/cordn/client.ts

123 lines
4.9 KiB
TypeScript

/**
* 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<NostrEvent[]>;
event: (event: NostrEvent, opts?: { signal: AbortSignal }) => Promise<void>;
}
/**
* 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<NostrEvent> {
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<void> {
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<CordnMessage[]> {
const { relay, endpoint, signer, viewerPk, limit = 200, sinceSeconds, signal } = params;
const filter: Record<string, unknown> = {
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;
}