From c2750eae85ff6c813a97c66e3f4289e876f08a1b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sat, 31 Oct 2026 16:25:52 +0700 Subject: [PATCH] feat: production crew-owned Cordn coordinator kit --- AGENTS.md | 9 ++ deploy/cordn/.env.example | 19 +++ deploy/cordn/README.md | 127 +++++++++++++++++++++ deploy/cordn/backup.sh | 14 +++ deploy/cordn/docker-compose.yml | 19 +++ deploy/cordn/restore.sh | 17 +++ src/components/CoordinatorSettingsCard.tsx | 9 ++ src/components/CrewForm.tsx | 11 +- src/lib/coordinator.ts | 7 +- 9 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 deploy/cordn/.env.example create mode 100644 deploy/cordn/README.md create mode 100755 deploy/cordn/backup.sh create mode 100644 deploy/cordn/docker-compose.yml create mode 100755 deploy/cordn/restore.sh diff --git a/AGENTS.md b/AGENTS.md index fb21331..a8cc7e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1418,6 +1418,15 @@ session with **no other memory of this project**. So: it in when known. **Profile discovery (built 2026-09-29):** the carrier is the existing kind-0 **`cordn`** field (app convention, documented in NIP.md — not a new kind). + **Crew-owned coordinator kit (2026-10-31):** `deploy/cordn/` is the + production hand-off for crews: Docker Compose, SQLite volume, `.env` template, + backup/restore scripts and a runbook. Cordn connects **outbound** to ContextVM + relays; no inbound port, domain or TLS terminator is required for the + coordinator itself. The app still does **not** embed a coordinator. Settings → + Coordinator links to the kit; the crew's `coordinator` tag/operator remains + the adoption path. Keep the coordinator key separate from crew/admin keys. + Do not claim multi-coordinator failover; this is one trusted delivery service + with backups, not BFT. `EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex / http URL), **normalises a bare pointer to Cordn's canonical add-link** before saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page` diff --git a/deploy/cordn/.env.example b/deploy/cordn/.env.example new file mode 100644 index 0000000..b9d817a --- /dev/null +++ b/deploy/cordn/.env.example @@ -0,0 +1,19 @@ +# Copy this file to .env, then generate a key: +# openssl rand -hex 32 +# Keep .env mode 600 and back it up with the coordinator data. +CORDN_SERVER_PRIVATE_KEY= + +# ContextVM relays the coordinator connects to. Add a second one if you want +# more reachability; the coordinator does not need an inbound port. +CORDN_RELAY_URLS=wss://relay.contextvm.org,wss://relay2.contextvm.org + +# Publicly announce the coordinator on its relays. +CORDN_ANNOUNCED=true +CORDN_SERVER_NAME=my-crew-coordinator + +# Per-identity abuse protection. Raise for larger crews; keep it bounded. +CORDN_RATE_LIMIT_ENABLED=true +CORDN_RATE_LIMIT_REFILL_PER_MINUTE=250 +CORDN_RATE_LIMIT_BURST=80 +CORDN_MAX_KEY_PACKAGES_PER_IDENTITY=50 +CORDN_MAX_LAST_RESORT_KEY_PACKAGES_PER_IDENTITY=1 diff --git a/deploy/cordn/README.md b/deploy/cordn/README.md new file mode 100644 index 0000000..11dab53 --- /dev/null +++ b/deploy/cordn/README.md @@ -0,0 +1,127 @@ +# Crew-owned Cordn coordinator + +This kit runs a **Cordn MLS delivery-service coordinator** that a crew owns. It +is transport only: the coordinator sees ciphertext and routing metadata (who, +when, which group), never message plaintext. It can withhold or delay messages; +it cannot forge members' messages. + +This is a **server** kit. SkateSpots remains a Cordn *client*; it does not embed a +coordinator in the APK. + +## What you need + +- A small Linux server or always-on home machine with Docker + Compose. +- Outbound HTTPS/WSS access. **No inbound port is required** — the coordinator + connects out to its ContextVM relay(s). +- A domain is **not** required for the coordinator itself. +- A crew willing to trust the operator with availability and metadata. + +## 1. Install + +```bash +cp .env.example .env +chmod 600 .env +openssl rand -hex 32 +``` + +Put the generated value into `CORDN_SERVER_PRIVATE_KEY`. Set +`CORDN_RELAY_URLS` to the relay(s) you want; the default is ContextVM’s public +relay. Set a useful `CORDN_SERVER_NAME`. + +```bash +docker compose up -d +docker compose logs -f cordn +``` + +The server prints its coordinator public identity/add link. It may show an +`nprofile` or a `cordn.net/chat/coordinators?c=…` link. + +If your build does not print an add link, derive the public key from the private +key using the Cordn CLI or `nostr-tools`, then encode an `nprofile` with the same +relay hints. Keep the private key on the server. + +## 2. Adopt it in SkateSpots + +Either: + +- Settings → **Coordinator** → paste the `nprofile`/add-link → **Add**; or +- Crew edit → **Coordinator** → paste it and save; or +- scan the coordinator QR. + +Use exactly the relay hints from the server. A mismatch produces an honest +“Not seen” health state even when the server is actually running. + +The coordinator should show **Online** (or “Stale” if its heartbeat is old). +“Offline” means presence was not found, not necessarily that the service is +broken. + +## 3. What the crew should know + +- The coordinator can **censor or drop messages**. +- It cannot **read** MLS plaintext. +- It can see **who, when and which group**. +- It can disappear; local messages remain on members' devices, but delivery + pauses until the coordinator returns or the crew switches. +- The operator should **not** also become a crew admin automatically. +- A loss of the database is a loss of delivery history; back it up. + +## 4. Backup + +```bash +./backup.sh +``` + +Backups land in `./backups/` as a compressed copy of the coordinator volume. +Store them off the server, encrypted. + +## 5. Restore + +Stop the coordinator, restore the archive into the `cordn-data` volume, start it +again: + +```bash +docker compose stop cordn +./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz +docker compose start cordn +``` + +## 6. Upgrade + +```bash +docker compose pull +docker compose up -d +``` + +The image is pinned only by the `latest` tag. If you need reproducible upgrades, +pin a digest and record it in your crew runbook. + +## 7. Move to a new coordinator + +MLS delivery state is tied to the coordinator’s database and identity. Before +switching: + +1. back up the current coordinator; +2. export/retain the crew’s local chat state on members’ devices; +3. start the new coordinator; +4. paste its `nprofile` into the crew record; +5. have members reopen the private chat and re-establish delivery; +6. update the crew operator/notice. + +Do not claim the switch is seamless unless the old and new coordinator state +have actually been migrated. + +## Security notes + +- Never commit `.env`; it contains the coordinator signing key. +- The coordinator key is **not** a crew admin key and must not be reused as one. +- Run it behind a trusted host/network; outbound relay traffic is metadata. +- Keep Docker and the image current. +- Do not expose an OpenCode or coordinator admin port publicly. +- This is **not** a BFT or multi-coordinator system. One coordinator is a + delivery service under the crew's social trust. + +## Files + +- `docker-compose.yml` — persistent SQLite deployment. +- `.env.example` — required environment. +- `backup.sh` / `restore.sh` — volume snapshots. diff --git a/deploy/cordn/backup.sh b/deploy/cordn/backup.sh new file mode 100755 index 0000000..f98428b --- /dev/null +++ b/deploy/cordn/backup.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Snapshot the Cordn coordinator data volume. Run from this directory. +set -eu +cd "$(dirname "$0")" +mkdir -p backups +stamp="$(date +%Y%m%d-%H%M%S)" +out="cordn-$stamp.tgz" +docker run --rm \ + -v cordn_cordn-data:/data:ro \ + -v "$PWD/backups":/backup \ + alpine:3.20 \ + sh -c "tar czf /backup/$out -C /data ." +echo "Backup: $PWD/backups/$out" +echo "Store it off this server and keep it encrypted." diff --git a/deploy/cordn/docker-compose.yml b/deploy/cordn/docker-compose.yml new file mode 100644 index 0000000..063d0c5 --- /dev/null +++ b/deploy/cordn/docker-compose.yml @@ -0,0 +1,19 @@ +name: cordn + +services: + cordn: + image: ghcr.io/cordn-msg/cordn:latest + container_name: cordn + restart: unless-stopped + env_file: + - .env + environment: + CORDN_STORAGE_BACKEND: sqlite + CORDN_SQLITE_PATH: /data/cordn.sqlite + volumes: + - cordn-data:/data + # No inbound port is required: the coordinator connects *out* to the + # ContextVM relay(s) configured in CORDN_RELAY_URLS. + +volumes: + cordn-data: diff --git a/deploy/cordn/restore.sh b/deploy/cordn/restore.sh new file mode 100755 index 0000000..a5a9c9a --- /dev/null +++ b/deploy/cordn/restore.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Restore a Cordn coordinator backup into the cordn-data volume. +# Usage: ./restore.sh backups/cordn-YYYYMMDD-HHMMSS.tgz +set -eu +cd "$(dirname "$0")" +[ "$#" -eq 1 ] || { echo "usage: $0 backups/cordn-YYYYMMDD-HHMMSS.tgz" >&2; exit 1; } +archive="$1" +[ -f "$archive" ] || { echo "no such backup: $archive" >&2; exit 1; } +printf 'This replaces the current coordinator volume. Type "restore" to continue: ' +read -r answer +[ "$answer" = restore ] || { echo "aborted" >&2; exit 1; } +docker run --rm \ + -v cordn_cordn-data:/data \ + -v "$(cd "$(dirname "$archive")" && pwd)":/backup:ro \ + alpine:3.20 \ + sh -c "rm -rf /data/* /data/.[!.]* /data/..?* 2>/dev/null || true; tar xzf /backup/$(basename "$archive") -C /data" +echo "Restored $(basename "$archive")." diff --git a/src/components/CoordinatorSettingsCard.tsx b/src/components/CoordinatorSettingsCard.tsx index c5ff61b..4600d83 100644 --- a/src/components/CoordinatorSettingsCard.tsx +++ b/src/components/CoordinatorSettingsCard.tsx @@ -21,6 +21,7 @@ import { toast } from '@/hooks/useToast'; import { CORDN_APP_URL, CORDN_DOCKER_COMMAND, + CORDN_DEPLOY_KIT_URL, CORDN_RELAY_URL, coordinatorAddUrl, parseCoordinatorInput, @@ -314,6 +315,14 @@ export function CoordinatorSettingsCard() { +
+ + + +

{t('socialUi.coordinator.androidSleep')} diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 3087302..13b520a 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -29,7 +29,7 @@ import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator'; import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; -import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR } from '@/lib/coordinator'; +import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator'; import { useRelays } from '@/lib/relays'; 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, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react'; @@ -1184,6 +1184,15 @@ export function CrewForm({ existing }: CrewFormProps) { > {t('socialUi.coordinator.cordnCoordinator')} + {' · '} + + {t('common.learnMore')} + .

diff --git a/src/lib/coordinator.ts b/src/lib/coordinator.ts index c2477e6..8bb0358 100644 --- a/src/lib/coordinator.ts +++ b/src/lib/coordinator.ts @@ -24,7 +24,12 @@ export const CORDN_ADD_BASE = 'https://cordn.net/chat/coordinators'; export const CORDN_HOME = 'https://cordn.net'; /** The self-host one-liner advertised on cordn.net. */ -export const CORDN_DOCKER_COMMAND = 'docker run --rm ghcr.io/cordn-msg/cordn:latest'; +export const CORDN_DOCKER_COMMAND = + 'docker run -d --name cordn --restart unless-stopped -v cordn-data:/data -e CORDN_STORAGE_BACKEND=sqlite -e CORDN_SQLITE_PATH=/data/cordn.sqlite -e CORDN_RELAY_URLS=wss://relay.contextvm.org -e CORDN_ANNOUNCED=true -e CORDN_SERVER_PRIVATE_KEY=YOUR_64_HEX_KEY ghcr.io/cordn-msg/cordn:latest'; + +/** Production deployment kit (persistent SQLite volume + backup/restore). */ +export const CORDN_DEPLOY_KIT_URL = + 'https://git.skate-spots.org/skatespots/SkateSpots/tree/main/deploy/cordn'; /** Where a user gets a coordinator they can run on a phone. */ export const CORDN_APP_URL = 'https://cordn.net';