Cordn native UI: crew MLS chat tab, IndexedDB store, membership flow

Milestone 2 for the native Cordn client.

- Session now runs on the viewer's own signer (NIP-07/Amber/nsec) instead of a
  raw key, so the Cordn identity is the user's pubkey. Added idbStore.ts
  (Dexie 'SkateSpotsCordnDB', one snapshot per account) so MLS state survives a
  reload, plus membership delivery: requestJoin / fetchJoinRequests /
  fetchWelcomes / acceptWelcome / addMemberByIdentifier, and addMember now also
  stores the Welcome on the coordinator for the target.
- useCordnNative owns the session lifecycle and lazy-imports the native module,
  keeping @contextvm/sdk + ts-mls in a separate ~678 kB async chunk (entry
  bundle unchanged at 1.9 MB).
- CordnNativePanel: a third 'Private chat' tab on the crew page — founder/admin
  create the group and approve join requests; members ask to join and check for
  an invite; live message list + composer (polling only for silent signers).
- Strings in i18n/legacy/cordnUi.ts (en/es).

Verified live against our coordinator with a new scenario
(join-request -> approve -> welcome) plus the existing two; npm test + smoke
pass. Not yet exercised in a real browser (browser tooling was offline this
session) — the dynamic-import failure path degrades to an inline error.
This commit is contained in:
SkateSpots 2026-10-12 22:30:15 +07:00
parent 1dcaf8cea3
commit 383fb07127
12 changed files with 944 additions and 32 deletions

View file

@ -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

View file

@ -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<HTMLDivElement>(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 <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
}
if (!isMember) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.membersOnly')} />;
}
if (!native.coordinator) {
return <Notice icon={<KeyRound className="h-5 w-5" />} text={t('cordnUi.noCoordinator')} />;
}
const status = native.ready ? t('cordnUi.ready') : t('cordnUi.connecting');
return (
<Card className="border-white/10 bg-black/40">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Lock className="h-4 w-4 text-teal-400" /> {t('cordnUi.title')}
</CardTitle>
<div className="flex items-center gap-2">
{native.group && (
<Badge variant="secondary" className="text-[10px]">
{t('cordnUi.memberCount', { count: native.group.members.length })}
</Badge>
)}
<Badge
variant="outline"
className={cn(
'text-[10px]',
native.ready ? 'border-teal-500/40 text-teal-300' : 'text-gray-400',
)}
>
{status}
</Badge>
</div>
</div>
<p className="pt-1 text-xs text-gray-500">{t('cordnUi.subtitle')}</p>
</CardHeader>
<CardContent className="space-y-3">
{native.error && (
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{t('cordnUi.errorPrefix')}: {native.error}
</div>
)}
{!native.group ? (
<div className="space-y-3 py-4 text-center">
<p className="text-sm text-gray-400">
{canManage ? t('cordnUi.noGroupAdmin') : t('cordnUi.noGroupMember')}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{canManage ? (
<Button
size="sm"
disabled={!native.ready || native.busy}
onClick={() => void native.createChat()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
<Plus className="h-3.5 w-3.5" /> {t('cordnUi.create')}
</Button>
) : (
<>
<Button
size="sm"
disabled={!native.ready || native.busy}
onClick={() => void native.requestJoin()}
className="gap-1.5 bg-teal-500 font-semibold text-black hover:bg-teal-400"
>
<UserPlus className="h-3.5 w-3.5" /> {t('cordnUi.requestJoin')}
</Button>
<Button
size="sm"
variant="outline"
disabled={!native.ready || native.busy}
onClick={() => void native.checkInvite()}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" /> {t('cordnUi.checkInvite')}
</Button>
</>
)}
</div>
</div>
) : (
<>
<div
ref={scrollRef}
className="max-h-80 space-y-2 overflow-y-auto rounded-md border border-white/10 bg-black/30 p-3"
>
{native.messages.length === 0 ? (
<p className="py-6 text-center text-xs text-gray-500">{t('cordnUi.placeholder')}</p>
) : (
native.messages.map((m) => (
<div
key={m.id}
className={cn('flex flex-col gap-0.5', m.direction === 'outbound' && 'items-end')}
>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-gray-500">
<span>{m.sender === user.pubkey ? t('cordnUi.you') : shortNpub(m.sender)}</span>
<span>{formatDistanceToNow(new Date(m.createdAt * 1000), { addSuffix: true })}</span>
</div>
<div
className={cn(
'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-1.5 text-sm',
m.direction === 'outbound'
? 'bg-teal-500/20 text-teal-100'
: 'bg-white/10 text-gray-100',
)}
>
{m.content}
</div>
</div>
))
)}
</div>
<div className="flex items-center gap-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
placeholder={t('cordnUi.placeholder')}
className="flex-1"
/>
<Button
size="icon"
disabled={native.busy || !draft.trim()}
onClick={() => void handleSend()}
aria-label={t('cordnUi.send')}
>
<Send className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="outline"
onClick={() => void native.refresh()}
aria-label={t('cordnUi.refresh')}
>
<RefreshCw className={cn('h-4 w-4', native.busy && 'animate-spin')} />
</Button>
</div>
{native.interactiveHintVisible && (
<p className="text-[11px] text-gray-500">{t('cordnUi.interactiveHint')}</p>
)}
{canManage && (
<div className="space-y-2 rounded-md border border-white/10 bg-black/20 p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-300">{t('cordnUi.requests')}</span>
<Button
size="sm"
variant="ghost"
disabled={native.busy}
onClick={() => void native.loadRequests()}
className="h-7 gap-1 text-xs"
>
<RefreshCw className="h-3 w-3" /> {t('cordnUi.checkRequests')}
</Button>
</div>
{native.requests.length === 0 ? (
<p className="px-1 text-[11px] text-gray-500">{t('cordnUi.noRequests')}</p>
) : (
native.requests.map((req) => (
<div key={`${req.pk}-${req.kp_ref}`} className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-gray-300">{shortNpub(req.pk)}</span>
<Button
size="sm"
disabled={native.busy}
onClick={() => void native.approve(req.pk)}
className="h-7 gap-1 text-xs"
>
<UserPlus className="h-3 w-3" /> {t('cordnUi.approve')}
</Button>
</div>
))
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
}
function Notice({ icon, text }: { icon: ReactNode; text: string }) {
return (
<Card className="border-dashed border-white/10 bg-black/40">
<CardContent className="flex items-center gap-3 py-8 text-sm text-gray-400">
<span className="text-teal-400">{icon}</span>
{text}
</CardContent>
</Card>
);
}

280
src/hooks/useCordnNative.ts Normal file
View file

@ -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<void>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
/** Founder/admin: create the crew's MLS group. */
createChat: () => Promise<void>;
/** Non-admin: ask the coordinator to be added. */
requestJoin: () => Promise<void>;
/** Non-admin: collect a waiting welcome and join. */
checkInvite: () => Promise<boolean>;
/** Founder/admin: pending join requests for this group. */
requests: JoinRequest[];
loadRequests: () => Promise<void>;
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
approve: (identifier: string) => Promise<void>;
/** 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<CordnSession | null>(null);
const [ready, setReady] = useState(false);
const [group, setGroup] = useState<CordnGroupInfo | null>(null);
const [messages, setMessages] = useState<CordnChatMessage[]>([]);
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
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,
};
}

View file

@ -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;

View file

@ -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,
};

View file

@ -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<void>;
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<PublishKeyPackageOutput> {
return this.call(
COORDINATOR_METHODS.publishKeyPackage,
@ -99,12 +121,10 @@ export class CordnCoordinatorClient {
);
}
/** List the key packages other members have published. */
async listAvailableKeyPackages(): Promise<ListAvailableKeyPackagesOutput> {
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<KeyPackage> {
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<RemoveKeyPackagesOutput> {
return this.call(COORDINATOR_METHODS.removeKeyPackages, { ...input }, removeKeyPackagesOutputSchema);
}
/* ── group messages ─────────────────────────────────────────────────────── */
async postGroupMessage(gid: string, sealedBase64: string): Promise<PostGroupMessageOutput> {
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<FetchManyGroupMessagesOutput> {
@ -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<StoreWelcomeOutput> {
return this.call(COORDINATOR_METHODS.storeWelcome, { ...input }, storeWelcomeOutputSchema);
}
async fetchPendingWelcomes(input: FetchPendingWelcomesInput = {}): Promise<FetchPendingWelcomesOutput> {
return this.call(COORDINATOR_METHODS.fetchPendingWelcomes, { ...input }, fetchPendingWelcomesOutputSchema);
}
/* ── join requests (ask to be added) ────────────────────────────────────── */
async storeJoinRequest(input: StoreJoinRequestInput): Promise<StoreJoinRequestOutput> {
return this.call(COORDINATOR_METHODS.storeJoinRequest, { ...input }, storeJoinRequestOutputSchema);
}
async fetchManyPendingJoinRequests(
input: FetchManyPendingJoinRequestsInput,
): Promise<FetchManyPendingJoinRequestsOutput> {
return this.call(
COORDINATOR_METHODS.fetchManyPendingJoinRequests,
{ ...input },
fetchManyPendingJoinRequestsOutputSchema,
);
}
async disconnect(): Promise<void> {
await this.connected.catch(() => undefined);
await this.transport.close().catch(() => undefined);

View file

@ -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<CordnStateRow, string>;
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<CordnSnapshot | null> {
const row = await getDB().states.get(accountPubkey);
return row?.snapshot ?? null;
},
async save(snapshot: CordnSnapshot): Promise<void> {
await getDB().states.put({ pubkey: accountPubkey, snapshot, updatedAt: Date.now() });
},
async clear(): Promise<void> {
await getDB().states.delete(accountPubkey);
},
};
}

View file

@ -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,

View file

@ -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<typeof getCiphersuiteImpl> | 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<string> {
const cipherSuite = await getCipherSuite();
return bytesToHex(await makeKeyPackageRef(keyPackage, cipherSuite.hash));
}
/* ── state serialization ──────────────────────────────────────────────────── */
export function serializeState(state: ClientState): string {

View file

@ -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);
});

View file

@ -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<CordnCoordinatorConfig, 'serverPubkey' | 'relays'>;
/** 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<CordnSession> {
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<AvailableKeyPackage[]> {
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<StoredKeyPackage> {
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<CordnGroupInfo> {
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<StoredKeyPackage> {
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<JoinRequest[]> {
const result = await this.getClient().fetchManyPendingJoinRequests({ groups: [{ gid }] });
return result.requests;
}
/** Welcomes waiting for this identity on the coordinator. */
async fetchWelcomes(): Promise<PendingWelcome[]> {
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<CordnGroupInfo> {
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;
}
}

View file

@ -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 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-black/60 border border-white/10">
<TabsList className="grid w-full grid-cols-3 bg-black/60 border border-white/10">
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
<Calendar className="h-4 w-4" /> {t('socialUi.crew.eventsTab')}
{crewEvents && crewEvents.length > 0 && (
@ -292,6 +292,9 @@ export default function CrewDetailPage() {
/>
)}
</TabsTrigger>
<TabsTrigger value="mls" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<Lock className="h-4 w-4" /> {t('cordnUi.title')}
</TabsTrigger>
</TabsList>
{/* Events */}
@ -326,6 +329,11 @@ export default function CrewDetailPage() {
canModerate={canManage}
/>
</TabsContent>
{/* Private chat — native Cordn / MLS (real end-to-end encryption). */}
<TabsContent value="mls" className="mt-4">
<CordnNativePanel crew={crew} canManage={canManage} isMember={isCrewMember} />
</TabsContent>
</Tabs>
</div>