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. **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 Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request
flow. Nothing imports the native module, so the web bundle is unchanged. 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 - **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 (full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an (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 skaterLabels from './skaterLabels';
import notify from './notify'; import notify from './notify';
import misc from './misc'; import misc from './misc';
import cordnUi from './cordnUi';
/** /**
* Namespaces added by the hardcoded-string sweep (2026-10-07), spread into * Namespaces added by the hardcoded-string sweep (2026-10-07), spread into
@ -17,6 +18,7 @@ export const legacyEn = {
skaterLabelsUi: skaterLabels.en, skaterLabelsUi: skaterLabels.en,
notifyUi: notify.en, notifyUi: notify.en,
legacyMiscUi: misc.en, legacyMiscUi: misc.en,
cordnUi: cordnUi.en,
}; };
export const legacyEs = { export const legacyEs = {
@ -26,4 +28,5 @@ export const legacyEs = {
skaterLabelsUi: skaterLabels.es, skaterLabelsUi: skaterLabels.es,
notifyUi: notify.es, notifyUi: notify.es,
legacyMiscUi: misc.es, legacyMiscUi: misc.es,
cordnUi: cordnUi.es,
}; };

View file

@ -6,6 +6,8 @@
* `@cordn/core`'s wire schemas. The coordinator routes **opaque MLS ciphertext** * `@cordn/core`'s wire schemas. The coordinator routes **opaque MLS ciphertext**
* plus metadata (which group, when) and never sees plaintext. * 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`. * Reimplemented from the Cordn wire contract — not copied from `@cordn/cli`.
*/ */
@ -16,21 +18,37 @@ import {
NostrClientTransport, NostrClientTransport,
PrivateKeySigner, PrivateKeySigner,
} from '@contextvm/sdk'; } from '@contextvm/sdk';
import type { NostrSigner } from '@contextvm/sdk/core';
import { import {
COORDINATOR_METHODS, COORDINATOR_METHODS,
consumeKeyPackageOutputSchema, consumeKeyPackageOutputSchema,
decodeBase64, decodeBase64,
decodeKeyPackage, decodeKeyPackage,
fetchManyGroupMessagesOutputSchema, fetchManyGroupMessagesOutputSchema,
fetchManyPendingJoinRequestsOutputSchema,
fetchPendingWelcomesOutputSchema,
groupMessageSchema, groupMessageSchema,
listAvailableKeyPackagesOutputSchema, listAvailableKeyPackagesOutputSchema,
postGroupMessageOutputSchema, postGroupMessageOutputSchema,
publishKeyPackageOutputSchema, publishKeyPackageOutputSchema,
removeKeyPackagesOutputSchema,
storeJoinRequestOutputSchema,
storeWelcomeOutputSchema,
type FetchManyGroupMessagesOutput, type FetchManyGroupMessagesOutput,
type FetchManyPendingJoinRequestsInput,
type FetchManyPendingJoinRequestsOutput,
type FetchPendingWelcomesInput,
type FetchPendingWelcomesOutput,
type GroupMessage, type GroupMessage,
type ListAvailableKeyPackagesOutput, type ListAvailableKeyPackagesOutput,
type PostGroupMessageOutput, type PostGroupMessageOutput,
type PublishKeyPackageOutput, type PublishKeyPackageOutput,
type RemoveKeyPackagesInput,
type RemoveKeyPackagesOutput,
type StoreJoinRequestInput,
type StoreJoinRequestOutput,
type StoreWelcomeInput,
type StoreWelcomeOutput,
} from '@cordn/core'; } from '@cordn/core';
import type { KeyPackage } from 'ts-mls'; import type { KeyPackage } from 'ts-mls';
@ -48,8 +66,10 @@ export interface CordnCoordinatorConfig {
serverPubkey: string; serverPubkey: string;
/** ContextVM relays that reach the coordinator. */ /** ContextVM relays that reach the coordinator. */
relays: string[]; relays: string[];
/** Client identity secret (hex). */ /** Signer for the transport (the viewer's Nostr signer, or a local test key). */
privateKeyHex: string; 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. */ /** Gift-wrap requests so relays cannot read the method/group id. */
encryptTransport?: boolean; encryptTransport?: boolean;
} }
@ -60,10 +80,11 @@ export class CordnCoordinatorClient {
private readonly connected: Promise<void>; private readonly connected: Promise<void>;
constructor(config: CordnCoordinatorConfig) { constructor(config: CordnCoordinatorConfig) {
const signer = config.signer ?? new PrivateKeySigner(config.privateKeyHex ?? '');
this.client = new Client({ name: 'SkateSpotsCordn', version: '1.0.0' }); this.client = new Client({ name: 'SkateSpotsCordn', version: '1.0.0' });
this.transport = new NostrClientTransport({ this.transport = new NostrClientTransport({
serverPubkey: config.serverPubkey, serverPubkey: config.serverPubkey,
signer: new PrivateKeySigner(config.privateKeyHex), signer,
relayHandler: new ApplesauceRelayPool(config.relays), relayHandler: new ApplesauceRelayPool(config.relays),
isStateless: true, isStateless: true,
logLevel: 'silent', logLevel: 'silent',
@ -90,7 +111,8 @@ export class CordnCoordinatorClient {
return schema.parse(result.structuredContent); 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> { async publishKeyPackage(kpRef: string, keyPackageBase64: string): Promise<PublishKeyPackageOutput> {
return this.call( return this.call(
COORDINATOR_METHODS.publishKeyPackage, COORDINATOR_METHODS.publishKeyPackage,
@ -99,12 +121,10 @@ export class CordnCoordinatorClient {
); );
} }
/** List the key packages other members have published. */
async listAvailableKeyPackages(): Promise<ListAvailableKeyPackagesOutput> { async listAvailableKeyPackages(): Promise<ListAvailableKeyPackagesOutput> {
return this.call(COORDINATOR_METHODS.listAvailableKeyPackages, {}, listAvailableKeyPackagesOutputSchema); 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> { async consumeKeyPackage(id: string): Promise<KeyPackage> {
const result = await this.call( const result = await this.call(
COORDINATOR_METHODS.consumeKeyPackage, COORDINATOR_METHODS.consumeKeyPackage,
@ -115,7 +135,12 @@ export class CordnCoordinatorClient {
return decodeConsumedKeyPackage(result.keyPackage); 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> { async postGroupMessage(gid: string, sealedBase64: string): Promise<PostGroupMessageOutput> {
return this.call( return this.call(
COORDINATOR_METHODS.postGroupMessage, COORDINATOR_METHODS.postGroupMessage,
@ -124,7 +149,6 @@ export class CordnCoordinatorClient {
); );
} }
/** Fetch group messages after a cursor (one entry per group). */
async fetchManyGroupMessages( async fetchManyGroupMessages(
groups: Array<{ gid: string; after?: number }>, groups: Array<{ gid: string; after?: number }>,
): Promise<FetchManyGroupMessagesOutput> { ): Promise<FetchManyGroupMessagesOutput> {
@ -136,6 +160,32 @@ export class CordnCoordinatorClient {
return { messages: result.messages.map((m) => groupMessageSchema.parse(m)) as GroupMessage[] }; 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> { async disconnect(): Promise<void> {
await this.connected.catch(() => undefined); await this.connected.catch(() => undefined);
await this.transport.close().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, type StoredKeyPackage,
} from './session'; } from './session';
export { createCordnStore } from './idbStore';
export { export {
CordnCoordinatorClient, CordnCoordinatorClient,
type CordnCoordinatorConfig, type CordnCoordinatorConfig,
@ -35,6 +37,7 @@ export {
export { export {
createIdentity, createIdentity,
identityFromPubkey,
createKeyPackage, createKeyPackage,
serializeState, serializeState,
deserializeState, deserializeState,

View file

@ -84,12 +84,12 @@ export function hexToBytes(hex: string): Uint8Array {
/* ── identity ─────────────────────────────────────────────────────────────── */ /* ── 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 { export interface CordnMlsIdentity {
/** 32-byte secret, hex. */ /** The "stable pubkey" — the Nostr pubkey that owns this identity. */
privateKeyHex: string;
/** The "stable pubkey" — the Nostr pubkey derived from the secret. */
pubkey: string; pubkey: string;
/** 32-byte secret, hex — present only for local/test identities. */
privateKeyHex?: string;
} }
export function createIdentity(privateKeyHex?: string): CordnMlsIdentity { export function createIdentity(privateKeyHex?: string): CordnMlsIdentity {
@ -100,6 +100,11 @@ export function createIdentity(privateKeyHex?: string): CordnMlsIdentity {
return { privateKeyHex: hex, pubkey: getPublicKey(secret) }; 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) ─────────────────────────────────────────────────── */ /* ── ciphersuite (cached) ─────────────────────────────────────────────────── */
let cipherSuitePromise: ReturnType<typeof getCiphersuiteImpl> | undefined; 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 ──────────────────────────────────────────────────── */ /* ── state serialization ──────────────────────────────────────────────────── */
export function serializeState(state: ClientState): string { export function serializeState(state: ClientState): string {

View file

@ -51,4 +51,75 @@ describe.skipIf(!live)('cordn native (live)', () => {
await a.disconnect(); await a.disconnect();
await b.disconnect(); await b.disconnect();
}, 180_000); }, 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. * Cordn native — session.
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
* A browser-safe Cordn client session: identity, key packages, group creation, * A browser-safe Cordn client session: identity, key packages, group creation,
* MLS message send/receive and bounded catch-up, all against a coordinator. This * membership (welcome / join-request), MLS message send/receive and bounded
* is the "Level 2" foundation — real RFC 9420 MLS, not the sealed-relay * catch-up, all against a coordinator. This is the "Level 2" foundation — real
* stand-in in `../client.ts`. * 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 session is isomorphic (no Node built-ins, no DOM); it can run against the
* the injected {@link CordnStore} so the browser can back it with IndexedDB and * viewer's own signer (no secret held) or a local test key. Persistence is
* tests with {@link MemoryCordnStore}. * behind the injected {@link CordnStore} so the browser backs it with IndexedDB
* and tests with {@link MemoryCordnStore}.
*/ */
import { import {
@ -26,7 +27,10 @@ import {
serializeState, serializeState,
deserializeState, deserializeState,
serializePrivateKeyPackage, serializePrivateKeyPackage,
keyPackageStablePubkey,
keyPackageRefOf,
createIdentity, createIdentity,
identityFromPubkey,
causalPrevTags, causalPrevTags,
type CordnMlsIdentity, type CordnMlsIdentity,
} from './mls'; } from './mls';
@ -39,7 +43,11 @@ import {
encodeBase64, encodeBase64,
encodeKeyPackage, encodeKeyPackage,
encodeWelcome, encodeWelcome,
type PendingWelcome,
type JoinRequest,
type AvailableKeyPackage,
} from '@cordn/core'; } from '@cordn/core';
import type { NostrSigner } from '@contextvm/sdk/core';
import type { ClientState, KeyPackage, PrivateKeyPackage } from 'ts-mls'; import type { ClientState, KeyPackage, PrivateKeyPackage } from 'ts-mls';
/* ── stored shapes ────────────────────────────────────────────────────────── */ /* ── stored shapes ────────────────────────────────────────────────────────── */
@ -88,7 +96,9 @@ interface RuntimeGroup {
export interface CordnSnapshot { export interface CordnSnapshot {
version: 1; version: 1;
privateKeyHex: string; pubkey: string;
/** Present only for local/test identities (never for a signer session). */
privateKeyHex?: string;
keyPackages: Array<{ keyPackages: Array<{
alias: string; alias: string;
keyPackageBase64: string; keyPackageBase64: string;
@ -131,7 +141,11 @@ export class MemoryCordnStore implements CordnStore {
export interface CordnSessionOptions { export interface CordnSessionOptions {
coordinator: Pick<CordnCoordinatorConfig, 'serverPubkey' | 'relays'>; 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; privateKeyHex?: string;
store?: CordnStore; store?: CordnStore;
encryptTransport?: boolean; encryptTransport?: boolean;
@ -152,14 +166,26 @@ export class CordnSession {
this.store = options.store ?? new MemoryCordnStore(); this.store = options.store ?? new MemoryCordnStore();
} }
/** Open a session, restoring persisted state when a store has it. */ /** Open a session, restoring persisted state when a store has it. */
static async open(options: CordnSessionOptions): Promise<CordnSession> { static async open(options: CordnSessionOptions): Promise<CordnSession> {
const store = options.store ?? new MemoryCordnStore(); const store = options.store ?? new MemoryCordnStore();
const snapshot = await store.load(); 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 }); const session = new CordnSession(identity, { ...options, store });
if (snapshot) session.restore(snapshot); if (snapshot && snapshot.pubkey === identity.pubkey) session.restore(snapshot);
return session; return session;
} }
@ -171,6 +197,7 @@ export class CordnSession {
this.client ??= new CordnCoordinatorClient({ this.client ??= new CordnCoordinatorClient({
serverPubkey: this.options.coordinator.serverPubkey, serverPubkey: this.options.coordinator.serverPubkey,
relays: this.options.coordinator.relays, relays: this.options.coordinator.relays,
signer: this.options.signer,
privateKeyHex: this.identity.privateKeyHex, privateKeyHex: this.identity.privateKeyHex,
encryptTransport: this.options.encryptTransport, encryptTransport: this.options.encryptTransport,
}); });
@ -212,6 +239,7 @@ export class CordnSession {
private snapshot(): CordnSnapshot { private snapshot(): CordnSnapshot {
return { return {
version: 1, version: 1,
pubkey: this.identity.pubkey,
privateKeyHex: this.identity.privateKeyHex, privateKeyHex: this.identity.privateKeyHex,
keyPackages: this.keyPackages.map((kp) => ({ keyPackages: this.keyPackages.map((kp) => ({
alias: kp.alias, alias: kp.alias,
@ -245,6 +273,12 @@ export class CordnSession {
return this.keyPackages; 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. */ /** Generate (and store) a key package others can use to add this identity. */
async generateKeyPackage(alias = `kp-${this.keyPackages.length + 1}`): Promise<StoredKeyPackage> { async generateKeyPackage(alias = `kp-${this.keyPackages.length + 1}`): Promise<StoredKeyPackage> {
const created = await createKeyPackage(this.identity.pubkey); 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 * Add a member from their key package, post the commit, and store the Welcome
* to join. The commit is posted to the coordinator and the local epoch adopts * on the coordinator for them to collect.
* the new state.
*/ */
async addMember(gid: string, memberKeyPackage: KeyPackage): Promise<{ welcomeBase64: string }> { async addMember(gid: string, memberKeyPackage: KeyPackage): Promise<{ welcomeBase64: string }> {
const group = this.requireGroup(gid); const group = this.requireGroup(gid);
const targetPubkey = keyPackageStablePubkey(memberKeyPackage);
const targetKpRef = await keyPackageRefOf(memberKeyPackage);
const { newState, commitMessageBase64, welcome } = await addMemberToGroup({ const { newState, commitMessageBase64, welcome } = await addMemberToGroup({
state: group.state, state: group.state,
memberKeyPackage, memberKeyPackage,
@ -342,8 +377,22 @@ export class CordnSession {
group.posted.add(sealed); group.posted.add(sealed);
group.state = newState; group.state = newState;
group.cursor = Math.max(group.cursor, posted.cursor); 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(); 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}. */ /** Join a group from a Welcome produced by {@link addMember}. */
@ -351,6 +400,7 @@ export class CordnSession {
welcomeBase64: string; welcomeBase64: string;
keyPackage: StoredKeyPackage; keyPackage: StoredKeyPackage;
name?: string; name?: string;
after?: number;
}): Promise<CordnGroupInfo> { }): Promise<CordnGroupInfo> {
const state = await joinFromWelcome({ const state = await joinFromWelcome({
welcome: decodeWelcome(decodeBase64(params.welcomeBase64), 'welcome'), welcome: decodeWelcome(decodeBase64(params.welcomeBase64), 'welcome'),
@ -363,7 +413,7 @@ export class CordnSession {
gid, gid,
name: params.name, name: params.name,
state, state,
cursor: 0, cursor: params.after ?? 0,
messages: [], messages: [],
posted: new Set(), posted: new Set(),
}; };
@ -372,6 +422,46 @@ export class CordnSession {
return this.describeGroup(group); 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 ──────────────────────────────────────────────────────────── */ /* ── messaging ──────────────────────────────────────────────────────────── */
messages(gid: string): CordnChatMessage[] { messages(gid: string): CordnChatMessage[] {
@ -446,4 +536,3 @@ export class CordnSession {
return received; return received;
} }
} }

View file

@ -22,7 +22,7 @@ import { crewAccent } from '@/lib/crewHelpers';
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
import { useCrewChatUnread } from '@/hooks/useCrewChatUnread'; import { useCrewChatUnread } from '@/hooks/useCrewChatUnread';
import { CrewChat } from '@/components/CrewChat'; 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 { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard'; import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
import { CrewLookingForCard } from '@/components/CrewLookingFor'; 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 { CREW_LOOKING_FOR_KEYS } from '@/lib/labelKeys';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import NotFound from './NotFound'; 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'; import type { NostrEvent } from '@nostrify/nostrify';
export default function CrewDetailPage() { export default function CrewDetailPage() {
@ -275,7 +275,7 @@ export default function CrewDetailPage() {
{/* Activity — one clean tabbed surface instead of three stacked cards */} {/* Activity — one clean tabbed surface instead of three stacked cards */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <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]"> <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')} <Calendar className="h-4 w-4" /> {t('socialUi.crew.eventsTab')}
{crewEvents && crewEvents.length > 0 && ( {crewEvents && crewEvents.length > 0 && (
@ -292,6 +292,9 @@ export default function CrewDetailPage() {
/> />
)} )}
</TabsTrigger> </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> </TabsList>
{/* Events */} {/* Events */}
@ -326,6 +329,11 @@ export default function CrewDetailPage() {
canModerate={canManage} canModerate={canManage}
/> />
</TabsContent> </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> </Tabs>
</div> </div>