diff --git a/AGENTS.md b/AGENTS.md index 78db36d..c58b925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1346,6 +1346,26 @@ session with **no other memory of this project**. So: **Not wired to any UI yet** — the app still uses the sealed-relay crew chat. Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request flow. Nothing imports the native module, so the web bundle is unchanged. + **Milestone 2 landed (2026-10-12):** the session now runs on the **viewer's own + signer** (NIP-07/Amber/nsec — no key held; `CordnSession.open({ pubkey, signer })`), + so the Cordn identity IS the user's pubkey. Added `idbStore.ts` + (`createCordnStore(pubkey)` — Dexie DB `SkateSpotsCordnDB`, one snapshot per + account, survives reload) and the **membership delivery** methods: + `requestJoin` / `fetchJoinRequests` / `fetchWelcomes` / `acceptWelcome` / + `addMemberByIdentifier` (+ auto `welcome_store` in `addMember`). **Verified live** + with a 3rd scenario (`join-request → approve → welcome`); ⚠️ `kp_take` accepts + the requester's **pubkey** (and a `kp_ref` also worked). UI: + `hooks/useCordnNative.ts` (opens/owns the session; **lazy-imports** the native + module so `@contextvm/sdk`+ts-mls land in their own ~678 kB async chunk, NOT the + 1.9 MB entry) + `components/crew/CordnNativePanel.tsx`, rendered as a **third + "Private chat" tab** on the crew page (founder/admin create the group + approve + requests; members ask to join + check for an invite). Strings in + `i18n/legacy/cordnUi.ts`. ⚠️ **Not yet exercised in a real browser** (the browser + tooling was disconnected this session) — the Node live tests + boot smoke pass, + but the panel's browser runtime (native WebSocket / IndexedDB) is unverified; + load the crew tab once before trusting it. For **interactive signers (Amber)** + polling is off (each ContextVM request would pop) — manual Refresh/Send, same + rule as the DM hooks. - **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links (Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx new file mode 100644 index 0000000..6a1c166 --- /dev/null +++ b/src/components/crew/CordnNativePanel.tsx @@ -0,0 +1,247 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { useTranslation } from '@/contexts/language'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useCordnNative } from '@/hooks/useCordnNative'; +import { shortNpub } from '@/lib/coordinator'; +import { formatDistanceToNow } from '@/lib/dateFnsI18n'; +import { cn } from '@/lib/utils'; +import type { SkateCrew } from '@/lib/types'; + +interface CordnNativePanelProps { + crew: SkateCrew; + /** Founder/admin — may create the group and approve join requests. */ + canManage: boolean; + /** Founder or listed member. */ + isMember: boolean; +} + +/** + * The crew's **native Cordn (MLS) chat** — real end-to-end encryption between + * members, routed through the crew's coordinator. See `lib/cordn/native/`. + */ +export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanelProps) { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const native = useCordnNative({ crew, enabled: !!user && isMember }); + const [draft, setDraft] = useState(''); + const scrollRef = useRef(null); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [native.messages.length]); + + const handleSend = useCallback(async () => { + const body = draft.trim(); + if (!body) return; + setDraft(''); + await native.send(body); + }, [draft, native]); + + if (!user) { + return } text={t('cordnUi.notLoggedIn')} />; + } + if (!isMember) { + return } text={t('cordnUi.membersOnly')} />; + } + if (!native.coordinator) { + return } text={t('cordnUi.noCoordinator')} />; + } + + const status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting'); + + return ( + + +
+ + {t('cordnUi.title')} + +
+ {native.group && ( + + {t('cordnUi.memberCount', { count: native.group.members.length })} + + )} + + {status} + +
+
+

{t('cordnUi.subtitle')}

+
+ + + {native.error && ( +
+ {t('cordnUi.errorPrefix')}: {native.error} +
+ )} + + {!native.group ? ( +
+

+ {canManage ? t('cordnUi.noGroupAdmin') : t('cordnUi.noGroupMember')} +

+
+ {canManage ? ( + + ) : ( + <> + + + + )} +
+
+ ) : ( + <> +
+ {native.messages.length === 0 ? ( +

{t('cordnUi.placeholder')}

+ ) : ( + native.messages.map((m) => ( +
+
+ {m.sender === user.pubkey ? t('cordnUi.you') : shortNpub(m.sender)} + {formatDistanceToNow(new Date(m.createdAt * 1000), { addSuffix: true })} +
+
+ {m.content} +
+
+ )) + )} +
+ +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + }} + placeholder={t('cordnUi.placeholder')} + className="flex-1" + /> + + +
+ + {native.interactiveHintVisible && ( +

{t('cordnUi.interactiveHint')}

+ )} + + {canManage && ( +
+
+ {t('cordnUi.requests')} + +
+ {native.requests.length === 0 ? ( +

{t('cordnUi.noRequests')}

+ ) : ( + native.requests.map((req) => ( +
+ {shortNpub(req.pk)} + +
+ )) + )} +
+ )} + + )} +
+
+ ); +} + +function Notice({ icon, text }: { icon: ReactNode; text: string }) { + return ( + + + {icon} + {text} + + + ); +} diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts new file mode 100644 index 0000000..cd2a557 --- /dev/null +++ b/src/hooks/useCordnNative.ts @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNostrLogin } from '@nostrify/react/login'; +import type { NostrSigner } from '@contextvm/sdk/core'; +import type { JoinRequest } from '@cordn/core'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useCoordinator } from '@/hooks/useCoordinator'; +import { SKATESPOTS_COORDINATOR } from '@/lib/coordinator'; +import { resolveCordnEndpoint } from '@/lib/cordn/endpoint'; +// Dexie-only (light); the heavy MLS/ContextVM module is imported lazily so it +// loads only when the private-chat tab is opened, and stays out of the crew-page +// chunk. +import { createCordnStore } from '@/lib/cordn/native/idbStore'; +import type { + CordnChatMessage, + CordnGroupInfo, + CordnSession, +} from '@/lib/cordn/native'; +import { isInteractiveSigner } from '@/lib/signerCapabilities'; +import type { SkateCrew } from '@/lib/types'; + +/** Resolved coordinator endpoint for the native (ContextVM) client. */ +export interface NativeCoordinator { + serverPubkey: string; + relays: string[]; +} + +/** + * Resolve the coordinator a crew's native MLS chat should use: the crew's own + * `coordinator` tag when it names a service pubkey, otherwise the viewer's + * Settings → Coordinator, otherwise SkateSpots' provided coordinator. + */ +export function resolveNativeCoordinator( + crew: SkateCrew, + fallback: { pubkey: string; relays: string[] } | null, +): NativeCoordinator | null { + const endpoint = resolveCordnEndpoint(crew); + const serverPubkey = + endpoint?.coordinatorPubkey ?? fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey; + if (!serverPubkey) return null; + const relays = endpoint + ? [endpoint.relayUrl] + : fallback?.relays?.length + ? fallback.relays + : SKATESPOTS_COORDINATOR.relays; + return { serverPubkey, relays }; +} + +export interface UseCordnNativeResult { + ready: boolean; + coordinator: NativeCoordinator | null; + /** The crew's MLS group id — the crew coordinate (deterministic for everyone). */ + groupId: string; + group: CordnGroupInfo | null; + messages: CordnChatMessage[]; + busy: boolean; + error: string | null; + /** Send a group message. */ + send: (text: string) => Promise; + /** Re-fetch messages (and adopt any new commits). */ + refresh: () => Promise; + /** Founder/admin: create the crew's MLS group. */ + createChat: () => Promise; + /** Non-admin: ask the coordinator to be added. */ + requestJoin: () => Promise; + /** Non-admin: collect a waiting welcome and join. */ + checkInvite: () => Promise; + /** Founder/admin: pending join requests for this group. */ + requests: JoinRequest[]; + loadRequests: () => Promise; + /** Founder/admin: add a requester (by pubkey) and deliver a welcome. */ + approve: (identifier: string) => Promise; + /** True for interactive signers (Amber) — manual refresh/send, no polling. */ + interactiveHintVisible: boolean; +} + +const POLL_MS = 6000; + +/** + * Drives a {@link CordnSession} for one crew: real MLS over the crew's Cordn + * coordinator, keyed to the viewer's own Nostr identity (the transport signs + * with the viewer's signer). State is persisted per account (IndexedDB), so a + * reload resumes the same MLS group. + */ +export function useCordnNative({ + crew, + enabled, +}: { + crew: SkateCrew | null; + enabled: boolean; +}): UseCordnNativeResult { + const { user } = useCurrentUser(); + const { logins } = useNostrLogin(); + const { coordinator: userCoordinator } = useCoordinator(); + + const coordinator = useMemo( + () => + crew + ? resolveNativeCoordinator( + crew, + userCoordinator ? { pubkey: userCoordinator.pubkey, relays: userCoordinator.relays } : null, + ) + : null, + [crew, userCoordinator], + ); + + const groupId = crew ? `35006:${crew.pubkey}:${crew.identifier}` : ''; + + const sessionRef = useRef(null); + const [ready, setReady] = useState(false); + const [group, setGroup] = useState(null); + const [messages, setMessages] = useState([]); + const [requests, setRequests] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const interactive = isInteractiveSigner(user, logins); + + // Stable primitives for the session effect — depending on the `user` object + // itself would re-open the session on every render if its identity churns. + const pubkey = user?.pubkey ?? null; + const signer = user?.signer; + + // Open the session when the crew, coordinator and identity are available. + useEffect(() => { + if (!enabled || !pubkey || !signer || !coordinator || !groupId) return; + let cancelled = false; + const session = import('@/lib/cordn/native').then(({ CordnSession: NativeSession }) => + NativeSession.open({ + coordinator: { serverPubkey: coordinator.serverPubkey, relays: coordinator.relays }, + pubkey, + signer: signer as unknown as NostrSigner, + store: createCordnStore(pubkey), + }), + ); + session + .then((opened) => { + if (cancelled) { + void opened.disconnect(); + return; + } + sessionRef.current = opened; + const found = opened.listGroups().find((g) => g.gid === groupId) ?? null; + setGroup(found); + setMessages(found ? opened.messages(found.gid) : []); + setReady(true); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + const s = sessionRef.current; + sessionRef.current = null; + setReady(false); + if (s) void s.disconnect(); + }; + }, [enabled, pubkey, signer, coordinator, groupId]); + + const refresh = useCallback(async () => { + const session = sessionRef.current; + if (!session || !group) return; + try { + await session.fetch(group.gid); + const next = session.messages(group.gid); + setMessages(next); + setGroup(session.listGroups().find((g) => g.gid === group.gid) ?? group); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [group]); + + // Live polling for silent signers only — an interactive signer would pop for + // every request, so those users refresh/send manually (same rule as the DM + // and sealed-relay chat hooks). + useEffect(() => { + if (!ready || !group || interactive) return; + const id = setInterval(() => void refresh(), POLL_MS); + return () => clearInterval(id); + }, [ready, group, interactive, refresh]); + + const run = useCallback(async (fn: () => Promise): Promise => { + setBusy(true); + setError(null); + try { + return await fn(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return undefined; + } finally { + setBusy(false); + } + }, []); + + const send = useCallback( + async (text: string) => { + const body = text.trim(); + const session = sessionRef.current; + if (!body || !session || !group) return; + await run(async () => { + await session.send(group.gid, body); + setMessages(session.messages(group.gid)); + }); + }, + [group, run], + ); + + const createChat = useCallback(async () => { + const session = sessionRef.current; + if (!session || !crew || !groupId) return; + await run(async () => { + const created = await session.createGroup({ groupId, name: crew.name }); + setGroup(created); + setMessages(session.messages(created.gid)); + }); + }, [crew, groupId, run]); + + const requestJoin = useCallback(async () => { + const session = sessionRef.current; + if (!session || !groupId) return; + await run(async () => { + await session.requestJoin(groupId); + }); + }, [groupId, run]); + + const checkInvite = useCallback(async () => { + const session = sessionRef.current; + if (!session || !crew || !groupId) return false; + const joined = await run(async () => { + const welcomes = await session.fetchWelcomes(); + if (welcomes.length === 0) return false; + const joinedGroup = await session.acceptWelcome(welcomes[0], crew.name); + setGroup(joinedGroup); + setMessages(session.messages(joinedGroup.gid)); + return true; + }); + return joined ?? false; + }, [crew, groupId, run]); + + const loadRequests = useCallback(async () => { + const session = sessionRef.current; + if (!session || !groupId) return; + await run(async () => { + const pending = await session.fetchJoinRequests(groupId); + setRequests(pending); + }); + }, [groupId, run]); + + const approve = useCallback( + async (identifier: string) => { + const session = sessionRef.current; + if (!session || !group) return; + await run(async () => { + await session.addMemberByIdentifier(group.gid, identifier); + const pending = await session.fetchJoinRequests(group.gid); + setRequests(pending); + }); + }, + [group, run], + ); + + return { + ready, + coordinator, + groupId, + group, + messages, + busy, + error, + send, + refresh, + createChat, + requestJoin, + checkInvite, + requests, + loadRequests, + approve, + interactiveHintVisible: interactive, + }; +} diff --git a/src/i18n/legacy/cordnUi.ts b/src/i18n/legacy/cordnUi.ts new file mode 100644 index 0000000..8c012f5 --- /dev/null +++ b/src/i18n/legacy/cordnUi.ts @@ -0,0 +1,67 @@ +/** + * `cordnUi.*` — the native Cordn (MLS) crew chat panel. Added to `legacy/` + * (the same `{ en, es }` fragment pattern as `notify.ts` — the flat key object, + * namespaced by `legacy/index.ts`). + */ +const cordnUi = { + en: { + title: 'Private chat', + subtitle: + 'End-to-end encrypted (MLS) across crew members. The coordinator routes ciphertext and metadata only — it never sees message contents.', + notLoggedIn: 'Log in to use the crew’s private chat.', + membersOnly: 'Only crew members can open the private chat.', + connecting: 'Connecting…', + noCoordinator: 'No coordinator is configured for this crew yet.', + noGroupAdmin: 'This crew’s private chat hasn’t been created yet.', + noGroupMember: 'You’re not in this crew’s private chat yet.', + create: 'Create the private chat', + requestJoin: 'Ask to join', + checkInvite: 'Check for invite', + requested: 'Request sent — a crew admin will approve it.', + noInvite: 'No invite waiting yet. Ask an admin to approve your request.', + placeholder: 'Message the crew…', + send: 'Send', + refresh: 'Refresh', + requests: 'Join requests', + checkRequests: 'Check requests', + approve: 'Add', + noRequests: 'No pending join requests.', + memberCount: '{{count}} in the group', + you: 'You', + ready: 'Connected · MLS', + errorPrefix: 'Couldn’t reach the coordinator', + interactiveHint: + 'Your signer asks for approval on each action — use Refresh and Send as needed.', + }, + es: { + title: 'Chat privado', + subtitle: + 'Cifrado de extremo a extremo (MLS) entre los miembros de la peña. El coordinador solo enruta cifrado y metadatos: nunca ve el contenido de los mensajes.', + notLoggedIn: 'Inicia sesión para usar el chat privado de la peña.', + membersOnly: 'Solo los miembros de la peña pueden abrir el chat privado.', + connecting: 'Conectando…', + noCoordinator: 'Esta peña aún no tiene un coordinador configurado.', + noGroupAdmin: 'El chat privado de esta peña aún no se ha creado.', + noGroupMember: 'Aún no estás en el chat privado de esta peña.', + create: 'Crear el chat privado', + requestJoin: 'Pedir entrar', + checkInvite: 'Buscar invitación', + requested: 'Solicitud enviada: un administrador la aprobará.', + noInvite: 'Aún no hay invitación. Pide a un administrador que apruebe tu solicitud.', + placeholder: 'Escribe a la peña…', + send: 'Enviar', + refresh: 'Actualizar', + requests: 'Solicitudes de entrada', + checkRequests: 'Ver solicitudes', + approve: 'Añadir', + noRequests: 'No hay solicitudes pendientes.', + memberCount: '{{count}} en el grupo', + you: 'Tú', + ready: 'Conectado · MLS', + errorPrefix: 'No se pudo contactar con el coordinador', + interactiveHint: + 'Tu firmante pide aprobación en cada acción: usa Actualizar y Enviar cuando haga falta.', + }, +}; + +export default cordnUi; diff --git a/src/i18n/legacy/index.ts b/src/i18n/legacy/index.ts index 422d909..8a4df8f 100644 --- a/src/i18n/legacy/index.ts +++ b/src/i18n/legacy/index.ts @@ -4,6 +4,7 @@ import weather from './weather'; import skaterLabels from './skaterLabels'; import notify from './notify'; import misc from './misc'; +import cordnUi from './cordnUi'; /** * Namespaces added by the hardcoded-string sweep (2026-10-07), spread into @@ -17,6 +18,7 @@ export const legacyEn = { skaterLabelsUi: skaterLabels.en, notifyUi: notify.en, legacyMiscUi: misc.en, + cordnUi: cordnUi.en, }; export const legacyEs = { @@ -26,4 +28,5 @@ export const legacyEs = { skaterLabelsUi: skaterLabels.es, notifyUi: notify.es, legacyMiscUi: misc.es, + cordnUi: cordnUi.es, }; diff --git a/src/lib/cordn/native/coordinator.ts b/src/lib/cordn/native/coordinator.ts index 793356b..965330c 100644 --- a/src/lib/cordn/native/coordinator.ts +++ b/src/lib/cordn/native/coordinator.ts @@ -6,6 +6,8 @@ * `@cordn/core`'s wire schemas. The coordinator routes **opaque MLS ciphertext** * plus metadata (which group, when) and never sees plaintext. * + * The transport signs its Nostr requests with the viewer's **own signer** (NIP-07 + * / Amber / nsec), so the Cordn identity is the user's pubkey. No key is stored. * Reimplemented from the Cordn wire contract — not copied from `@cordn/cli`. */ @@ -16,21 +18,37 @@ import { NostrClientTransport, PrivateKeySigner, } from '@contextvm/sdk'; +import type { NostrSigner } from '@contextvm/sdk/core'; import { COORDINATOR_METHODS, consumeKeyPackageOutputSchema, decodeBase64, decodeKeyPackage, fetchManyGroupMessagesOutputSchema, + fetchManyPendingJoinRequestsOutputSchema, + fetchPendingWelcomesOutputSchema, groupMessageSchema, listAvailableKeyPackagesOutputSchema, postGroupMessageOutputSchema, publishKeyPackageOutputSchema, + removeKeyPackagesOutputSchema, + storeJoinRequestOutputSchema, + storeWelcomeOutputSchema, type FetchManyGroupMessagesOutput, + type FetchManyPendingJoinRequestsInput, + type FetchManyPendingJoinRequestsOutput, + type FetchPendingWelcomesInput, + type FetchPendingWelcomesOutput, type GroupMessage, type ListAvailableKeyPackagesOutput, type PostGroupMessageOutput, type PublishKeyPackageOutput, + type RemoveKeyPackagesInput, + type RemoveKeyPackagesOutput, + type StoreJoinRequestInput, + type StoreJoinRequestOutput, + type StoreWelcomeInput, + type StoreWelcomeOutput, } from '@cordn/core'; import type { KeyPackage } from 'ts-mls'; @@ -48,8 +66,10 @@ export interface CordnCoordinatorConfig { serverPubkey: string; /** ContextVM relays that reach the coordinator. */ relays: string[]; - /** Client identity secret (hex). */ - privateKeyHex: string; + /** Signer for the transport (the viewer's Nostr signer, or a local test key). */ + signer?: NostrSigner; + /** Local secret (hex) — used instead of `signer` for tests / device keys. */ + privateKeyHex?: string; /** Gift-wrap requests so relays cannot read the method/group id. */ encryptTransport?: boolean; } @@ -60,10 +80,11 @@ export class CordnCoordinatorClient { private readonly connected: Promise; constructor(config: CordnCoordinatorConfig) { + const signer = config.signer ?? new PrivateKeySigner(config.privateKeyHex ?? ''); this.client = new Client({ name: 'SkateSpotsCordn', version: '1.0.0' }); this.transport = new NostrClientTransport({ serverPubkey: config.serverPubkey, - signer: new PrivateKeySigner(config.privateKeyHex), + signer, relayHandler: new ApplesauceRelayPool(config.relays), isStateless: true, logLevel: 'silent', @@ -90,7 +111,8 @@ export class CordnCoordinatorClient { return schema.parse(result.structuredContent); } - /** Publish a key package for others to add this identity to a group. */ + /* ── key packages ───────────────────────────────────────────────────────── */ + async publishKeyPackage(kpRef: string, keyPackageBase64: string): Promise { return this.call( COORDINATOR_METHODS.publishKeyPackage, @@ -99,12 +121,10 @@ export class CordnCoordinatorClient { ); } - /** List the key packages other members have published. */ async listAvailableKeyPackages(): Promise { return this.call(COORDINATOR_METHODS.listAvailableKeyPackages, {}, listAvailableKeyPackagesOutputSchema); } - /** Fetch a member's full key package and mark it consumed for this identity. */ async consumeKeyPackage(id: string): Promise { const result = await this.call( COORDINATOR_METHODS.consumeKeyPackage, @@ -115,7 +135,12 @@ export class CordnCoordinatorClient { return decodeConsumedKeyPackage(result.keyPackage); } - /** Post a sealed MLS message to a group; returns the stream cursor. */ + async removeKeyPackages(input: RemoveKeyPackagesInput): Promise { + return this.call(COORDINATOR_METHODS.removeKeyPackages, { ...input }, removeKeyPackagesOutputSchema); + } + + /* ── group messages ─────────────────────────────────────────────────────── */ + async postGroupMessage(gid: string, sealedBase64: string): Promise { return this.call( COORDINATOR_METHODS.postGroupMessage, @@ -124,7 +149,6 @@ export class CordnCoordinatorClient { ); } - /** Fetch group messages after a cursor (one entry per group). */ async fetchManyGroupMessages( groups: Array<{ gid: string; after?: number }>, ): Promise { @@ -136,6 +160,32 @@ export class CordnCoordinatorClient { return { messages: result.messages.map((m) => groupMessageSchema.parse(m)) as GroupMessage[] }; } + /* ── welcomes (membership delivery) ─────────────────────────────────────── */ + + async storeWelcome(input: StoreWelcomeInput): Promise { + return this.call(COORDINATOR_METHODS.storeWelcome, { ...input }, storeWelcomeOutputSchema); + } + + async fetchPendingWelcomes(input: FetchPendingWelcomesInput = {}): Promise { + return this.call(COORDINATOR_METHODS.fetchPendingWelcomes, { ...input }, fetchPendingWelcomesOutputSchema); + } + + /* ── join requests (ask to be added) ────────────────────────────────────── */ + + async storeJoinRequest(input: StoreJoinRequestInput): Promise { + return this.call(COORDINATOR_METHODS.storeJoinRequest, { ...input }, storeJoinRequestOutputSchema); + } + + async fetchManyPendingJoinRequests( + input: FetchManyPendingJoinRequestsInput, + ): Promise { + return this.call( + COORDINATOR_METHODS.fetchManyPendingJoinRequests, + { ...input }, + fetchManyPendingJoinRequestsOutputSchema, + ); + } + async disconnect(): Promise { await this.connected.catch(() => undefined); await this.transport.close().catch(() => undefined); diff --git a/src/lib/cordn/native/idbStore.ts b/src/lib/cordn/native/idbStore.ts new file mode 100644 index 0000000..02b2138 --- /dev/null +++ b/src/lib/cordn/native/idbStore.ts @@ -0,0 +1,54 @@ +/** + * Cordn native — IndexedDB-backed session store. + * ───────────────────────────────────────────────────────────────────────────── + * Persists one Cordn session snapshot per account, so a member's MLS state + * (groups, epochs, message history, key packages) survives a reload. Kept out of + * `session.ts` on purpose: the session stays isomorphic, and Dexie is pulled in + * only here (browser-side). + * + * ⚠️ The snapshot contains MLS group state and private key-package material — + * it is as sensitive as the identity. It is device-local and never published. + */ + +import Dexie, { type Table } from 'dexie'; +import type { CordnSnapshot, CordnStore } from './session'; + +interface CordnStateRow { + pubkey: string; + snapshot: CordnSnapshot; + updatedAt: number; +} + +class SkateSpotsCordnDB extends Dexie { + states!: Table; + + constructor() { + super('SkateSpotsCordnDB'); + this.version(1).stores({ + states: 'pubkey, updatedAt', + }); + } +} + +let db: SkateSpotsCordnDB | null = null; + +function getDB(): SkateSpotsCordnDB { + db ??= new SkateSpotsCordnDB(); + return db; +} + +/** A per-account Cordn session store backed by IndexedDB. */ +export function createCordnStore(accountPubkey: string): CordnStore { + return { + async load(): Promise { + const row = await getDB().states.get(accountPubkey); + return row?.snapshot ?? null; + }, + async save(snapshot: CordnSnapshot): Promise { + await getDB().states.put({ pubkey: accountPubkey, snapshot, updatedAt: Date.now() }); + }, + async clear(): Promise { + await getDB().states.delete(accountPubkey); + }, + }; +} diff --git a/src/lib/cordn/native/index.ts b/src/lib/cordn/native/index.ts index 165f477..165a156 100644 --- a/src/lib/cordn/native/index.ts +++ b/src/lib/cordn/native/index.ts @@ -28,6 +28,8 @@ export { type StoredKeyPackage, } from './session'; +export { createCordnStore } from './idbStore'; + export { CordnCoordinatorClient, type CordnCoordinatorConfig, @@ -35,6 +37,7 @@ export { export { createIdentity, + identityFromPubkey, createKeyPackage, serializeState, deserializeState, diff --git a/src/lib/cordn/native/mls.ts b/src/lib/cordn/native/mls.ts index 9dadc74..5421d37 100644 --- a/src/lib/cordn/native/mls.ts +++ b/src/lib/cordn/native/mls.ts @@ -84,12 +84,12 @@ export function hexToBytes(hex: string): Uint8Array { /* ── identity ─────────────────────────────────────────────────────────────── */ -/** A device identity: the Nostr secret is also the MLS credential identity. */ +/** A device identity: the Nostr pubkey is the MLS credential identity. */ export interface CordnMlsIdentity { - /** 32-byte secret, hex. */ - privateKeyHex: string; - /** The "stable pubkey" — the Nostr pubkey derived from the secret. */ + /** The "stable pubkey" — the Nostr pubkey that owns this identity. */ pubkey: string; + /** 32-byte secret, hex — present only for local/test identities. */ + privateKeyHex?: string; } export function createIdentity(privateKeyHex?: string): CordnMlsIdentity { @@ -100,6 +100,11 @@ export function createIdentity(privateKeyHex?: string): CordnMlsIdentity { return { privateKeyHex: hex, pubkey: getPublicKey(secret) }; } +/** An identity backed by the viewer's signer (no secret is held). */ +export function identityFromPubkey(pubkey: string): CordnMlsIdentity { + return { pubkey }; +} + /* ── ciphersuite (cached) ─────────────────────────────────────────────────── */ let cipherSuitePromise: ReturnType | undefined; @@ -164,6 +169,21 @@ export function keyPackageSupportsCordnMetadata(keyPackage: KeyPackage): boolean ); } +/** The stable pubkey (credential identity) a key package belongs to. */ +export function keyPackageStablePubkey(keyPackage: KeyPackage): string { + const credential = keyPackage.leafNode.credential; + if (credential && 'identity' in credential && credential.identity) { + return textDecoder.decode(credential.identity); + } + throw new Error('Key package has no basic credential identity'); +} + +/** Compute a key package's reference (the coordinator's `kp_ref`). */ +export async function keyPackageRefOf(keyPackage: KeyPackage): Promise { + const cipherSuite = await getCipherSuite(); + return bytesToHex(await makeKeyPackageRef(keyPackage, cipherSuite.hash)); +} + /* ── state serialization ──────────────────────────────────────────────────── */ export function serializeState(state: ClientState): string { diff --git a/src/lib/cordn/native/native.live.test.ts b/src/lib/cordn/native/native.live.test.ts index 07b2269..f32c562 100644 --- a/src/lib/cordn/native/native.live.test.ts +++ b/src/lib/cordn/native/native.live.test.ts @@ -51,4 +51,75 @@ describe.skipIf(!live)('cordn native (live)', () => { await a.disconnect(); await b.disconnect(); }, 180_000); + + it('delivers membership through the coordinator (list/take + welcome take)', async () => { + const coordinator = { + serverPubkey: SKATESPOTS_COORDINATOR.pubkey, + relays: SKATESPOTS_COORDINATOR.relays, + }; + + const a = await CordnSession.open({ coordinator, store: new MemoryCordnStore() }); + const b = await CordnSession.open({ coordinator, store: new MemoryCordnStore() }); + + await a.generateKeyPackage('a'); + const bKeyPackage = await b.generateKeyPackage('b'); + await a.publishKeyPackage('a'); + await b.publishKeyPackage('b'); + + const group = await a.createGroup({ groupId: `skate-native-${Date.now()}`, name: 'Join test' }); + + // A discovers B's published key package on the coordinator, then adds by ref. + const available = await a.listAvailableKeyPackages(); + const bEntry = available.find((kp) => kp.pk === b.pubkey); + expect(bEntry, 'B key package should be listed').toBeTruthy(); + await a.addMemberByIdentifier(group.gid, bEntry!.kp_ref); + + // B collects the welcome from the coordinator and joins. + const welcomes = await b.fetchWelcomes(); + const mine = welcomes.find((w) => w.kp_ref === bKeyPackage.keyPackageRef); + expect(mine, 'welcome should be waiting for B').toBeTruthy(); + const bGroup = await b.acceptWelcome(mine!, 'Join test'); + expect(bGroup.members.map((m) => m.pubkey)).toEqual(expect.arrayContaining([a.pubkey, b.pubkey])); + + await a.send(group.gid, 'membership hello'); + const received = await b.fetch(bGroup.gid); + expect(received.map((m) => m.content)).toContain('membership hello'); + + await a.disconnect(); + await b.disconnect(); + }, 180_000); + + it('runs the join-request → approve → welcome flow', async () => { + const coordinator = { + serverPubkey: SKATESPOTS_COORDINATOR.pubkey, + relays: SKATESPOTS_COORDINATOR.relays, + }; + + const a = await CordnSession.open({ coordinator, store: new MemoryCordnStore() }); + const b = await CordnSession.open({ coordinator, store: new MemoryCordnStore() }); + + await a.generateKeyPackage('a'); + await b.generateKeyPackage('b'); + await a.publishKeyPackage('a'); + await b.publishKeyPackage('b'); + + const group = await a.createGroup({ groupId: `skate-native-${Date.now()}`, name: 'Request test' }); + + // B asks to join; A sees the request and approves it. + await b.requestJoin(group.gid); + const requests = await a.fetchJoinRequests(group.gid); + const req = requests.find((r) => r.pk === b.pubkey) ?? requests[0]; + expect(req, 'join request should be visible to the admin').toBeTruthy(); + + // The coordinator's `kp_take` accepts the requester's pubkey. + await a.addMemberByIdentifier(group.gid, req.pk); + + const welcomes = await b.fetchWelcomes(); + expect(welcomes.length).toBeGreaterThan(0); + const bGroup = await b.acceptWelcome(welcomes[0], 'Request test'); + expect(bGroup.members.map((m) => m.pubkey)).toEqual(expect.arrayContaining([a.pubkey, b.pubkey])); + + await a.disconnect(); + await b.disconnect(); + }, 180_000); }); diff --git a/src/lib/cordn/native/session.ts b/src/lib/cordn/native/session.ts index 2bfa85f..fed2956 100644 --- a/src/lib/cordn/native/session.ts +++ b/src/lib/cordn/native/session.ts @@ -2,13 +2,14 @@ * Cordn native — session. * ───────────────────────────────────────────────────────────────────────────── * A browser-safe Cordn client session: identity, key packages, group creation, - * MLS message send/receive and bounded catch-up, all against a coordinator. This - * is the "Level 2" foundation — real RFC 9420 MLS, not the sealed-relay - * stand-in in `../client.ts`. + * membership (welcome / join-request), MLS message send/receive and bounded + * catch-up, all against a coordinator. This is the "Level 2" foundation — real + * RFC 9420 MLS, not the sealed-relay stand-in in `../client.ts`. * - * The session is isomorphic (no Node built-ins, no DOM); persistence is behind - * the injected {@link CordnStore} so the browser can back it with IndexedDB and - * tests with {@link MemoryCordnStore}. + * The session is isomorphic (no Node built-ins, no DOM); it can run against the + * viewer's own signer (no secret held) or a local test key. Persistence is + * behind the injected {@link CordnStore} so the browser backs it with IndexedDB + * and tests with {@link MemoryCordnStore}. */ import { @@ -26,7 +27,10 @@ import { serializeState, deserializeState, serializePrivateKeyPackage, + keyPackageStablePubkey, + keyPackageRefOf, createIdentity, + identityFromPubkey, causalPrevTags, type CordnMlsIdentity, } from './mls'; @@ -39,7 +43,11 @@ import { encodeBase64, encodeKeyPackage, encodeWelcome, + type PendingWelcome, + type JoinRequest, + type AvailableKeyPackage, } from '@cordn/core'; +import type { NostrSigner } from '@contextvm/sdk/core'; import type { ClientState, KeyPackage, PrivateKeyPackage } from 'ts-mls'; /* ── stored shapes ────────────────────────────────────────────────────────── */ @@ -88,7 +96,9 @@ interface RuntimeGroup { export interface CordnSnapshot { version: 1; - privateKeyHex: string; + pubkey: string; + /** Present only for local/test identities (never for a signer session). */ + privateKeyHex?: string; keyPackages: Array<{ alias: string; keyPackageBase64: string; @@ -131,7 +141,11 @@ export class MemoryCordnStore implements CordnStore { export interface CordnSessionOptions { coordinator: Pick; - /** Existing secret (hex); omit to mint a fresh identity. */ + /** The viewer's pubkey — the MLS identity. */ + pubkey?: string; + /** The viewer's Nostr signer (NIP-07 / Amber / nsec); used for transport. */ + signer?: NostrSigner; + /** Existing secret (hex) for a local/test identity. */ privateKeyHex?: string; store?: CordnStore; encryptTransport?: boolean; @@ -152,14 +166,26 @@ export class CordnSession { this.store = options.store ?? new MemoryCordnStore(); } - /** Open a session, restoring persisted state when a store has it. */ static async open(options: CordnSessionOptions): Promise { const store = options.store ?? new MemoryCordnStore(); const snapshot = await store.load(); - const identity = createIdentity(options.privateKeyHex ?? snapshot?.privateKeyHex); + let identity: CordnMlsIdentity; + if (options.signer && options.pubkey) { + identity = identityFromPubkey(options.pubkey); + } else if (options.privateKeyHex) { + identity = createIdentity(options.privateKeyHex); + } else if (options.pubkey) { + identity = identityFromPubkey(options.pubkey); + } else if (snapshot) { + identity = snapshot.privateKeyHex + ? createIdentity(snapshot.privateKeyHex) + : identityFromPubkey(snapshot.pubkey); + } else { + identity = createIdentity(); + } const session = new CordnSession(identity, { ...options, store }); - if (snapshot) session.restore(snapshot); + if (snapshot && snapshot.pubkey === identity.pubkey) session.restore(snapshot); return session; } @@ -171,6 +197,7 @@ export class CordnSession { this.client ??= new CordnCoordinatorClient({ serverPubkey: this.options.coordinator.serverPubkey, relays: this.options.coordinator.relays, + signer: this.options.signer, privateKeyHex: this.identity.privateKeyHex, encryptTransport: this.options.encryptTransport, }); @@ -212,6 +239,7 @@ export class CordnSession { private snapshot(): CordnSnapshot { return { version: 1, + pubkey: this.identity.pubkey, privateKeyHex: this.identity.privateKeyHex, keyPackages: this.keyPackages.map((kp) => ({ alias: kp.alias, @@ -245,6 +273,12 @@ export class CordnSession { return this.keyPackages; } + /** Key packages other identities have published to the coordinator. */ + async listAvailableKeyPackages(): Promise { + const result = await this.getClient().listAvailableKeyPackages(); + return result.keyPackages; + } + /** Generate (and store) a key package others can use to add this identity. */ async generateKeyPackage(alias = `kp-${this.keyPackages.length + 1}`): Promise { const created = await createKeyPackage(this.identity.pubkey); @@ -325,12 +359,13 @@ export class CordnSession { } /** - * Add a member from their key package and return the Welcome the member needs - * to join. The commit is posted to the coordinator and the local epoch adopts - * the new state. + * Add a member from their key package, post the commit, and store the Welcome + * on the coordinator for them to collect. */ async addMember(gid: string, memberKeyPackage: KeyPackage): Promise<{ welcomeBase64: string }> { const group = this.requireGroup(gid); + const targetPubkey = keyPackageStablePubkey(memberKeyPackage); + const targetKpRef = await keyPackageRefOf(memberKeyPackage); const { newState, commitMessageBase64, welcome } = await addMemberToGroup({ state: group.state, memberKeyPackage, @@ -342,8 +377,22 @@ export class CordnSession { group.posted.add(sealed); group.state = newState; group.cursor = Math.max(group.cursor, posted.cursor); + + const welcomeBase64 = encodeBase64(encodeWelcome(welcome)); + await this.getClient().storeWelcome({ + target_pk: targetPubkey, + kp_ref: targetKpRef, + welcome_64: welcomeBase64, + after: posted.cursor, + }); await this.persist(); - return { welcomeBase64: encodeBase64(encodeWelcome(welcome)) }; + return { welcomeBase64 }; + } + + /** Add a member by identifier (a consumed key package ref / pubkey). */ + async addMemberByIdentifier(gid: string, identifier: string): Promise<{ welcomeBase64: string }> { + const memberKeyPackage = await this.getClient().consumeKeyPackage(identifier); + return this.addMember(gid, memberKeyPackage); } /** Join a group from a Welcome produced by {@link addMember}. */ @@ -351,6 +400,7 @@ export class CordnSession { welcomeBase64: string; keyPackage: StoredKeyPackage; name?: string; + after?: number; }): Promise { const state = await joinFromWelcome({ welcome: decodeWelcome(decodeBase64(params.welcomeBase64), 'welcome'), @@ -363,7 +413,7 @@ export class CordnSession { gid, name: params.name, state, - cursor: 0, + cursor: params.after ?? 0, messages: [], posted: new Set(), }; @@ -372,6 +422,46 @@ export class CordnSession { return this.describeGroup(group); } + /* ── membership delivery ────────────────────────────────────────────────── */ + + /** Ask the coordinator to be added to a group (publishes a key package). */ + async requestJoin(gid: string, alias?: string): Promise { + let keyPackage = alias + ? this.keyPackages.find((kp) => kp.alias === alias) + : this.keyPackages.find((kp) => !kp.consumed && kp.publishedAt !== undefined); + if (!keyPackage || keyPackage.publishedAt === undefined) { + keyPackage = await this.publishKeyPackage(alias); + } + await this.getClient().storeJoinRequest({ gid, kp_ref: keyPackage.keyPackageRef }); + return keyPackage; + } + + /** Pending join requests for a group (for an admin to approve). */ + async fetchJoinRequests(gid: string): Promise { + const result = await this.getClient().fetchManyPendingJoinRequests({ groups: [{ gid }] }); + return result.requests; + } + + /** Welcomes waiting for this identity on the coordinator. */ + async fetchWelcomes(): Promise { + const result = await this.getClient().fetchPendingWelcomes({}); + return result.welcomes; + } + + /** Accept a pending Welcome whose key package this identity still holds. */ + async acceptWelcome(welcome: PendingWelcome, name?: string): Promise { + const keyPackage = + this.keyPackages.find((kp) => kp.keyPackageRef === welcome.kp_ref && !kp.consumed) ?? + this.keyPackages.find((kp) => kp.keyPackageRef === welcome.kp_ref); + if (!keyPackage) throw new Error('No local key package matches this welcome'); + return this.joinGroup({ + welcomeBase64: welcome.welcome_64, + keyPackage, + name, + after: welcome.after ?? undefined, + }); + } + /* ── messaging ──────────────────────────────────────────────────────────── */ messages(gid: string): CordnChatMessage[] { @@ -446,4 +536,3 @@ export class CordnSession { return received; } } - diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 307448c..50ef2d8 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -22,7 +22,7 @@ import { crewAccent } from '@/lib/crewHelpers'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useCrewChatUnread } from '@/hooks/useCrewChatUnread'; import { CrewChat } from '@/components/CrewChat'; -import { CrewAgentPanel } from '@/components/CrewAgentPanel'; +import { CordnNativePanel } from '@/components/crew/CordnNativePanel';import { CrewAgentPanel } from '@/components/CrewAgentPanel'; import { CREW_AGENT_AVATAR } from '@/lib/crewAgent'; import { CrewTreasuryCard } from '@/components/CrewTreasuryCard'; import { CrewLookingForCard } from '@/components/CrewLookingFor'; @@ -49,7 +49,7 @@ import { SKATE_CREW_KIND, SHOW_CREW_TREASURY, CREW_LOOKING_FOR_LABELS, type Crew import { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import NotFound from './NotFound'; -import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music } from 'lucide-react'; +import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, Music, Lock } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; export default function CrewDetailPage() { @@ -275,7 +275,7 @@ export default function CrewDetailPage() { {/* Activity — one clean tabbed surface instead of three stacked cards */} - + {t('socialUi.crew.eventsTab')} {crewEvents && crewEvents.length > 0 && ( @@ -292,6 +292,9 @@ export default function CrewDetailPage() { /> )} + + {t('cordnUi.title')} + {/* Events */} @@ -326,6 +329,11 @@ export default function CrewDetailPage() { canModerate={canManage} /> + + {/* Private chat — native Cordn / MLS (real end-to-end encryption). */} + + +