···33# docker compose reads .env for interpolation, and sm rsyncs it to the server.
4455# Public URL of the HappyView instance (OAuth callbacks, dashboard links).
66+# BARE ORIGIN ONLY — HappyView appends BASE_PATH (/hv) itself; including it
77+# here breaks domain matching (421s) and doubles the OAuth metadata path.
68# Local: the loopback port published by compose, so atproto OAuth loopback
79# login works without a publicly reachable URL.
88-PODIUM_PUBLIC_URL=http://127.0.0.1:3300/hv
99-# Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it/hv
1010+PODIUM_PUBLIC_URL=http://127.0.0.1:3300
1111+# Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it
10121113# Session cookie signing secret, 64+ chars: `openssl rand -hex 48`
1214PODIUM_SESSION_SECRET=
+21-11
README.md
···51515252## The `podium` CLI
53535454-`npm link` once (or call `node bin/podium.js`). Like `sm`, commands hit prod by
5454+`npm link` once (or call `node bin/podium.js`). Hits prod by
5555default; add `--local` for the local stack.
56565757```sh
5858podium deploy [--local] [--no-push] # env file → .env, sm deploy, then push
5959podium push [--local] [--watch] # sync domains, lexicons + Lua scripts to HappyView
6060podium backfill [collection] [--local] # (re)start historical backfill
6161-podium bootstrap <handle> --local # first-boot: seed super user + API key (local only)
6161+podium bootstrap <handle> [--local] # first-boot: seed super user + API key (prod goes over SSH)
6262podium status [--local] # what the instance currently has
6363```
6464···8585logging in there with the bootstrapped handle works because the seeded user
8686*is* that identity.
87878888-**Prod**:
8888+**Prod** (needs `$SUPRAMUNDANE` for the SSH steps):
89899090```sh
9191cp .env.example .env.prod # prod URLs, fresh PODIUM_SESSION_SECRET, no key yet
9292-podium deploy # rsyncs + builds on $SUPRAMUNDANE, wires the front
9393-# then in a browser: https://podium.mycopunk.it/hv → log in with your handle
9494-# (first login becomes super user) → Settings > API Keys → create a key with
9595-# lexicons/network-lexicons/scripts/settings/backfill permissions
9696-# put it in .env.prod as PODIUM_HV_KEY, then:
9797-podium push
9292+podium deploy --no-push # rsyncs + builds on $SUPRAMUNDANE, wires the front
9393+podium bootstrap robin.berjon.com # seeds super user + API key on the server, over SSH
9494+podium push # domains, lexicons, scripts, kicks off backfill
9895```
9996100100-## Things learned the hard way
9797+`podium bootstrap` is the no-browser path; dashboard OAuth login at
9898+`https://podium.mycopunk.it/hv` also works from first boot (first login
9999+becomes super user) **provided `PODIUM_PUBLIC_URL` is the bare origin** — see
100100+"Careful" below.
101101102102+## Careful
103103+104104+- **`PUBLIC_URL` must be the bare origin — never include `/hv`.** HappyView
105105+ appends `BASE_PATH` itself (`effective_public_url()`), so a path-ful value
106106+ yields `/hv/hv/` OAuth metadata URLs (login fails with
107107+ `invalid_client_metadata`) *and* a primary domain whose host key contains
108108+ the path, which matches no Host header → everything domain-gated 421s.
102109- **HappyView 421s any Host it doesn't know.** Its primary domain comes from
103110 `PUBLIC_URL`; every other public origin (e.g. `https://podium.bast`) must be
104104- registered, which `podium push` does from `PODIUM_DOMAINS`.
111111+ registered, which `podium push` does from `PODIUM_DOMAINS`. Domain URLs are
112112+ UNIQUE in its DB and the boot-time primary sync panics on conflict, so never
113113+ register an extra domain equal to what the primary will sync to (push skips
114114+ ones that already exist, which covers this).
105115- **Don't touch the SQLite file while the container runs.** It's on a Docker
106116 bind mount; host-side writes against the live WAL corrupt it (this is why
107117 `bootstrap` stops the container first). If it happens anyway: stop the
+50-22
bin/podium.js
···228228 await push(envFile, await loadConfig());
229229}
230230231231-// First-boot helper for environments where the dashboard's atproto OAuth
232232-// can't run: seeds a super user + API key straight into SQLite. Only safe
233233-// with HappyView stopped (host + container sharing a live WAL db over a
234234-// Docker bind mount corrupts it), so this stops and restarts the container.
231231+// First-boot helper: seeds a super user + API key straight into SQLite,
232232+// bypassing dashboard OAuth (which can't run against .bast domains, and on a
233233+// fresh prod instance 421s until `podium push` has registered the public
234234+// domain — a chicken-and-egg since push needs the key). Only safe with
235235+// HappyView stopped (writing to the live WAL db over a Docker bind mount
236236+// corrupts it), so this stops and restarts the container — locally via
237237+// compose, on prod over SSH to $SUPRAMUNDANE.
235238async function cmdBootstrap() {
236236- if (env !== 'local') fail('bootstrap only works on the local stack (use the dashboard in prod)');
237239 const { execSync } = await import('node:child_process');
238240 const crypto = await import('node:crypto');
239241 const envFile = await loadEnvFile();
240240- if (envFile.vars.PODIUM_HV_KEY) fail('.env.local already has PODIUM_HV_KEY — nothing to do');
242242+ if (envFile.vars.PODIUM_HV_KEY) fail(`.env.${env} already has PODIUM_HV_KEY — nothing to do`);
241243242244 const handleArg = args.filter((a) => !a.startsWith('-'))[1];
243243- if (!handleArg) fail('usage: podium bootstrap <atproto-handle-or-did> --local');
245245+ if (!handleArg) fail('usage: podium bootstrap <atproto-handle-or-did> [--local]');
244246 let did = handleArg;
245247 if (!did.startsWith('did:')) {
246248 const res = await fetch(
···251253 log(`${handleArg} → ${did}`);
252254 }
253255254254- const dataRoot = envFile.vars.SM_DATA_ROOT;
255255- if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local');
256256- const db = `${dataRoot}/podium/happyview.db`;
257257-258256 const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`;
259257 const hash = crypto.createHash('sha256').update(rawKey).digest('hex');
260260- const run = (cmd, input) =>
261261- execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] });
262262-263263- run('docker compose stop podium-hv');
264264- try {
265265- const sql = `
258258+ const sql = `
266259INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at)
267260 VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now'));
268261INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at)
269262 SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now')
270263 FROM happyview_users WHERE did = '${did}';`;
271271- run(`sqlite3 ${JSON.stringify(db)}`, sql);
272272- } finally {
273273- run('docker compose start podium-hv');
264264+ const run = (cmd, input) =>
265265+ execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] });
266266+267267+ if (env === 'local') {
268268+ const dataRoot = envFile.vars.SM_DATA_ROOT;
269269+ if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local');
270270+ run('docker compose stop podium-hv');
271271+ try {
272272+ run(`sqlite3 ${JSON.stringify(`${dataRoot}/podium/happyview.db`)}`, sql);
273273+ } finally {
274274+ run('docker compose start podium-hv');
275275+ }
276276+ } else {
277277+ const host = process.env.SUPRAMUNDANE;
278278+ if (!host) fail('SUPRAMUNDANE is not set (remote host for prod bootstrap)');
279279+ const target = process.env.SM_REMOTE_USER ? `${process.env.SM_REMOTE_USER}@${host}` : host;
280280+ const ssh = (cmd, input) =>
281281+ execSync(`ssh ${target} ${JSON.stringify(cmd)}`, {
282282+ input,
283283+ stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'],
284284+ });
285285+ // Ask docker where /data actually lives rather than guessing paths.
286286+ const dataDir = ssh(
287287+ `docker inspect podium-hv --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}'`
288288+ )
289289+ .toString()
290290+ .trim();
291291+ if (!dataDir) fail('could not find the /data mount of podium-hv on the server');
292292+ log(`server data dir: ${dataDir}`);
293293+ ssh('docker stop podium-hv');
294294+ try {
295295+ ssh(
296296+ `docker run --rm -i -v ${dataDir}:/d alpine:3 sh -c 'apk add -q sqlite >/dev/null && sqlite3 /d/happyview.db'`,
297297+ sql
298298+ );
299299+ } finally {
300300+ ssh('docker start podium-hv');
301301+ }
274302 }
275303276304 const envText = await readFile(envFile.file, 'utf8');
277305 const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`);
278306 const { writeFile } = await import('node:fs/promises');
279307 await writeFile(envFile.file, updated.includes(rawKey) ? updated : `${envText}\nPODIUM_HV_KEY=${rawKey}\n`);
280280- log(`✓ super user ${did} seeded, API key written to .env.local`);
281281- log(' run: podium push --local');
308308+ log(`✓ super user ${did} seeded, API key written to .env.${env}`);
309309+ log(` run: podium push${env === 'local' ? ' --local' : ''}`);
282310}
283311284312async function cmdStatus() {