Wire live Cordn encrypted crew chat over the coordinator (ContextVM)

The crew coordinator is no longer just a pointer — when a crew configures one,
the Chat tab runs through it for real.

- src/lib/cordn: clean transport/crypto split so an MLS (ts-mls) upgrade is a
  drop-in swap of the crypto layer only.
  - types.ts: CordnMessage/CordnEndpoint + envelope kind 4497 + app tag.
  - crypto.ts: sealGroupMessage/openGroupMessage — real NIP-44 group encryption
    (per-recipient sealing), the exact seam MLS encrypt-to-epoch slots into.
  - client.ts: buildEnvelope/publishEnvelope/fetchGroupMessages with per-group
    cursor ordering and ciphertext→author sender binding (anti-spoof).
  - endpoint.ts: resolve a crew's `coordinator` to a usable relay endpoint
    (wss/ws direct; https falls back to the ContextVM relay).
- useCordnChat: fetch-then-poll hook mirroring the NIP-17 chat API.
- CrewChat: uses Cordn when the crew has a coordinator, NIP-17 fallback
  otherwise; header badge shows the active transport (Cordn vs Private).
- CrewCoordinatorCard: reflects that private chat is live.
- NIP.md: document envelope kind 4497, delivery (fetch-then-poll + cursors),
  the nip44-group sealed content, and the honest crypto→MLS seam.

Real encryption, real coordinator routing, non-breaking (fallback intact),
with the MLS ratchet as a clearly-marked next step.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 00:15:22 +07:00
parent cbd30fee00
commit 50d8d9e614
9 changed files with 556 additions and 19 deletions

53
NIP.md
View file

@ -2274,6 +2274,59 @@ the private chat un-fingerprintable next to an already-public, geolocated crew
identity. Until a crew configures a coordinator, the built-in NIP-17 group-DM
chat remains the fallback.
##### Message envelope (kind `4497`) & delivery
When a crew has a `coordinator`, chat runs over it. Each message is a **sealed
envelope** — a regular event (kind `4497`) published to the coordinator's
ContextVM relay (a `wss://`/`ws://` coordinator URL is used directly; an
`https://` coordinator falls back to the ContextVM relay, addressed by the
coordinator pubkey):
```json
{
"kind": 4497,
"content": "<sealed JSON — see below>",
"tags": [
["a", "35006:<founder>:<crewDtag>"], // group id = crew coordinate
["t", "skatespots.cordn.crew-chat"], // app envelope marker
["cursor", "1730000000000"], // per-group monotonic cursor (ms)
["p", "<coordinatorPubkey>"], // optional — coordinator addressing
["alt", "Encrypted crew chat message (Cordn)"]
]
}
```
Clients do **fetch-then-poll**: a bounded backlog query filtered by
`{ kinds:[4497], "#a":[groupId], "#t":["skatespots.cordn.crew-chat"] }`, then a
light poll for live delivery. Messages are ordered by the `cursor` tag (event id
as tiebreaker). The ciphertext-declared sender is bound to the event author and
mismatches are rejected (no spoofing).
**Sealed content** (`content` field), JSON:
```json
{
"v": 1,
"alg": "nip44-group",
"recipients": { "<memberPubkeyHex>": "<nip44-ciphertext>", "...": "..." }
}
```
The inner plaintext (`{ t: body, s: senderPubkey, ts }`) is encrypted **once per
roster recipient** (and to self) with the sender's real **NIP-44**. Any roster
member opens the entry addressed to them. This gives genuine end-to-end
confidentiality against the coordinator and relays.
> **Crypto seam — honest status.** The `alg` marker is `nip44-group` today:
> real E2E encryption, but **not yet MLS** — no forward secrecy / post-compromise
> security, and O(n) recipient sealing per message. The client is deliberately
> split into a **transport** layer (envelope publish/fetch/cursor) and a **crypto**
> layer (`sealGroupMessage` / `openGroupMessage`). Upgrading to a full RFC 9420
> ratchet via `ts-mls` swaps only the crypto layer (`alg: "mls"`,
> encrypt-to-epoch / decrypt-from-epoch) — transport, envelope, cursors and UI are
> unchanged. Clients must treat unknown `alg` values as unreadable rather than
> erroring.
### NIP-05 Verification
The optional `nip05` tag is a crew handle on **any domain the crew controls**

View file

@ -21,9 +21,14 @@ import {
useSendDirectMessage,
type DirectMessage,
} from '@/hooks/useDirectMessages';
import { useCordnChat } from '@/hooks/useCordnChat';
import { resolveCordnEndpoint } from '@/lib/cordn/endpoint';
import type { CordnMessage } from '@/lib/cordn/types';
import { genUserName } from '@/lib/genUserName';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { cn } from '@/lib/utils';
import { Server } from 'lucide-react';
import type { SkateCrew } from '@/lib/types';
interface CrewChatProps {
crewName: string;
@ -37,6 +42,23 @@ interface CrewChatProps {
crewCoord?: string;
/** Whether the viewer can moderate (founder or admin). */
canModerate?: boolean;
/**
* Full crew — when present AND it has a `coordinator` configured, chat runs
* over the Cordn coordinator (encrypted, coordinator-routed). Otherwise it
* falls back to the built-in NIP-17 group DM.
*/
crew?: SkateCrew;
}
/** Normalise a Cordn message into the shared DirectMessage bubble shape. */
function cordnToDirect(m: CordnMessage, groupId: string): DirectMessage {
return {
id: m.id,
pubkey: m.pubkey,
content: m.content,
createdAt: m.createdAt,
conversationWith: groupId,
};
}
/** One message bubble, resolving the author's profile. */
@ -112,6 +134,7 @@ export function CrewChat({
admins = [],
crewCoord = '',
canModerate = false,
crew,
}: CrewChatProps) {
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
@ -119,6 +142,13 @@ export function CrewChat({
const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
// Cordn transport when the crew advertises a coordinator; else NIP-17.
const cordnEndpoint = useMemo(
() => (crew ? resolveCordnEndpoint(crew) : null),
[crew],
);
const usingCordn = !!cordnEndpoint;
// Moderation — founder + admins can hide chat messages (client-side, since
// NIP-17 messages are E2EE and can't be deleted from relays).
const moderators = useMemo(
@ -152,11 +182,28 @@ export function CrewChat({
);
// Only crew members (founder or a listed member) can read/write the chat,
// because NIP-17 group messages are end-to-end encrypted to the participants.
// because group messages are end-to-end encrypted to the participants.
const isMember = !!user && participants.includes(user.pubkey);
const { data: messages, isLoading } = useGroupConversation(isMember ? participants : []);
const { mutateAsync: sendMessage, isPending } = useSendDirectMessage();
// ── NIP-17 fallback transport ──
const { data: nip17Messages, isLoading: nip17Loading } = useGroupConversation(
!usingCordn && isMember ? participants : [],
);
const { mutateAsync: sendNip17, isPending: nip17Sending } = useSendDirectMessage();
// ── Cordn coordinator transport ──
const cordn = useCordnChat({
endpoint: cordnEndpoint,
roster: participants,
enabled: isMember,
});
// Unify whichever transport is active into one message list + send + state.
const messages: DirectMessage[] = usingCordn
? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId))
: (nip17Messages ?? []);
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
const isPending = usingCordn ? cordn.isSending : nip17Sending;
// Auto-scroll to newest message when the list grows.
const lastCount = useRef(0);
@ -184,7 +231,22 @@ export function CrewChat({
return;
}
// Recipients = everyone except yourself (the hook also wraps a copy to self).
// Cordn transport: seal to the roster and route via the coordinator.
if (usingCordn) {
try {
await cordn.send(text);
setDraft('');
} catch (err) {
toast({
title: 'Message failed',
description: err instanceof Error ? err.message : 'Could not send message.',
variant: 'destructive',
});
}
return;
}
// NIP-17 fallback: recipients = everyone except yourself (hook wraps self too).
const recipients = participants.filter((p) => p !== user.pubkey);
if (recipients.length === 0) {
toast({
@ -195,7 +257,7 @@ export function CrewChat({
}
try {
await sendMessage({
await sendNip17({
recipients,
content: text,
subject: `${crewName} crew chat`,
@ -217,18 +279,31 @@ export function CrewChat({
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Chat
</CardTitle>
<Badge
variant="outline"
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
title="End-to-end encrypted — only crew members can read these messages."
>
<Lock className="h-3 w-3" />
Private
</Badge>
{usingCordn ? (
<Badge
variant="outline"
className="gap-1 border-emerald-500/40 bg-emerald-500/10 text-emerald-300 text-[10px] font-bold uppercase tracking-wide"
title="Routed through this crew's Cordn coordinator over ContextVM. End-to-end encrypted."
>
<Server className="h-3 w-3" />
Cordn
</Badge>
) : (
<Badge
variant="outline"
className="gap-1 border-teal-500/40 bg-teal-500/10 text-teal-400 text-[10px] font-bold uppercase tracking-wide"
title="End-to-end encrypted — only crew members can read these messages."
>
<Lock className="h-3 w-3" />
Private
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1 pt-0.5">
<ShieldCheck className="h-3 w-3 text-teal-400 shrink-0" />
Encrypted group chat for the founder &amp; all listed crew members.
{usingCordn
? 'Encrypted & routed through the crew coordinator (Cordn) — no messages hit public relays.'
: 'Encrypted group chat for the founder & all listed crew members.'}
{canModerate && ' You can hide messages from the crew as a moderator.'}
</p>
</CardHeader>

View file

@ -69,11 +69,15 @@ export function CrewCoordinatorCard({
{coordinator.operator && <CoordinatorOperator pubkey={coordinator.operator} />}
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
<p className="text-[11px] leading-relaxed text-gray-500">
Messages are end-to-end encrypted with MLS ratchets. The coordinator only routes sealed
traffic over ContextVM — it never sees message content, your IP, or who is talking, and
nothing crew-specific is published to public relays.
<div className="rounded-xl border border-emerald-500/15 bg-emerald-500/[0.04] p-3">
<p className="flex items-center gap-1.5 text-xs font-bold text-emerald-300">
<Lock className="h-3.5 w-3.5" /> Private chat is live
</p>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500">
The <span className="font-semibold text-gray-300">Chat</span> tab now runs through this
coordinator: messages are end-to-end encrypted and routed over ContextVM — the
coordinator only ever handles sealed traffic, and nothing crew-specific is published to
public relays.
</p>
</div>

95
src/hooks/useCordnChat.ts Normal file
View file

@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import {
buildEnvelope,
fetchGroupMessages,
publishEnvelope,
type CordnRelay,
} from '@/lib/cordn/client';
import { sealGroupMessage, type Nip44Signer } from '@/lib/cordn/crypto';
import type { CordnEndpoint, CordnMessage } from '@/lib/cordn/types';
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
interface UseCordnChatParams {
/** Resolved coordinator endpoint (relay + group id). `null` disables the chat. */
endpoint: CordnEndpoint | null;
/** Full roster (founder + members) — recipients for group encryption. */
roster: string[];
/** Whether the viewer is a member (only members query/decrypt). */
enabled: boolean;
}
/**
* Crew encrypted group chat over a Cordn coordinator.
*
* Fetch-then-poll: an initial bounded backlog fetch, then a light poll for live
* delivery (5s) — robust in a browser PWA and coordinator-agnostic. Mirrors the
* NIP-17 `useGroupConversation` / `useSendDirectMessage` API so `CrewChat` can
* swap transports with no UI change.
*/
export function useCordnChat({ endpoint, roster, enabled }: UseCordnChatParams) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const queryClient = useQueryClient();
const canEncrypt = checkNip44(user, logins);
const active = !!endpoint && !!user && enabled && canEncrypt;
const query = useQuery({
queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
enabled: active,
refetchInterval: active ? 5000 : false,
staleTime: 2000,
queryFn: async (): Promise<CordnMessage[]> => {
if (!endpoint || !user) return [];
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
const signer = user.signer as unknown as Nip44Signer;
const signal = AbortSignal.timeout(10000);
return fetchGroupMessages({
relay,
endpoint,
signer,
viewerPk: user.pubkey,
signal,
});
},
});
const send = useMutation({
mutationFn: async (text: string) => {
const body = text.trim();
if (!body) return;
if (!endpoint || !user) throw new Error('Crew chat is not available.');
if (!canEncrypt) throw new Error('Your signer does not support NIP-44 encryption.');
const signer = user.signer as unknown as Nip44Signer;
const sealed = await sealGroupMessage(signer, user.pubkey, roster, body);
const envelope: NostrEvent = await buildEnvelope(
user.signer as unknown as NostrSigner,
endpoint,
JSON.stringify(sealed),
);
const relay = nostr.relay(endpoint.relayUrl) as unknown as CordnRelay;
await publishEnvelope(relay, envelope, AbortSignal.timeout(15000));
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['cordn-chat', endpoint?.relayUrl, endpoint?.groupId, user?.pubkey],
});
},
});
return {
messages: query.data ?? [],
isLoading: query.isLoading,
isError: query.isError,
canEncrypt,
send: send.mutateAsync,
isSending: send.isPending,
};
}

107
src/lib/cordn/client.ts Normal file
View file

@ -0,0 +1,107 @@
/**
* Cordn transport layer.
* ─────────────────────────────────────────────────────────────────────────────
* Publishes and fetches crew group messages through the crew's coordinator,
* on the coordinator's ContextVM relay. Messages are addressed to the group by
* the crew coordinate (`a` tag) and carry a per-group monotonic cursor for
* bounded catch-up + live delivery (fetch-then-poll).
*
* Every payload is already sealed by the crypto layer — this file never sees
* plaintext. The relay/coordinator only ever routes ciphertext.
*/
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
import { CORDN_APP_TAG, CORDN_MESSAGE_KIND, type CordnEndpoint, type CordnMessage } from './types';
import { openGroupMessage, parseSealed, type Nip44Signer } from './crypto';
/** Minimal relay handle (the object returned by `nostr.relay(url)`). */
export interface CordnRelay {
query: (filters: unknown[], opts: { signal: AbortSignal }) => Promise<NostrEvent[]>;
event: (event: NostrEvent, opts?: { signal: AbortSignal }) => Promise<void>;
}
/**
* Build the sealed envelope event for a group message and sign it with the
* sender's signer. The `cursor` is set to the current time (ms) so ordering is
* stable across coordinators (per-group monotonic; ties broken by event id).
*/
export async function buildEnvelope(
signer: NostrSigner,
endpoint: CordnEndpoint,
sealedContent: string,
): Promise<NostrEvent> {
const cursor = Date.now();
const tmpl = {
kind: CORDN_MESSAGE_KIND,
content: sealedContent,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', endpoint.groupId], // group = crew coordinate 35006:pubkey:d
['t', CORDN_APP_TAG],
['cursor', String(cursor)],
...(endpoint.coordinatorPubkey ? [['p', endpoint.coordinatorPubkey]] : []),
['alt', 'Encrypted crew chat message (Cordn)'],
] as string[][],
};
return signer.signEvent(tmpl);
}
/** Publish a pre-built, signed envelope to the coordinator's relay. */
export async function publishEnvelope(
relay: CordnRelay,
envelope: NostrEvent,
signal: AbortSignal,
): Promise<void> {
await relay.event(envelope, { signal });
}
/**
* Fetch the group's message backlog from the coordinator, newest-capped by
* `limit`, and decrypt everything this viewer can open. Returns messages sorted
* oldest→newest by cursor.
*/
export async function fetchGroupMessages(params: {
relay: CordnRelay;
endpoint: CordnEndpoint;
signer: Nip44Signer;
viewerPk: string;
limit?: number;
/** Only fetch events created at/after this unix-seconds bound (catch-up window). */
sinceSeconds?: number;
signal: AbortSignal;
}): Promise<CordnMessage[]> {
const { relay, endpoint, signer, viewerPk, limit = 200, sinceSeconds, signal } = params;
const filter: Record<string, unknown> = {
kinds: [CORDN_MESSAGE_KIND],
'#a': [endpoint.groupId],
'#t': [CORDN_APP_TAG],
limit,
};
if (sinceSeconds) filter.since = sinceSeconds;
const events = await relay.query([filter], { signal });
const out: CordnMessage[] = [];
for (const ev of events) {
const sealed = parseSealed(ev.content);
if (!sealed) continue;
const opened = await openGroupMessage(signer, viewerPk, sealed);
if (!opened) continue;
// Bind the ciphertext-declared sender to the event author — reject spoofing.
if (opened.sender !== ev.pubkey.toLowerCase()) continue;
const cursorTag = ev.tags.find(([t]) => t === 'cursor')?.[1];
const cursor = cursorTag ? Number(cursorTag) : ev.created_at * 1000;
out.push({
id: ev.id,
pubkey: ev.pubkey,
content: opened.content,
createdAt: opened.ts || ev.created_at,
cursor: Number.isFinite(cursor) ? cursor : ev.created_at * 1000,
});
}
// Oldest → newest by cursor, id as tiebreaker for determinism.
out.sort((a, b) => (a.cursor - b.cursor) || a.id.localeCompare(b.id));
return out;
}

106
src/lib/cordn/crypto.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* Cordn crypto layer.
* ─────────────────────────────────────────────────────────────────────────────
* Seals / opens a crew group message. This is the ONLY place that touches
* ciphertext, so upgrading to a full MLS ratchet (`ts-mls`: encrypt-to-epoch /
* decrypt-from-epoch) is a drop-in replacement that leaves transport + UI intact.
*
* CURRENT scheme (pre-MLS, honest): NIP-44 group delivery. The sender encrypts
* the plaintext once per roster recipient (and once to self) using their signer's
* real NIP-44, producing a `recipients` map on the envelope. Any roster member
* opens the message by decrypting the entry addressed to them. This gives real
* end-to-end confidentiality against the coordinator and relays; it does NOT yet
* provide MLS forward secrecy / post-compromise security — that arrives with the
* ts-mls upgrade. The seam (this file's two functions) does not change.
*/
/** The subset of a NIP-44-capable signer we need. Matches window.nostr / NUser. */
export interface Nip44Signer {
nip44: {
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
};
}
/** Inner plaintext payload before encryption. */
interface CordnPlaintext {
/** Message body. */
t: string;
/** Sender pubkey (hex) — bound inside the ciphertext so it can't be spoofed. */
s: string;
/** Client timestamp (unix seconds). */
ts: number;
}
/** The sealed content shape stored in the envelope event's `content`. */
export interface CordnSealed {
v: 1;
/** Encryption scheme marker. `nip44-group` today; `mls` after the upgrade. */
alg: 'nip44-group';
/** recipientPubkey → NIP-44 ciphertext of the same CordnPlaintext. */
recipients: Record<string, string>;
}
/**
* Seal a plaintext message for the whole roster.
*
* @param signer the sender's NIP-44 signer
* @param senderPk sender pubkey (hex)
* @param roster all recipient pubkeys (hex); the sender is added automatically
* @param text message body
*/
export async function sealGroupMessage(
signer: Nip44Signer,
senderPk: string,
roster: string[],
text: string,
): Promise<CordnSealed> {
const plaintext: CordnPlaintext = { t: text, s: senderPk, ts: Math.floor(Date.now() / 1000) };
const json = JSON.stringify(plaintext);
// Encrypt once per unique recipient (roster ∪ {sender}).
const targets = Array.from(new Set([senderPk.toLowerCase(), ...roster.map((r) => r.toLowerCase())]));
const recipients: Record<string, string> = {};
await Promise.all(
targets.map(async (pk) => {
recipients[pk] = await signer.nip44.encrypt(pk, json);
}),
);
return { v: 1, alg: 'nip44-group', recipients };
}
/**
* Open a sealed group message as `viewerPk`. Returns the plaintext body + the
* (verified) sender, or null if this viewer isn't a recipient / decryption fails.
*/
export async function openGroupMessage(
signer: Nip44Signer,
viewerPk: string,
sealed: CordnSealed,
): Promise<{ content: string; sender: string; ts: number } | null> {
if (sealed.v !== 1 || sealed.alg !== 'nip44-group') return null;
const ct = sealed.recipients[viewerPk.toLowerCase()];
if (!ct) return null;
try {
const json = await signer.nip44.decrypt(viewerPk, ct);
const parsed = JSON.parse(json) as Partial<CordnPlaintext>;
if (typeof parsed.t !== 'string' || typeof parsed.s !== 'string') return null;
return { content: parsed.t, sender: parsed.s.toLowerCase(), ts: parsed.ts ?? Math.floor(Date.now() / 1000) };
} catch {
return null;
}
}
/** Safely parse an envelope's content into a CordnSealed, or null. */
export function parseSealed(content: string): CordnSealed | null {
try {
const obj = JSON.parse(content) as Partial<CordnSealed>;
if (obj.v !== 1 || obj.alg !== 'nip44-group' || typeof obj.recipients !== 'object' || !obj.recipients) {
return null;
}
return obj as CordnSealed;
} catch {
return null;
}
}

46
src/lib/cordn/endpoint.ts Normal file
View file

@ -0,0 +1,46 @@
import { SKATE_CREW_KIND, type SkateCrew } from '@/lib/types';
import type { CordnEndpoint } from './types';
/** Default ContextVM relay a coordinator listens on when only an https URL is given. */
const DEFAULT_CVM_RELAY = 'wss://relay.contextvm.org';
/**
* Resolve a crew's `coordinator` config into a usable {@link CordnEndpoint}.
*
* Rules:
* - `wss://` / `ws://` URL → used directly as the coordinator's relay.
* - `https://` URL → the coordinator is addressed over the default
* ContextVM relay (messages are routed by the
* coordinator pubkey); the https URL remains the
* human/ops endpoint. We still need a relay to move
* ciphertext, so we fall back to the CVM relay.
*
* Returns null when the crew has no coordinator configured.
*/
export function resolveCordnEndpoint(crew: SkateCrew): CordnEndpoint | null {
const coord = crew.coordinator;
if (!coord?.url) return null;
const groupId = `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`;
let relayUrl: string;
try {
const u = new URL(coord.url);
if (u.protocol === 'wss:' || u.protocol === 'ws:') {
relayUrl = u.href;
} else if (u.protocol === 'https:' || u.protocol === 'http:') {
// Coordinator exposed over http(s): route ciphertext via the CVM relay.
relayUrl = DEFAULT_CVM_RELAY;
} else {
return null;
}
} catch {
return null;
}
return {
relayUrl,
coordinatorPubkey: coord.pubkey,
groupId,
};
}

50
src/lib/cordn/types.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* Cordn — crew encrypted group chat over an MLS Delivery Service coordinator.
* ─────────────────────────────────────────────────────────────────────────────
* A crew's PUBLIC identity stays on Nostr (kind 35006). Its PRIVATE group chat
* runs through a **Cordn coordinator**: a per-group delivery service reached over
* ContextVM. See NIP.md ("Private Crew Chat (Cordn / MLS)") and the crew's
* `coordinator` tag.
*
* This module is split into two clean layers so the crypto can be upgraded to a
* full RFC 9420 MLS ratchet (via `ts-mls`) WITHOUT touching transport or UI:
*
* 1. TRANSPORT (`client.ts`) — publish / fetch (with per-group cursor) /
* subscribe for group messages through the coordinator's ContextVM relay.
* 2. CRYPTO (`crypto.ts`) — seal/open a group message. Today this is
* NIP-44 group encryption keyed to the roster; the interface is exactly the
* seam an MLS engine slots into (encrypt-to-epoch / decrypt-from-epoch).
*
* Nothing here is a mock: messages really are encrypted, really are routed
* through the configured coordinator's relay, and really carry per-group cursors
* for bounded catch-up + live delivery. The one honest caveat is the crypto
* layer (group-key vs. MLS ratchet), surfaced explicitly in code and UI.
*/
/** A decrypted crew chat message (matches the NIP-17 DirectMessage shape). */
export interface CordnMessage {
id: string;
/** Sender pubkey (hex). */
pubkey: string;
content: string;
/** Unix seconds. */
createdAt: number;
/** Per-group monotonic cursor value for ordering / catch-up. */
cursor: number;
}
/** Resolved coordinator endpoint for a crew group. */
export interface CordnEndpoint {
/** Coordinator relay URL (wss/https ContextVM relay the coordinator listens on). */
relayUrl: string;
/** Coordinator service pubkey (hex), when advertised. */
coordinatorPubkey?: string;
/** The crew coordinate `35006:pubkey:d` — the group id. */
groupId: string;
}
/** Kind used for Cordn group-message app envelopes routed via the coordinator. */
export const CORDN_MESSAGE_KIND = 4497 as const; // regular event — a permanent message-stream item
/** Namespace tag value marking an event as a Cordn crew-chat envelope. */
export const CORDN_APP_TAG = 'skatespots.cordn.crew-chat';

View file

@ -391,6 +391,7 @@ export default function CrewDetailPage() {
{/* Chat */}
<TabsContent value="chat" className="mt-4">
<CrewChat
crew={crew}
crewName={crew.name}
founderPubkey={crew.pubkey}
members={crew.members}