diff --git a/NIP.md b/NIP.md index bfae7d6..60fe62a 100644 --- a/NIP.md +++ b/NIP.md @@ -2056,6 +2056,8 @@ bare `p` tag on the crew is only a claim until the member self-labels. | `noffer` | Optional shared tip jar (CLINK Offer — see 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. | +| `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", , , ]`. `` 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. `` is the coordinator service pubkey (hex) for ContextVM addressing; `` is the human (hex) who runs it, if delegated. | ### Crew-Hosted Events @@ -2187,13 +2189,83 @@ A client honors a label only when its **author is the crew founder or a listed admin**, so outsiders can't forge moderation. Moderators still see hidden items (greyed, marked "Hidden") so the action is transparent and reversible. -#### Invites +#### Invites (kind `36229`) — structured, notifiable Because only the founder can write the crew's `p` (member) list, an admin cannot -silently add members. Every moderator can **invite** — share a link/message to -the crew page where the invitee, once listed by the founder, confirms membership -with their own key (the standard bilateral attestation above). Founders get an -extra shortcut to add a member key directly in crew settings. +silently add members. A moderator (founder or admin) can send a **structured +invite**: an addressable event (kind `36229`) that `p`-tags a specific invitee so +that person can query "invites addressed to me" and be **notified**, then decide +Accept or Decline. The invite carries nothing sensitive — real membership stays +unforgeable (founder `p` tag + invitee attestation). + +```json +{ + "kind": 36229, + "content": "Come skate with us — the ledge crew wants you.", + "tags": [ + ["d", "35006:::"], + ["p", ""], + ["a", "35006::"], + ["crew_name", "Barcelona Night Crew"], + ["role", "member"], + ["alt", "Crew invite: join Barcelona Night Crew on SkateSpots"] + ] +} +``` + +| Tag | Description | +|-----|-------------| +| `d` | Deterministic id `:` — re-inviting the same person **replaces** the prior invite (addressable). | +| `p` | The invitee's pubkey (hex). Queryable at the relay level so the recipient can find invites addressed to them: `{ kinds: [36229], "#p": [""] }`. | +| `a` | The crew coordinate (`35006:pubkey:d`) being invited to. | +| `crew_name` | Readable crew name for rendering the notification without a second fetch. | +| `role` | `member` (default) or `admin` — the role proposed for the invitee. | + +**Flow:** + +1. **Send.** A moderator publishes the invite. When the sender is the **founder**, + they *optimistically pre-list* the invitee's `p` tag on the crew event, so the + moment the invitee accepts (attests) they are a **confirmed** member with no + extra founder step. An **admin** can send the invite but cannot edit the + founder's event; the founder confirms the add on accept. +2. **Notify.** The invitee's client queries `#p` invites and surfaces a + notification with **Accept** / **Decline**. +3. **Accept.** The invitee publishes their own NIP-32 membership attestation + (kind `1985`, as above). Since the invitee can't delete an invite they didn't + author, the invite is cleared from *their* notifications via a local dismissal. +4. **Decline.** Local dismissal only — no event required. + +Founders also retain a shareable **link/message** invite for people not yet on +Nostr, and a shortcut to add a member key directly in crew settings. + +#### 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. + +A crew advertises its coordinator with the optional `coordinator` tag (see the +Optional Tags table). This is intentionally just a **pointer**: + +- **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). +- **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. ### NIP-05 Verification diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 44743ae..f43d2cb 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -20,6 +20,8 @@ import { NofferField } from '@/components/NofferField'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, + SKATE_SHOP_KIND, + parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, @@ -34,7 +36,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 } from 'lucide-react'; +import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Lock } from 'lucide-react'; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness']; const FOCUS_ICONS: Record = { @@ -168,6 +170,10 @@ export function CrewForm({ existing }: CrewFormProps) { const toggleLookingFor = (slug: string) => setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug])); + // Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator URL. + const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? ''); + const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? ''); + // Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing. const { data: existingGoal } = useCrewGoal(existing?.goalRef); const { config } = useAppContext(); @@ -386,6 +392,18 @@ export function CrewForm({ existing }: CrewFormProps) { tags.push(['looking-for', slug]); } + // Sponsoring shop — must be a valid NIP-15 stall coordinate (30017:pubkey:d). + const sponsorCoord = parseAddrCoord(sponsorRef.trim()); + if (sponsorCoord && sponsorCoord.kind === SKATE_SHOP_KIND) { + tags.push(['sponsor', sponsorCoord.coord]); + } + + // Cordn / MLS coordinator for the crew's encrypted chat (https/wss URL only). + const coordUrl = coordinatorUrl.trim(); + if (coordUrl && /^(https?|wss?):\/\//i.test(coordUrl)) { + tags.push(['coordinator', coordUrl]); + } + await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags }); toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' }); @@ -724,6 +742,44 @@ export function CrewForm({ existing }: CrewFormProps) { {/* Linked Lab profiles (cross-link) */} + {/* Sponsor shop */} +
+ +

+ If a local shop backs your crew, paste their shop address here to show a + “Sponsored by” banner linking to them. Use the shop's coordinate: + 30017:pubkey:d. +

+ setSponsorRef(e.target.value)} + className="bg-gray-800 border-gray-700 font-mono text-xs" + /> +
+ + {/* Encrypted chat coordinator (Cordn / MLS) */} +
+ +

+ Point your crew at a Cordn coordinator + (an MLS delivery service reached over ContextVM) to enable end-to-end encrypted group chat. + Nothing crew-specific ever hits a public relay. Paste the coordinator's URL. +

+ setCoordinatorUrl(e.target.value)} + className="bg-gray-800 border-gray-700 font-mono text-xs" + /> +
+
diff --git a/src/components/crew/CrewCoordinatorCard.tsx b/src/components/crew/CrewCoordinatorCard.tsx new file mode 100644 index 0000000..d054ab1 --- /dev/null +++ b/src/components/crew/CrewCoordinatorCard.tsx @@ -0,0 +1,142 @@ +import { Link } from 'react-router-dom'; +import { Lock, ShieldCheck, Server, ExternalLink, Settings } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; +import type { CrewCoordinator, SkateCrew } from '@/lib/types'; +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. + */ +export function CrewCoordinatorCard({ + crew, + isFounder, +}: { + crew: SkateCrew; + isFounder: boolean; +}) { + 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; + return ( + + + + Private Crew Chat + + + +

+ Turn on end-to-end encrypted 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. +

+ + Set up a coordinator + +
+
+ ); + } + + return ( + + + + Private Crew Chat + + E2E · MLS + + + + + + + {coordinator.operator && } + +
+

+ 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. +

+
+ + {isFounder && ( + + Manage coordinator + + )} +
+
+ ); +} + +function CoordinatorEndpoint({ coordinator }: { coordinator: CrewCoordinator }) { + const safe = sanitizeUrl(coordinator.url); + return ( +
+
+ +
+
+

+ Coordinator +

+

{urlDomain(coordinator.url)}

+
+ {safe && ( + + + + )} +
+ ); +} + +function CoordinatorOperator({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? genUserName(pubkey); + const pic = author.data?.metadata?.picture; + const npub = nip19.npubEncode(pubkey); + + return ( + + + + {name.slice(0, 2).toUpperCase()} + +
+

Coordinator run by

+

{name}

+
+ + ); +} diff --git a/src/components/crew/CrewSponsorBanner.tsx b/src/components/crew/CrewSponsorBanner.tsx new file mode 100644 index 0000000..da7fe61 --- /dev/null +++ b/src/components/crew/CrewSponsorBanner.tsx @@ -0,0 +1,62 @@ +import { Link } from 'react-router-dom'; +import { Store, ChevronRight, Loader2, BadgeCheck } from 'lucide-react'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useSkateShop } from '@/hooks/useSkateShops'; +import { parseAddrCoord } from '@/lib/types'; + +/** + * "Sponsored by [Shop]" banner for a crew backed by a local skate shop. + * + * Renders nothing if the crew has no sponsor, or the referenced shop can't be + * resolved — a sponsor should be a real, linkable shop (NIP-15 stall), not a + * dangling claim. + */ +export function CrewSponsorBanner({ sponsorRef }: { sponsorRef: string | undefined }) { + const coord = sponsorRef ? parseAddrCoord(sponsorRef) : null; + const { data: shop, isLoading } = useSkateShop(coord?.pubkey, coord?.identifier); + + if (!coord) return null; + + if (isLoading) { + return ( +
+ + Loading sponsor… +
+ ); + } + + if (!shop) return null; + + const logo = shop.images?.[0]; + + return ( + +
+ + + + + + + +
+

+ Sponsored by +

+

{shop.name}

+ {shop.location && ( +

{shop.location}

+ )} +
+ + + + ); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 8f6da73..69e0fe9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1611,10 +1611,37 @@ export interface SkateCrew { labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d) goalRef?: string; // NIP-75 zap goal event id (crew funding goal / treasury) lookingFor: string[]; // Lab craft slugs the crew is hiring for (e.g. "filmer", "editor") + sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag + coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew createdAt: number; event: NostrEvent; } +/** + * A crew's encrypted-group coordinator (Cordn / MLS Delivery Service). + * + * SkateSpots crews keep their PUBLIC identity on Nostr (roster, spots, events), + * but the crew's private group chat is designed to run over Cordn — an MLS + * (RFC 9420) delivery-service coordinator, one per group, reached over ContextVM. + * A crew can advertise which coordinator it uses so members' clients know where + * to publish KeyPackages and fetch group messages. + * + * This is intentionally just a pointer: the crew event stays public and + * discoverable, while message content, membership secrets and ratchets live + * client-side under MLS. See the "Marmot / Concord / Cordn" analysis — Cordn is + * the chosen fit because its coordinator-per-group model mirrors a crew's + * leader/coordinator structure, it is browser/TypeScript-native, and it leaks no + * protocol-specific events onto public relays. + */ +export interface CrewCoordinator { + /** ContextVM server / coordinator endpoint (https or wss URL). */ + url: string; + /** Optional coordinator service pubkey (hex) for ContextVM addressing. */ + pubkey?: string; + /** Human pubkey (hex) responsible for running/operating the coordinator, if delegated. */ + operator?: string; +} + export interface SkateBuilder { id: string; pubkey: string; @@ -2401,6 +2428,20 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { const lookingFor = event.tags .filter(([name, value]) => name === 'looking-for' && !!value?.trim()) .map(([, value]) => value.trim().toLowerCase()); + // Sponsoring shop — a NIP-15 stall coordinate (30017:pubkey:d). + const sponsorRef = event.tags.find( + ([name, value]) => name === 'sponsor' && value?.startsWith(`${SKATE_SHOP_KIND}:`), + )?.[1]; + // Cordn / MLS coordinator for the crew's encrypted group chat. + // Tag shape: ['coordinator', , , ] + const coordinatorTag = event.tags.find(([name, value]) => name === 'coordinator' && !!value?.trim()); + const coordinator: CrewCoordinator | undefined = coordinatorTag + ? { + url: coordinatorTag[1].trim(), + pubkey: /^[0-9a-f]{64}$/i.test(coordinatorTag[2] ?? '') ? coordinatorTag[2] : undefined, + operator: /^[0-9a-f]{64}$/i.test(coordinatorTag[3] ?? '') ? coordinatorTag[3] : undefined, + } + : undefined; return { id: event.id, @@ -2429,6 +2470,8 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { labRefs, goalRef, lookingFor, + sponsorRef, + coordinator, createdAt: event.created_at, event, }; diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index af71a19..210aa64 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -14,6 +14,8 @@ import { ClinkPayButton } from '@/components/ClinkPayButton'; import { CollectiveMembers } from '@/components/CollectiveMembers'; import { CrewHero } from '@/components/crew/CrewHero'; import { CrewLeadership } from '@/components/crew/CrewLeadership'; +import { CrewSponsorBanner } from '@/components/crew/CrewSponsorBanner'; +import { CrewCoordinatorCard } from '@/components/crew/CrewCoordinatorCard'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { CrewChat } from '@/components/CrewChat'; import { CrewAgentPanel } from '@/components/CrewAgentPanel'; @@ -295,6 +297,9 @@ export default function CrewDetailPage() { } /> + {/* Sponsor — a local shop backing the crew */} + {crew.sponsorRef && } + {/* Open call — looking for filmers/editors/members (Lab pipeline) */} @@ -380,6 +385,9 @@ export default function CrewDetailPage() { {/* Leadership — team leader (founder) + captains (admins) */} + {/* Private encrypted chat coordinator (Cordn / MLS) */} + + {/* Members (with per-member visibility respected) */} {hasMembers && (