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:
parent
1dcaf8cea3
commit
383fb07127
12 changed files with 944 additions and 32 deletions
20
AGENTS.md
20
AGENTS.md
|
|
@ -1346,6 +1346,26 @@ session with **no other memory of this project**. So:
|
|||
**Not wired to any UI yet** — the app still uses the sealed-relay crew chat.
|
||||
Next: IndexedDB store + a crew chat panel + the coordinator welcome/join-request
|
||||
flow. Nothing imports the native module, so the web bundle is unchanged.
|
||||
**Milestone 2 landed (2026-10-12):** the session now runs on the **viewer's own
|
||||
signer** (NIP-07/Amber/nsec — no key held; `CordnSession.open({ pubkey, signer })`),
|
||||
so the Cordn identity IS the user's pubkey. Added `idbStore.ts`
|
||||
(`createCordnStore(pubkey)` — Dexie DB `SkateSpotsCordnDB`, one snapshot per
|
||||
account, survives reload) and the **membership delivery** methods:
|
||||
`requestJoin` / `fetchJoinRequests` / `fetchWelcomes` / `acceptWelcome` /
|
||||
`addMemberByIdentifier` (+ auto `welcome_store` in `addMember`). **Verified live**
|
||||
with a 3rd scenario (`join-request → approve → welcome`); ⚠️ `kp_take` accepts
|
||||
the requester's **pubkey** (and a `kp_ref` also worked). UI:
|
||||
`hooks/useCordnNative.ts` (opens/owns the session; **lazy-imports** the native
|
||||
module so `@contextvm/sdk`+ts-mls land in their own ~678 kB async chunk, NOT the
|
||||
1.9 MB entry) + `components/crew/CordnNativePanel.tsx`, rendered as a **third
|
||||
"Private chat" tab** on the crew page (founder/admin create the group + approve
|
||||
requests; members ask to join + check for an invite). Strings in
|
||||
`i18n/legacy/cordnUi.ts`. ⚠️ **Not yet exercised in a real browser** (the browser
|
||||
tooling was disconnected this session) — the Node live tests + boot smoke pass,
|
||||
but the panel's browser runtime (native WebSocket / IndexedDB) is unverified;
|
||||
load the crew tab once before trusting it. For **interactive signers (Amber)**
|
||||
polling is off (each ContextVM request would pop) — manual Refresh/Send, same
|
||||
rule as the DM hooks.
|
||||
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
|
||||
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
|
||||
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
|
||||
|
|
|
|||
247
src/components/crew/CordnNativePanel.tsx
Normal file
247
src/components/crew/CordnNativePanel.tsx
Normal 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
280
src/hooks/useCordnNative.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
67
src/i18n/legacy/cordnUi.ts
Normal file
67
src/i18n/legacy/cordnUi.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
54
src/lib/cordn/native/idbStore.ts
Normal file
54
src/lib/cordn/native/idbStore.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue