feat: production crew-owned Cordn coordinator kit
This commit is contained in:
parent
609b15be08
commit
c2750eae85
9 changed files with 230 additions and 2 deletions
|
|
@ -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
19
deploy/cordn/.env.example
Normal 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
127
deploy/cordn/README.md
Normal 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
14
deploy/cordn/backup.sh
Executable 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."
|
||||||
19
deploy/cordn/docker-compose.yml
Normal file
19
deploy/cordn/docker-compose.yml
Normal 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
17
deploy/cordn/restore.sh
Executable 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")."
|
||||||
|
|
@ -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')}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue