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. |
| `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). |
| `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
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. |
| `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. |
| `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. |
| `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
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
to run over **Cordn** — an MLS ([RFC 9420](https://datatracker.ietf.org/doc/rfc9420/))
Delivery Service coordinator, **one per group**, reached over ContextVM.
Nostr as the kind-`35006` event above.
A crew advertises its coordinator with the optional `coordinator` tag (see the
Optional Tags table). This is intentionally just a **pointer**:
**A crew's private group chat is end-to-end encrypted and on by default** — no
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
protocol-specific messaging events (no per-message kinds) are ever published to
public relays, so there is nothing crew-specific to fingerprint or cluster.
- **Client-side / under MLS:** message content, the MLS member tree, epoch keys
and ratchets. Forward secrecy and post-compromise security hold; membership
changes are O(log n).
**Optionally**, a technical founder who **self-hosts a
[Cordn](https://github.com/Cordn-msg/cordn) coordinator** (an MLS
[RFC 9420](https://datatracker.ietf.org/doc/rfc9420/) Delivery Service reached
over ContextVM, one per group) can advertise it with the optional `coordinator`
tag (see the Optional Tags table) to route chat through it instead. This is a
**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
content, client IPs, or (for routine sends using ephemeral transport keys) who
is talking. If it is unreachable, chat delivery stalls but the group, its
membership and its history on members' devices survive; groups can migrate
coordinators (per-group cursors preserve ordering).
Cordn is chosen over Marmot (fully decentralised MLS) and Concord (shared-key,
no forward secrecy) because its **coordinator-per-group** model mirrors a crew's
**leader / coordinator** structure, it is browser/TypeScript-native, and it keeps
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.
content or client IPs. If it is unreachable, chat delivery stalls but the
group, its membership and its history on members' devices survive; groups can
migrate 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
seam below) — real E2E confidentiality, but **not yet** an MLS ratchet, so
forward secrecy / post-compromise security are a future upgrade, not a present
claim.
##### Message envelope (kind `4497`) & delivery

View file

@ -41,7 +41,7 @@ import {
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useAppContext } from '@/hooks/useAppContext';
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_ICONS: Record<CrewFocus, typeof Users> = {
@ -1082,14 +1082,24 @@ export function CrewForm({ existing }: CrewFormProps) {
);
const coordinatorBlock = (
<div className="space-y-2 rounded-lg border border-emerald-500/30 bg-emerald-500/[0.04] p-4">
<Label htmlFor="crew-coordinator" className="flex items-center gap-2 text-emerald-400">
<Lock className="h-4 w-4" /> Private Chat Coordinator <span className="font-normal text-gray-500">· advanced</span>
<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-gray-300">
<Server className="h-4 w-4" /> Custom Chat Coordinator <span className="font-normal text-gray-500">· experimental · self-host</span>
</Label>
<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
(an MLS delivery service reached over ContextVM), paste its URL to enable end-to-end encrypted group chat —
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">You don't need this.</span> Your crew already has private,
end-to-end encrypted group chat built in — no setup. This field is only for crews who{' '}
<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>
<Input
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).
*
* A crew's PUBLIC identity lives on Nostr; its PRIVATE group chat is designed to
* run over Cordn — an MLS (RFC 9420) coordinator, one per group, reached over
* ContextVM. This card surfaces which coordinator the crew uses, explains what
* the coordinator can and cannot see, and (for the leader) prompts to set one
* when none is configured.
* Every crew already has built-in end-to-end encrypted group chat (NIP-17
* giftwrap to the roster) — no setup required. This card:
* - by default, reassures the crew that private chat is already ON; and
* - if a founder has opted into routing chat through their own self-hosted
* 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({
crew,
@ -27,28 +31,47 @@ export function CrewCoordinatorCard({
const { coordinator } = crew;
if (!coordinator) {
// Only the leader sees the "set it up" prompt; others see nothing (keeps the
// page clean for crews that just use the built-in feed chat for now).
if (!isFounder) return null;
// Default state: private chat is ALREADY on for every crew (built-in NIP-17
// giftwrap group chat, no setup). We say so plainly rather than implying a
// coordinator is required. Founders get a small, honestly-labeled link to
// the experimental self-hosted-coordinator option.
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">
<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>
</CardHeader>
<CardContent className="space-y-3">
<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
for your crew, powered by an MLS coordinator (Cordn). Forward secrecy, post-compromise
security, and nothing about your chat ever hits a public relay.
The <span className="font-semibold text-gray-200">Chat</span> tab is{' '}
<span className="font-bold text-teal-300">end-to-end encrypted</span> for the founder and all
listed members — nothing to set up. Messages are sealed to each member's key (NIP-17), so only
your crew can read them.
</p>
<Link
to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}
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"
>
<Settings className="h-4 w-4" /> Set up a coordinator
</Link>
{isFounder && (
<p className="text-[11px] leading-relaxed text-gray-600">
Advanced: run your own{' '}
<a
href="https://github.com/Cordn-msg/cordn"
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>
</Card>
);
@ -60,7 +83,7 @@ export function CrewCoordinatorCard({
<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
<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>
</CardTitle>
</CardHeader>
@ -74,10 +97,10 @@ export function CrewCoordinatorCard({
<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.
The <span className="font-semibold text-gray-300">Chat</span> tab runs through this
coordinator: messages are end-to-end encrypted (NIP-44, sealed to each member) and routed
over ContextVM, so the coordinator only ever handles sealed traffic and nothing
crew-specific hits a public relay.
</p>
</div>