Cordn native: browser probe, group-ref sharing, image messages

- scripts/cordn-browser-probe.mjs: headless-Chromium proof that the built
  native chunk runs in a real browser — imports the chunk, runs a session
  against the coordinator (publish kp, create group, send), then reopens from
  the Dexie/IndexedDB store. Not part of npm test (needs network + Chromium).
- Group ref: the panel shows/copies the group's cordn1... ref
  (encodeGroupRef from the crew coordinate + coordinator), so it can be opened
  in Staircase/cordn-web. Computed in the hook via the lazy native import.
- Images: attach button uploads to Blossom (useUploadFile) and sends the URL
  inside the encrypted MLS envelope (['image', url]); CordnChatMessage.imageUrl
  + ImageWithFallback rendering. Covered by the live test.
- Unread badge deliberately not built (needs a background signed request).

npm test + smoke + browser probe + 3 live scenarios all pass.
This commit is contained in:
SkateSpots 2026-10-12 22:42:44 +07:00
parent 383fb07127
commit a9a5966714
9 changed files with 369 additions and 18 deletions

View file

@ -1360,12 +1360,27 @@ session with **no other memory of this project**. So:
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)**
`i18n/legacy/cordnUi.ts`. ⚠️ The **React panel** isn't click-tested (no automated
login+crew flow); the native stack's browser runtime IS — see milestone 2b. For **interactive signers (Amber)**
polling is off (each ContextVM request would pop) — manual Refresh/Send, same
rule as the DM hooks.
**Milestone 2b (2026-10-12):** ① **browser runtime proven** —
`node scripts/cordn-browser-probe.mjs` serves `dist/`, drives headless Chromium
over CDP (same harness as `smoke-test.mjs`), imports the built native chunk, and
runs a real session against the coordinator (publish key package → create group
→ send), then reopens from the **Dexie/IndexedDB** store (1 group, 1 message
restored). It is NOT in `npm test` (needs network + Chromium). ⚠️ The barrel
chunk exports its namespace under a mangled name, so the probe picks
`ns.CordnSession ? ns : ns.a`. ② **Group ref sharing** — the panel shows the
`cordn1…` ref (`encodeGroupRef({ gid: crew coordinate, coordinatorPubkey,
relays })`, re-exported from the native barrel; computed in the hook) with a
**Copy** button, so the group can be opened in Staircase/cordn-web. ③ **Images**
— the composer has an attach button (`useUploadFile` → Blossom), and the URL
rides **inside** the encrypted MLS envelope (`['image', url]` tag),
`CordnChatMessage.imageUrl`, rendered with `ImageWithFallback`. Verified in the
live test (image URL decrypts to the other member). ⚠️ **Unread badge for the
tab is deliberately NOT built** — it needs a background signed request (pops on
Amber) or a poll; revisit only if the feature gets real use.
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an

View file

@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* Cordn-native browser probe.
*
* The Node live test proves the MLS logic; this proves the **browser runtime**
* of the same stack — the built native chunk actually loading in Chromium, then
* a real session against the coordinator over a browser WebSocket, with MLS
* crypto and the Dexie/IndexedDB store.
*
* It is deliberately NOT part of `npm test` (it needs network + Chromium):
*
* node scripts/cordn-browser-probe.mjs
*
* Exit 0 = the browser stack worked end to end.
*/
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { readFile, readdir } from 'node:fs/promises';
import { existsSync, rmSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, extname, normalize } from 'node:path';
import { tmpdir } from 'node:os';
import net from 'node:net';
import { randomBytes } from 'node:crypto';
import WebSocket from 'ws';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = join(root, 'dist');
const assetsDir = join(distDir, 'assets');
const chromePath = process.env.CHROME_PATH || '/snap/bin/chromium';
const log = (m) => console.log(`[cordn-probe] ${m}`);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// The SkateSpots coordinator (public facts, same as src/lib/coordinator.ts).
const COORDINATOR_PUBKEY = '02816a05b1d4e70a7df9c712ef6f7272774269d65bd8a2f262ce0330d0885693';
const COORDINATOR_RELAY = 'wss://relay.skate-spots.org';
if (!existsSync(join(distDir, 'index.html'))) {
console.error('[cordn-probe] dist/index.html missing — run `npm run build` first.');
process.exit(1);
}
if (!existsSync(chromePath)) {
console.error(`[cordn-probe] Chromium not found at ${chromePath}. Set CHROME_PATH.`);
process.exit(1);
}
// Find the lazy chunk that carries the native Cordn module (the barrel chunk —
// it contains the coordinator method strings and the store factory).
const assets = (await readdir(assetsDir)).filter((f) => f.endsWith('.js'));
let nativeChunk = null;
for (const f of assets) {
const text = await readFile(join(assetsDir, f), 'utf8');
if (text.includes('kp_publish') && text.includes('createCordnStore')) {
nativeChunk = f;
break;
}
}
if (!nativeChunk) {
console.error('[cordn-probe] could not find the native Cordn chunk in dist/assets (build it, and make sure something imports src/lib/cordn/native).');
process.exit(1);
}
log(`native chunk: ${nativeChunk}`);
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript',
'.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json',
'.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm',
};
const freePort = () => new Promise((res) => {
const s = net.createServer();
s.on('error', () => res(0));
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
});
const servePort = await freePort();
const server = createServer(async (req, res) => {
try {
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
let filePath = join(distDir, normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
if (urlPath === '/' || !extname(filePath)) filePath = join(distDir, 'index.html');
let body;
try { body = await readFile(filePath); }
catch { filePath = join(distDir, 'index.html'); body = await readFile(filePath); }
res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(500); res.end('error');
}
});
await new Promise((r) => server.listen(servePort, '127.0.0.1', r));
const appUrl = `http://127.0.0.1:${servePort}/`;
const cdpPort = await freePort();
const profileDir = join(tmpdir(), `skatespots-cordn-probe-${process.pid}-${cdpPort}`);
const chrome = spawn(chromePath, [
'--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
'--disable-extensions', '--no-first-run', '--no-default-browser-check',
`--user-data-dir=${profileDir}`, `--remote-debugging-port=${cdpPort}`, appUrl,
], { stdio: ['ignore', 'ignore', 'pipe'] });
chrome.stderr.on('data', () => {});
const cleanup = () => {
try { chrome.kill('SIGKILL'); } catch {}
try { server.close(); } catch {}
try { rmSync(profileDir, { recursive: true, force: true }); } catch {}
};
process.on('exit', cleanup);
async function findPageTarget() {
for (let i = 0; i < 60; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json();
const p = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
if (p) return p;
} catch {}
await sleep(300);
}
return null;
}
const target = await findPageTarget();
if (!target) { console.error('[cordn-probe] FAIL: no Chromium page target'); cleanup(); process.exit(1); }
const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
let id = 0; const pend = new Map(); const exceptions = [];
ws.on('message', (d) => {
const m = JSON.parse(d);
if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); return; }
if (m.method === 'Runtime.exceptionThrown') {
const e = m.params.exceptionDetails;
exceptions.push(e.exception?.description || e.text || 'unknown exception');
}
});
const send = (method, params = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
await new Promise((r) => ws.on('open', r));
await send('Runtime.enable');
await send('Page.enable');
// Wait for the app to mount so the module graph is warm.
for (let i = 0; i < 40; i++) {
const kids = (await send('Runtime.evaluate', { expression: 'document.getElementById("root")?.children.length ?? 0', returnByValue: true }))?.result?.result?.value;
if (kids > 0) break;
await sleep(300);
}
const privateKeyHex = randomBytes(32).toString('hex');
const accountKey = `probe-${Date.now()}`;
const groupId = `browser-probe-${Date.now()}`;
const probe = `(async () => {
const ns = await import('/assets/${nativeChunk}');
const m = ns.CordnSession ? ns : (ns.a || ns.default || ns);
const store = m.createCordnStore('${accountKey}');
const session = await m.CordnSession.open({
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
privateKeyHex: '${privateKeyHex}',
store,
});
await session.generateKeyPackage('probe');
await session.publishKeyPackage('probe');
const group = await session.createGroup({ groupId: '${groupId}', name: 'Browser probe' });
await session.send(group.gid, 'hello from headless chromium');
const local = session.messages(group.gid).map((x) => x.content);
await session.disconnect();
// Reopen with the same IndexedDB store: the group must restore.
const reopened = await m.CordnSession.open({
coordinator: { serverPubkey: '${COORDINATOR_PUBKEY}', relays: ['${COORDINATOR_RELAY}'] },
privateKeyHex: '${privateKeyHex}',
store: m.createCordnStore('${accountKey}'),
});
const restored = reopened.listGroups().length;
const restoredMsgs = reopened.listGroups()[0] ? reopened.messages(reopened.listGroups()[0].gid).length : 0;
await reopened.disconnect();
return { ok: true, pubkey: session.pubkey, gid: group.gid, local, restored, restoredMsgs };
})()`;
const result = await send('Runtime.evaluate', { expression: probe, returnByValue: true, awaitPromise: true });
const value = result?.result?.result?.value;
const thrown = result?.result?.exceptionDetails;
ws.close();
if (thrown) {
console.error('[cordn-probe] FAIL — the browser threw:');
console.error(' ' + (thrown.exception?.description || thrown.text));
process.exit(1);
}
if (exceptions.length > 0) {
console.error('[cordn-probe] FAIL — uncaught exceptions in the page:');
for (const e of exceptions) console.error(' • ' + e);
process.exit(1);
}
if (!value?.ok) {
console.error('[cordn-probe] FAIL — unexpected result:', JSON.stringify(value));
process.exit(1);
}
log(`PASS — native stack ran in Chromium (${COORDINATOR_RELAY})`);
log(` pubkey: ${value.pubkey}`);
log(` group: ${value.gid}`);
log(` sent locally: ${JSON.stringify(value.local)}`);
log(` restored (IDB): ${value.restored} group(s), ${value.restoredMsgs} message(s)`);
process.exit(0);

View file

@ -1,12 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus } from 'lucide-react';
import { KeyRound, Lock, Plus, RefreshCw, Send, UserPlus, Copy, Check, ImagePlus, Loader2 } 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 { ImageWithFallback } from '@/components/ImageWithFallback';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCordnNative } from '@/hooks/useCordnNative';
import { shortNpub } from '@/lib/coordinator';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
@ -30,7 +32,10 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
const { user } = useCurrentUser();
const native = useCordnNative({ crew, enabled: !!user && isMember });
const [draft, setDraft] = useState('');
const [copied, setCopied] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
@ -43,6 +48,34 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
await native.send(body);
}, [draft, native]);
const onPickImage = useCallback(
async (file: File | undefined) => {
if (!file) return;
try {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (!url) throw new Error('upload returned no url');
const caption = draft.trim();
setDraft('');
await native.send(caption, url);
} catch {
// The hook surfaces nothing on failure; keep the composer usable.
}
},
[uploadFile, draft, native],
);
const copyRef = useCallback(async () => {
if (!native.groupRef) return;
try {
await navigator.clipboard.writeText(native.groupRef);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard unavailable — the ref is still visible to select.
}
}, [native.groupRef]);
if (!user) {
return <Notice icon={<Lock className="h-5 w-5" />} text={t('cordnUi.notLoggedIn')} />;
}
@ -147,13 +180,21 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</div>
<div
className={cn(
'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-3 py-1.5 text-sm',
'max-w-[85%] space-y-1 overflow-hidden 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}
{m.imageUrl && (
<ImageWithFallback
src={m.imageUrl}
alt=""
loading="lazy"
className="max-h-60 w-full rounded-lg object-cover"
/>
)}
{m.content && <div className="whitespace-pre-wrap break-words">{m.content}</div>}
</div>
</div>
))
@ -161,6 +202,25 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</div>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
void onPickImage(e.target.files?.[0]);
e.target.value = '';
}}
/>
<Button
size="icon"
variant="outline"
disabled={uploading || native.busy}
onClick={() => fileRef.current?.click()}
aria-label={t('cordnUi.attachImage')}
>
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ImagePlus className="h-4 w-4" />}
</Button>
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
@ -191,6 +251,24 @@ export function CordnNativePanel({ crew, canManage, isMember }: CordnNativePanel
</Button>
</div>
{native.groupRef && (
<div className="flex items-center gap-2 text-[11px] text-gray-500">
<span className="shrink-0">{t('cordnUi.groupRef')}</span>
<code className="min-w-0 flex-1 truncate rounded bg-black/40 px-1.5 py-0.5 text-gray-400">
{native.groupRef}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-xs"
onClick={() => void copyRef()}
>
{copied ? <Check className="h-3 w-3 text-teal-400" /> : <Copy className="h-3 w-3" />}
{copied ? t('cordnUi.copied') : t('cordnUi.copyRef')}
</Button>
</div>
)}
{native.interactiveHintVisible && (
<p className="text-[11px] text-gray-500">{t('cordnUi.interactiveHint')}</p>
)}

View file

@ -54,8 +54,8 @@ export interface UseCordnNativeResult {
messages: CordnChatMessage[];
busy: boolean;
error: string | null;
/** Send a group message. */
send: (text: string) => Promise<void>;
/** Send a group message (optionally an image URL, encrypted in the payload). */
send: (text: string, imageUrl?: string) => Promise<void>;
/** Re-fetch messages (and adopt any new commits). */
refresh: () => Promise<void>;
/** Founder/admin: create the crew's MLS group. */
@ -71,6 +71,8 @@ export interface UseCordnNativeResult {
approve: (identifier: string) => Promise<void>;
/** True for interactive signers (Amber) — manual refresh/send, no polling. */
interactiveHintVisible: boolean;
/** Shareable `cordn1…` reference for the crew's group (once known). */
groupRef: string | null;
}
const POLL_MS = 6000;
@ -112,6 +114,7 @@ export function useCordnNative({
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [groupRef, setGroupRef] = useState<string | null>(null);
const interactive = isInteractiveSigner(user, logins);
@ -124,14 +127,27 @@ export function useCordnNative({
useEffect(() => {
if (!enabled || !pubkey || !signer || !coordinator || !groupId) return;
let cancelled = false;
const session = import('@/lib/cordn/native').then(({ CordnSession: NativeSession }) =>
NativeSession.open({
const session = import('@/lib/cordn/native').then((mod) => {
if (!cancelled) {
try {
setGroupRef(
mod.encodeGroupRef({
gid: groupId,
coordinatorPubkey: coordinator.serverPubkey,
relays: coordinator.relays,
}),
);
} catch {
setGroupRef(null);
}
}
return mod.CordnSession.open({
coordinator: { serverPubkey: coordinator.serverPubkey, relays: coordinator.relays },
pubkey,
signer: signer as unknown as NostrSigner,
store: createCordnStore(pubkey),
}),
);
});
});
session
.then((opened) => {
if (cancelled) {
@ -152,6 +168,7 @@ export function useCordnNative({
const s = sessionRef.current;
sessionRef.current = null;
setReady(false);
setGroupRef(null);
if (s) void s.disconnect();
};
}, [enabled, pubkey, signer, coordinator, groupId]);
@ -193,12 +210,12 @@ export function useCordnNative({
}, []);
const send = useCallback(
async (text: string) => {
async (text: string, imageUrl?: string) => {
const body = text.trim();
const session = sessionRef.current;
if (!body || !session || !group) return;
if ((!body && !imageUrl) || !session || !group) return;
await run(async () => {
await session.send(group.gid, body);
await session.send(group.gid, body, imageUrl);
setMessages(session.messages(group.gid));
});
},
@ -276,5 +293,6 @@ export function useCordnNative({
loadRequests,
approve,
interactiveHintVisible: interactive,
groupRef,
};
}

View file

@ -28,6 +28,12 @@ const cordnUi = {
noRequests: 'No pending join requests.',
memberCount: '{{count}} in the group',
you: 'You',
groupRef: 'Group link',
copyRef: 'Copy',
copied: 'Copied',
attachImage: 'Attach image',
uploading: 'Uploading…',
uploadFailed: 'Couldn’t upload the image.',
ready: 'Connected · MLS',
errorPrefix: 'Couldn’t reach the coordinator',
interactiveHint:
@ -57,6 +63,12 @@ const cordnUi = {
noRequests: 'No hay solicitudes pendientes.',
memberCount: '{{count}} en el grupo',
you: 'Tú',
groupRef: 'Enlace del grupo',
copyRef: 'Copiar',
copied: 'Copiado',
attachImage: 'Adjuntar imagen',
uploading: 'Subiendo…',
uploadFailed: 'No se pudo subir la imagen.',
ready: 'Conectado · MLS',
errorPrefix: 'No se pudo contactar con el coordinador',
interactiveHint:

View file

@ -46,3 +46,6 @@ export {
type CordnMlsIdentity,
type CordnKeyPackage,
} from './mls';
/** Build the shareable `cordn1…` reference for a group (Cordn wire format). */
export { encodeGroupRef, type GroupRef } from '@cordn/core';

View file

@ -389,10 +389,16 @@ export async function createGroupApplicationMessage(params: {
sender: string;
content: string;
tags?: string[][];
/** Optional image URL, carried inside the encrypted envelope. */
imageUrl?: string;
}): Promise<{ newState: ClientState; messageBase64: string; envelope: CordnEnvelope }> {
const cipherSuite = await getCipherSuite();
const tags = [
...(params.tags ?? []),
...(params.imageUrl ? [['image', params.imageUrl]] : []),
];
const envelope = finalizeEnvelope(
createEnvelope({ pubkey: params.sender, content: params.content, tags: params.tags }),
createEnvelope({ pubkey: params.sender, content: params.content, tags }),
);
const result = await createApplicationMessage({
context: { cipherSuite, authService: unsafeTestingAuthenticationService },

View file

@ -43,6 +43,14 @@ describe.skipIf(!live)('cordn native (live)', () => {
expect(receivedByB.map((m) => m.content)).toContain('hello from A');
expect(receivedByB.find((m) => m.content === 'hello from A')?.sender).toBe(a.pubkey);
// An image message: the URL travels inside the encrypted envelope.
const imageUrl = 'https://blossom.skate-spots.org/abc123.png';
await a.send(group.gid, 'look at this', imageUrl);
const withImage = await b.fetch(bGroup.gid);
const imageMsg = withImage.find((m) => m.imageUrl);
expect(imageMsg?.imageUrl).toBe(imageUrl);
expect(imageMsg?.content).toBe('look at this');
await b.send(bGroup.gid, 'hi from B');
const receivedByA = await a.fetch(group.gid);
expect(receivedByA.map((m) => m.content)).toContain('hi from B');

View file

@ -67,6 +67,8 @@ export interface CordnChatMessage {
/** Proven MLS sender pubkey (from the MLS authenticated data). */
sender: string;
content: string;
/** Optional image URL (decrypted from the envelope's `image` tag). */
imageUrl?: string;
createdAt: number;
cursor: number;
direction: 'inbound' | 'outbound';
@ -469,12 +471,13 @@ export class CordnSession {
}
/** Encrypt, post and locally adopt an application message. */
async send(gid: string, content: string): Promise<CordnChatMessage> {
async send(gid: string, content: string, imageUrl?: string): Promise<CordnChatMessage> {
const group = this.requireGroup(gid);
const { newState, messageBase64, envelope } = await createGroupApplicationMessage({
state: group.state,
sender: this.identity.pubkey,
content,
imageUrl,
tags: causalPrevTags(
group.messages.map((m) => m.id),
group.messages.map((m) => m.prev),
@ -488,6 +491,7 @@ export class CordnSession {
id: envelope.id ?? '',
sender: this.identity.pubkey,
content,
imageUrl,
createdAt: envelope.created_at,
cursor: posted.cursor,
direction: 'outbound',
@ -524,6 +528,7 @@ export class CordnSession {
id: envelope.id ?? message.msg_64,
sender,
content: envelope.content,
imageUrl: envelope.tags.find((tag) => tag[0] === 'image')?.[1],
createdAt: envelope.created_at,
cursor: message.cursor,
direction: sender === this.identity.pubkey ? 'outbound' : 'inbound',