feat: production crew-owned Cordn coordinator kit

This commit is contained in:
SkateSpots 2026-10-31 16:25:52 +07:00
parent 609b15be08
commit c2750eae85
9 changed files with 230 additions and 2 deletions

View file

@ -1418,6 +1418,15 @@ session with **no other memory of this project**. So:
it in when known. it in when known.
**Profile discovery (built 2026-09-29):** the carrier is the existing kind-0 **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). **`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 / `EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
http URL), **normalises a bare pointer to Cordn's canonical add-link** before http URL), **normalises a bare pointer to Cordn's canonical add-link** before
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page` saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`

19
deploy/cordn/.env.example Normal file
View file

@ -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

127
deploy/cordn/README.md Normal file
View file

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

14
deploy/cordn/backup.sh Executable file
View file

@ -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."

View file

@ -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:

17
deploy/cordn/restore.sh Executable file
View file

@ -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")."

View file

@ -21,6 +21,7 @@ import { toast } from '@/hooks/useToast';
import { import {
CORDN_APP_URL, CORDN_APP_URL,
CORDN_DOCKER_COMMAND, CORDN_DOCKER_COMMAND,
CORDN_DEPLOY_KIT_URL,
CORDN_RELAY_URL, CORDN_RELAY_URL,
coordinatorAddUrl, coordinatorAddUrl,
parseCoordinatorInput, parseCoordinatorInput,
@ -314,6 +315,14 @@ export function CoordinatorSettingsCard() {
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<a href={CORDN_DEPLOY_KIT_URL} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
{t('common.learnMore')}
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</Button>
</a>
</div>
<p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed"> <p className="flex items-start gap-2 text-[11px] text-amber-400/90 leading-relaxed">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" /> <AlertTriangle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
{t('socialUi.coordinator.androidSleep')} {t('socialUi.coordinator.androidSleep')}

View file

@ -29,7 +29,7 @@ import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator'; import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; 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 { useRelays } from '@/lib/relays';
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, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react'; 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('socialUi.coordinator.cordnCoordinator')}
</a> </a>
{' · '}
<a
href={CORDN_DEPLOY_KIT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 underline underline-offset-2 hover:text-teal-300"
>
{t('common.learnMore')}
</a>
. .
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">

View file

@ -24,7 +24,12 @@ export const CORDN_ADD_BASE = 'https://cordn.net/chat/coordinators';
export const CORDN_HOME = 'https://cordn.net'; export const CORDN_HOME = 'https://cordn.net';
/** The self-host one-liner advertised on 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. */ /** Where a user gets a coordinator they can run on a phone. */
export const CORDN_APP_URL = 'https://cordn.net'; export const CORDN_APP_URL = 'https://cordn.net';