diff --git a/.env.example b/.env.example index 2908141..d50e291 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,12 @@ # docker compose reads .env for interpolation, and sm rsyncs it to the server. # Public URL of the HappyView instance (OAuth callbacks, dashboard links). +# BARE ORIGIN ONLY — HappyView appends BASE_PATH (/hv) itself; including it +# here breaks domain matching (421s) and doubles the OAuth metadata path. # Local: the loopback port published by compose, so atproto OAuth loopback # login works without a publicly reachable URL. -PODIUM_PUBLIC_URL=http://127.0.0.1:3300/hv -# Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it/hv +PODIUM_PUBLIC_URL=http://127.0.0.1:3300 +# Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it # Session cookie signing secret, 64+ chars: `openssl rand -hex 48` PODIUM_SESSION_SECRET= diff --git a/README.md b/README.md index 2c95ef6..95a2cf3 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ All available on both `/xrpc/…` and `/hv/xrpc/…`. ## The `podium` CLI -`npm link` once (or call `node bin/podium.js`). Like `sm`, commands hit prod by +`npm link` once (or call `node bin/podium.js`). Hits prod by default; add `--local` for the local stack. ```sh podium deploy [--local] [--no-push] # env file → .env, sm deploy, then push podium push [--local] [--watch] # sync domains, lexicons + Lua scripts to HappyView podium backfill [collection] [--local] # (re)start historical backfill -podium bootstrap --local # first-boot: seed super user + API key (local only) +podium bootstrap [--local] # first-boot: seed super user + API key (prod goes over SSH) podium status [--local] # what the instance currently has ``` @@ -85,23 +85,33 @@ loopback port. The dashboard is at `http://127.0.0.1:3300/hv/dashboard/`, and logging in there with the bootstrapped handle works because the seeded user *is* that identity. -**Prod**: +**Prod** (needs `$SUPRAMUNDANE` for the SSH steps): ```sh cp .env.example .env.prod # prod URLs, fresh PODIUM_SESSION_SECRET, no key yet -podium deploy # rsyncs + builds on $SUPRAMUNDANE, wires the front -# then in a browser: https://podium.mycopunk.it/hv → log in with your handle -# (first login becomes super user) → Settings > API Keys → create a key with -# lexicons/network-lexicons/scripts/settings/backfill permissions -# put it in .env.prod as PODIUM_HV_KEY, then: -podium push +podium deploy --no-push # rsyncs + builds on $SUPRAMUNDANE, wires the front +podium bootstrap robin.berjon.com # seeds super user + API key on the server, over SSH +podium push # domains, lexicons, scripts, kicks off backfill ``` -## Things learned the hard way +`podium bootstrap` is the no-browser path; dashboard OAuth login at +`https://podium.mycopunk.it/hv` also works from first boot (first login +becomes super user) **provided `PODIUM_PUBLIC_URL` is the bare origin** — see +"Careful" below. +## Careful + +- **`PUBLIC_URL` must be the bare origin — never include `/hv`.** HappyView + appends `BASE_PATH` itself (`effective_public_url()`), so a path-ful value + yields `/hv/hv/` OAuth metadata URLs (login fails with + `invalid_client_metadata`) *and* a primary domain whose host key contains + the path, which matches no Host header → everything domain-gated 421s. - **HappyView 421s any Host it doesn't know.** Its primary domain comes from `PUBLIC_URL`; every other public origin (e.g. `https://podium.bast`) must be - registered, which `podium push` does from `PODIUM_DOMAINS`. + registered, which `podium push` does from `PODIUM_DOMAINS`. Domain URLs are + UNIQUE in its DB and the boot-time primary sync panics on conflict, so never + register an extra domain equal to what the primary will sync to (push skips + ones that already exist, which covers this). - **Don't touch the SQLite file while the container runs.** It's on a Docker bind mount; host-side writes against the live WAL corrupt it (this is why `bootstrap` stops the container first). If it happens anyway: stop the diff --git a/bin/podium.js b/bin/podium.js index ff5b523..32abf0c 100755 --- a/bin/podium.js +++ b/bin/podium.js @@ -228,19 +228,21 @@ async function cmdDeploy() { await push(envFile, await loadConfig()); } -// First-boot helper for environments where the dashboard's atproto OAuth -// can't run: seeds a super user + API key straight into SQLite. Only safe -// with HappyView stopped (host + container sharing a live WAL db over a -// Docker bind mount corrupts it), so this stops and restarts the container. +// First-boot helper: seeds a super user + API key straight into SQLite, +// bypassing dashboard OAuth (which can't run against .bast domains, and on a +// fresh prod instance 421s until `podium push` has registered the public +// domain — a chicken-and-egg since push needs the key). Only safe with +// HappyView stopped (writing to the live WAL db over a Docker bind mount +// corrupts it), so this stops and restarts the container — locally via +// compose, on prod over SSH to $SUPRAMUNDANE. async function cmdBootstrap() { - if (env !== 'local') fail('bootstrap only works on the local stack (use the dashboard in prod)'); const { execSync } = await import('node:child_process'); const crypto = await import('node:crypto'); const envFile = await loadEnvFile(); - if (envFile.vars.PODIUM_HV_KEY) fail('.env.local already has PODIUM_HV_KEY — nothing to do'); + if (envFile.vars.PODIUM_HV_KEY) fail(`.env.${env} already has PODIUM_HV_KEY — nothing to do`); const handleArg = args.filter((a) => !a.startsWith('-'))[1]; - if (!handleArg) fail('usage: podium bootstrap --local'); + if (!handleArg) fail('usage: podium bootstrap [--local]'); let did = handleArg; if (!did.startsWith('did:')) { const res = await fetch( @@ -251,34 +253,60 @@ async function cmdBootstrap() { log(`${handleArg} → ${did}`); } - const dataRoot = envFile.vars.SM_DATA_ROOT; - if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); - const db = `${dataRoot}/podium/happyview.db`; - const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`; const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); - const run = (cmd, input) => - execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); - - run('docker compose stop podium-hv'); - try { - const sql = ` + const sql = ` INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at) VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now')); INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now') FROM happyview_users WHERE did = '${did}';`; - run(`sqlite3 ${JSON.stringify(db)}`, sql); - } finally { - run('docker compose start podium-hv'); + const run = (cmd, input) => + execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); + + if (env === 'local') { + const dataRoot = envFile.vars.SM_DATA_ROOT; + if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); + run('docker compose stop podium-hv'); + try { + run(`sqlite3 ${JSON.stringify(`${dataRoot}/podium/happyview.db`)}`, sql); + } finally { + run('docker compose start podium-hv'); + } + } else { + const host = process.env.SUPRAMUNDANE; + if (!host) fail('SUPRAMUNDANE is not set (remote host for prod bootstrap)'); + const target = process.env.SM_REMOTE_USER ? `${process.env.SM_REMOTE_USER}@${host}` : host; + const ssh = (cmd, input) => + execSync(`ssh ${target} ${JSON.stringify(cmd)}`, { + input, + stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'], + }); + // Ask docker where /data actually lives rather than guessing paths. + const dataDir = ssh( + `docker inspect podium-hv --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}'` + ) + .toString() + .trim(); + if (!dataDir) fail('could not find the /data mount of podium-hv on the server'); + log(`server data dir: ${dataDir}`); + ssh('docker stop podium-hv'); + try { + ssh( + `docker run --rm -i -v ${dataDir}:/d alpine:3 sh -c 'apk add -q sqlite >/dev/null && sqlite3 /d/happyview.db'`, + sql + ); + } finally { + ssh('docker start podium-hv'); + } } const envText = await readFile(envFile.file, 'utf8'); const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`); const { writeFile } = await import('node:fs/promises'); await writeFile(envFile.file, updated.includes(rawKey) ? updated : `${envText}\nPODIUM_HV_KEY=${rawKey}\n`); - log(`✓ super user ${did} seeded, API key written to .env.local`); - log(' run: podium push --local'); + log(`✓ super user ${did} seeded, API key written to .env.${env}`); + log(` run: podium push${env === 'local' ? ' --local' : ''}`); } async function cmdStatus() { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c4514f1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "podium", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "podium", + "version": "0.1.0", + "license": "MIT", + "bin": { + "podium": "bin/podium.js" + }, + "engines": { + "node": ">=20" + } + } + } +}