Crew chat: frame built-in E2E chat as the default, Cordn as honest opt-in

Decision (per Marmot/Concord/Cordn analysis): a semi-public, socially-verified
skate crew fits Concord-style shared-key chat, not MLS. The built-in NIP-17
giftwrap group chat is already the right answer and works with zero setup — so
stop implying a coordinator is required and stop over-claiming MLS guarantees
the code doesn't yet provide.

- CrewCoordinatorCard: default state now reassures that private E2E chat is
  already ON (no setup) for everyone, instead of prompting founders to "set up a
  coordinator". Founders get a small, honestly-labeled advanced link to the
  self-hosted Cordn option. Fixed the "E2E · MLS / forward secrecy" badge — the
  coordinator path is NIP-44 group encryption routed over ContextVM, not an MLS
  ratchet.
- CrewForm: the coordinator field is now clearly "experimental · self-host",
  states you don't need it, and links to the Cordn repo.
- NIP.md: reworded the crew-chat section + tag/profile descriptions to match the
  honest crypto seam (NIP-44 group today; MLS a future upgrade), so docs no
  longer claim forward secrecy that isn't shipped.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 18:02:53 +07:00
parent e698e4880f
commit 739469aa1d
3 changed files with 91 additions and 55 deletions

51
NIP.md
View file

@ -14,7 +14,7 @@ other clients, and preserved on edit:
| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. | | `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. |
| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. | | `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. |
| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). | | `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). |
| `cordn` | string | A Cordn (cordn.net) contact URL for private, MLS-encrypted messaging. | | `cordn` | string | A Cordn (cordn.net) contact URL for private, end-to-end encrypted messaging via a self-hosted coordinator. |
`lud16` (Lightning address) is standard NIP-01 but is also editable directly in `lud16` (Lightning address) is standard NIP-01 but is also editable directly in
the SkateSpots profile form for convenience. the SkateSpots profile form for convenience.
@ -2128,7 +2128,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. | | `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. | | `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
| `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. | | `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. |
| `coordinator` | Optional **encrypted-chat coordinator** for the crew's private group. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a **Cordn** coordinator (an MLS / RFC 9420 Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content, membership secrets and MLS ratchets live client-side. Nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. | | `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. |
| `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. | | `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. |
| `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", <memberPubkey>, <role>]`, repeatable (a member may hold several). `<role>` ∈ the full Lab-craft set — `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `graffiti`, `tattoo`, `trainer`, `musician`, `judge` — plus the crew-specific `manager` and `og`. The Lab-craft roles mirror the Lab profession kinds (35001–35012), so a crew's "filmer" speaks the same language as a Lab Filmer profile. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. | | `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", <memberPubkey>, <role>]`, repeatable (a member may hold several). `<role>` ∈ the full Lab-craft set — `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `graffiti`, `tattoo`, `trainer`, `musician`, `judge` — plus the crew-specific `manager` and `og`. The Lab-craft roles mirror the Lab profession kinds (35001–35012), so a crew's "filmer" speaks the same language as a Lab Filmer profile. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. |
@ -2469,31 +2469,34 @@ by event id).
#### Private Crew Chat (Cordn / MLS) — the `coordinator` tag #### Private Crew Chat (Cordn / MLS) — the `coordinator` tag
A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on A crew's **public** identity (roster, spots, events, treasury, sponsor) lives on
Nostr as the kind-`35006` event above. A crew's **private** group chat is designed Nostr as the kind-`35006` event above.
to run over **Cordn** — an MLS ([RFC 9420](https://datatracker.ietf.org/doc/rfc9420/))
Delivery Service coordinator, **one per group**, reached over ContextVM.
A crew advertises its coordinator with the optional `coordinator` tag (see the **A crew's private group chat is end-to-end encrypted and on by default** — no
Optional Tags table). This is intentionally just a **pointer**: setup. The built-in transport is **NIP-17 giftwrap group DM**: every message is
sealed to each member's key and published as gift wraps, so only the founder and
listed members can read it and relays see only ciphertext. This is the right fit
for a semi-public, socially-verified skate crew (cf. Concord's shared-key model
for "large casual rooms").
- **On relays:** only the public crew event and a coordinator URL. No **Optionally**, a technical founder who **self-hosts a
protocol-specific messaging events (no per-message kinds) are ever published to [Cordn](https://github.com/Cordn-msg/cordn) coordinator** (an MLS
public relays, so there is nothing crew-specific to fingerprint or cluster. [RFC 9420](https://datatracker.ietf.org/doc/rfc9420/) Delivery Service reached
- **Client-side / under MLS:** message content, the MLS member tree, epoch keys over ContextVM, one per group) can advertise it with the optional `coordinator`
and ratchets. Forward secrecy and post-compromise security hold; membership tag (see the Optional Tags table) to route chat through it instead. This is a
changes are O(log n). **pointer, not a requirement**:
- **On relays:** only the public crew event and a coordinator URL. With the
coordinator path, no per-message events hit public relays at all, so there is
nothing crew-specific to fingerprint or cluster.
- **The coordinator** routes sealed ContextVM traffic and never sees message - **The coordinator** routes sealed ContextVM traffic and never sees message
content, client IPs, or (for routine sends using ephemeral transport keys) who content or client IPs. If it is unreachable, chat delivery stalls but the
is talking. If it is unreachable, chat delivery stalls but the group, its group, its membership and its history on members' devices survive; groups can
membership and its history on members' devices survive; groups can migrate migrate coordinators (per-group cursors preserve ordering).
coordinators (per-group cursors preserve ordering). - **Crypto status (honest):** the coordinator path currently uses the same
**NIP-44 group encryption** as the built-in chat (see the envelope's crypto
Cordn is chosen over Marmot (fully decentralised MLS) and Concord (shared-key, seam below) — real E2E confidentiality, but **not yet** an MLS ratchet, so
no forward secrecy) because its **coordinator-per-group** model mirrors a crew's forward secrecy / post-compromise security are a future upgrade, not a present
**leader / coordinator** structure, it is browser/TypeScript-native, and it keeps claim.
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 ##### Message envelope (kind `4497`) & delivery

View file

@ -41,7 +41,7 @@ import {
import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon } from 'lucide-react'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react';
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness']; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = { const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
@ -1082,14 +1082,24 @@ export function CrewForm({ existing }: CrewFormProps) {
); );
const coordinatorBlock = ( const coordinatorBlock = (
<div className="space-y-2 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.04] p-4"> <div className="space-y-2 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-emerald-400"> <Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-gray-300">
<Lock className="h-4 w-4" /> Private Chat Coordinator <span className="font-normal text-gray-500">· advanced</span> <Server className="h-4 w-4" /> Custom Chat Coordinator <span className="font-normal text-gray-500">· experimental · self-host</span>
</Label> </Label>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Most crews can skip this. If you run a <span className="font-semibold text-emerald-300">Cordn</span> coordinator <span className="font-semibold text-gray-300">You don't need this.</span> Your crew already has private,
(an MLS delivery service reached over ContextVM), paste its URL to enable end-to-end encrypted group chat — end-to-end encrypted group chat built in — no setup. This field is only for crews who{' '}
nothing crew-specific ever hits a public relay. Leave blank and your crew still gets a private group chat. <span className="font-semibold text-gray-300">run their own</span>{' '}
<a
href="https://github.com/Cordn-msg/cordn"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
>
Cordn coordinator
</a>{' '}
(a small self-hosted MLS delivery service). Paste its URL to route chat through it instead. Leave blank
to use the built-in chat.
</p> </p>
<Input <Input
id="crew-coordinator" id="crew-coordinator"

View file

@ -11,11 +11,15 @@ import { nip19 } from 'nostr-tools';
/** /**
* Shows the crew's encrypted-chat coordinator (Cordn / MLS Delivery Service). * Shows the crew's encrypted-chat coordinator (Cordn / MLS Delivery Service).
* *
* A crew's PUBLIC identity lives on Nostr; its PRIVATE group chat is designed to * Every crew already has built-in end-to-end encrypted group chat (NIP-17
* run over Cordn — an MLS (RFC 9420) coordinator, one per group, reached over * giftwrap to the roster) — no setup required. This card:
* ContextVM. This card surfaces which coordinator the crew uses, explains what * - by default, reassures the crew that private chat is already ON; and
* the coordinator can and cannot see, and (for the leader) prompts to set one * - if a founder has opted into routing chat through their own self-hosted
* when none is configured. * Cordn coordinator, surfaces which coordinator is in use.
*
* Note: the coordinator path currently provides NIP-44 group encryption routed
* over ContextVM (nothing crew-specific hits a public relay). It is NOT yet a
* full MLS ratchet, so we deliberately avoid claiming forward secrecy here.
*/ */
export function CrewCoordinatorCard({ export function CrewCoordinatorCard({
crew, crew,
@ -27,28 +31,47 @@ export function CrewCoordinatorCard({
const { coordinator } = crew; const { coordinator } = crew;
if (!coordinator) { if (!coordinator) {
// Only the leader sees the "set it up" prompt; others see nothing (keeps the // Default state: private chat is ALREADY on for every crew (built-in NIP-17
// page clean for crews that just use the built-in feed chat for now). // giftwrap group chat, no setup). We say so plainly rather than implying a
if (!isFounder) return null; // coordinator is required. Founders get a small, honestly-labeled link to
// the experimental self-hosted-coordinator option.
return ( return (
<Card className="border-2 border-dashed border-emerald-500/25 bg-gradient-to-br from-emerald-500/[0.05] to-transparent"> <Card className="border-2 border-teal-500/25 bg-gradient-to-br from-teal-500/[0.06] to-black/90">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white"> <CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat <Lock className="h-5 w-5 text-teal-400" /> Private Crew Chat
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-teal-300">
<ShieldCheck className="h-3 w-3" /> On
</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<p className="text-sm leading-relaxed text-gray-400"> <p className="text-sm leading-relaxed text-gray-400">
Turn on <span className="font-bold text-emerald-300">end-to-end encrypted</span> group chat The <span className="font-semibold text-gray-200">Chat</span> tab is{' '}
for your crew, powered by an MLS coordinator (Cordn). Forward secrecy, post-compromise <span className="font-bold text-teal-300">end-to-end encrypted</span> for the founder and all
security, and nothing about your chat ever hits a public relay. listed members — nothing to set up. Messages are sealed to each member's key (NIP-17), so only
your crew can read them.
</p> </p>
<Link {isFounder && (
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`} <p className="text-[11px] leading-relaxed text-gray-600">
className="inline-flex items-center gap-1.5 rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm font-bold text-emerald-300 transition-colors hover:bg-emerald-500/20" Advanced: run your own{' '}
> <a
<Settings className="h-4 w-4" /> Set up a coordinator href="https://github.com/Cordn-msg/cordn"
</Link> target="_blank"
rel="noopener noreferrer"
className="text-gray-400 underline underline-offset-2 hover:text-teal-300"
>
Cordn coordinator
</a>
?{' '}
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
className="inline-flex items-center gap-1 font-semibold text-gray-400 hover:text-teal-300"
>
<Settings className="h-3 w-3" /> Route chat through it
</Link>
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
@ -60,7 +83,7 @@ export function CrewCoordinatorCard({
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white"> <CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
<Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat <Lock className="h-5 w-5 text-emerald-400" /> Private Crew Chat
<span className="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-emerald-300"> <span className="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<ShieldCheck className="h-3 w-3" /> E2E · MLS <Server className="h-3 w-3" /> Coordinator
</span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -74,10 +97,10 @@ export function CrewCoordinatorCard({
<Lock className="h-3.5 w-3.5" /> Private chat is live <Lock className="h-3.5 w-3.5" /> Private chat is live
</p> </p>
<p className="mt-1 text-[11px] leading-relaxed text-gray-500"> <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 The <span className="font-semibold text-gray-300">Chat</span> tab runs through this
coordinator: messages are end-to-end encrypted and routed over ContextVM — the coordinator: messages are end-to-end encrypted (NIP-44, sealed to each member) and routed
coordinator only ever handles sealed traffic, and nothing crew-specific is published to over ContextVM, so the coordinator only ever handles sealed traffic and nothing
public relays. crew-specific hits a public relay.
</p> </p>
</div> </div>