This repository has no description
2

Configure Feed

Select the types of activity you want to include in your feed.

works

Robin Berjon (Jul 3, 2026, 10:16 AM +0200) 03a26a4a f0eb5de9

+94 -35
+4 -2
.env.example
··· 3 3 # docker compose reads .env for interpolation, and sm rsyncs it to the server. 4 4 5 5 # Public URL of the HappyView instance (OAuth callbacks, dashboard links). 6 + # BARE ORIGIN ONLY — HappyView appends BASE_PATH (/hv) itself; including it 7 + # here breaks domain matching (421s) and doubles the OAuth metadata path. 6 8 # Local: the loopback port published by compose, so atproto OAuth loopback 7 9 # login works without a publicly reachable URL. 8 - PODIUM_PUBLIC_URL=http://127.0.0.1:3300/hv 9 - # Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it/hv 10 + PODIUM_PUBLIC_URL=http://127.0.0.1:3300 11 + # Prod: PODIUM_PUBLIC_URL=https://podium.mycopunk.it 10 12 11 13 # Session cookie signing secret, 64+ chars: `openssl rand -hex 48` 12 14 PODIUM_SESSION_SECRET=
+21 -11
README.md
··· 51 51 52 52 ## The `podium` CLI 53 53 54 - `npm link` once (or call `node bin/podium.js`). Like `sm`, commands hit prod by 54 + `npm link` once (or call `node bin/podium.js`). Hits prod by 55 55 default; add `--local` for the local stack. 56 56 57 57 ```sh 58 58 podium deploy [--local] [--no-push] # env file → .env, sm deploy, then push 59 59 podium push [--local] [--watch] # sync domains, lexicons + Lua scripts to HappyView 60 60 podium backfill [collection] [--local] # (re)start historical backfill 61 - podium bootstrap <handle> --local # first-boot: seed super user + API key (local only) 61 + podium bootstrap <handle> [--local] # first-boot: seed super user + API key (prod goes over SSH) 62 62 podium status [--local] # what the instance currently has 63 63 ``` 64 64 ··· 85 85 logging in there with the bootstrapped handle works because the seeded user 86 86 *is* that identity. 87 87 88 - **Prod**: 88 + **Prod** (needs `$SUPRAMUNDANE` for the SSH steps): 89 89 90 90 ```sh 91 91 cp .env.example .env.prod # prod URLs, fresh PODIUM_SESSION_SECRET, no key yet 92 - podium deploy # rsyncs + builds on $SUPRAMUNDANE, wires the front 93 - # then in a browser: https://podium.mycopunk.it/hv → log in with your handle 94 - # (first login becomes super user) → Settings > API Keys → create a key with 95 - # lexicons/network-lexicons/scripts/settings/backfill permissions 96 - # put it in .env.prod as PODIUM_HV_KEY, then: 97 - podium push 92 + podium deploy --no-push # rsyncs + builds on $SUPRAMUNDANE, wires the front 93 + podium bootstrap robin.berjon.com # seeds super user + API key on the server, over SSH 94 + podium push # domains, lexicons, scripts, kicks off backfill 98 95 ``` 99 96 100 - ## Things learned the hard way 97 + `podium bootstrap` is the no-browser path; dashboard OAuth login at 98 + `https://podium.mycopunk.it/hv` also works from first boot (first login 99 + becomes super user) **provided `PODIUM_PUBLIC_URL` is the bare origin** — see 100 + "Careful" below. 101 101 102 + ## Careful 103 + 104 + - **`PUBLIC_URL` must be the bare origin — never include `/hv`.** HappyView 105 + appends `BASE_PATH` itself (`effective_public_url()`), so a path-ful value 106 + yields `/hv/hv/` OAuth metadata URLs (login fails with 107 + `invalid_client_metadata`) *and* a primary domain whose host key contains 108 + the path, which matches no Host header → everything domain-gated 421s. 102 109 - **HappyView 421s any Host it doesn't know.** Its primary domain comes from 103 110 `PUBLIC_URL`; every other public origin (e.g. `https://podium.bast`) must be 104 - registered, which `podium push` does from `PODIUM_DOMAINS`. 111 + registered, which `podium push` does from `PODIUM_DOMAINS`. Domain URLs are 112 + UNIQUE in its DB and the boot-time primary sync panics on conflict, so never 113 + register an extra domain equal to what the primary will sync to (push skips 114 + ones that already exist, which covers this). 105 115 - **Don't touch the SQLite file while the container runs.** It's on a Docker 106 116 bind mount; host-side writes against the live WAL corrupt it (this is why 107 117 `bootstrap` stops the container first). If it happens anyway: stop the
+50 -22
bin/podium.js
··· 228 228 await push(envFile, await loadConfig()); 229 229 } 230 230 231 - // First-boot helper for environments where the dashboard's atproto OAuth 232 - // can't run: seeds a super user + API key straight into SQLite. Only safe 233 - // with HappyView stopped (host + container sharing a live WAL db over a 234 - // Docker bind mount corrupts it), so this stops and restarts the container. 231 + // First-boot helper: seeds a super user + API key straight into SQLite, 232 + // bypassing dashboard OAuth (which can't run against .bast domains, and on a 233 + // fresh prod instance 421s until `podium push` has registered the public 234 + // domain — a chicken-and-egg since push needs the key). Only safe with 235 + // HappyView stopped (writing to the live WAL db over a Docker bind mount 236 + // corrupts it), so this stops and restarts the container — locally via 237 + // compose, on prod over SSH to $SUPRAMUNDANE. 235 238 async function cmdBootstrap() { 236 - if (env !== 'local') fail('bootstrap only works on the local stack (use the dashboard in prod)'); 237 239 const { execSync } = await import('node:child_process'); 238 240 const crypto = await import('node:crypto'); 239 241 const envFile = await loadEnvFile(); 240 - if (envFile.vars.PODIUM_HV_KEY) fail('.env.local already has PODIUM_HV_KEY — nothing to do'); 242 + if (envFile.vars.PODIUM_HV_KEY) fail(`.env.${env} already has PODIUM_HV_KEY — nothing to do`); 241 243 242 244 const handleArg = args.filter((a) => !a.startsWith('-'))[1]; 243 - if (!handleArg) fail('usage: podium bootstrap <atproto-handle-or-did> --local'); 245 + if (!handleArg) fail('usage: podium bootstrap <atproto-handle-or-did> [--local]'); 244 246 let did = handleArg; 245 247 if (!did.startsWith('did:')) { 246 248 const res = await fetch( ··· 251 253 log(`${handleArg} → ${did}`); 252 254 } 253 255 254 - const dataRoot = envFile.vars.SM_DATA_ROOT; 255 - if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); 256 - const db = `${dataRoot}/podium/happyview.db`; 257 - 258 256 const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`; 259 257 const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); 260 - const run = (cmd, input) => 261 - execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); 262 - 263 - run('docker compose stop podium-hv'); 264 - try { 265 - const sql = ` 258 + const sql = ` 266 259 INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at) 267 260 VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now')); 268 261 INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at) 269 262 SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now') 270 263 FROM happyview_users WHERE did = '${did}';`; 271 - run(`sqlite3 ${JSON.stringify(db)}`, sql); 272 - } finally { 273 - run('docker compose start podium-hv'); 264 + const run = (cmd, input) => 265 + execSync(cmd, { cwd: root, input, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'] }); 266 + 267 + if (env === 'local') { 268 + const dataRoot = envFile.vars.SM_DATA_ROOT; 269 + if (!dataRoot) fail('SM_DATA_ROOT is not set in .env.local'); 270 + run('docker compose stop podium-hv'); 271 + try { 272 + run(`sqlite3 ${JSON.stringify(`${dataRoot}/podium/happyview.db`)}`, sql); 273 + } finally { 274 + run('docker compose start podium-hv'); 275 + } 276 + } else { 277 + const host = process.env.SUPRAMUNDANE; 278 + if (!host) fail('SUPRAMUNDANE is not set (remote host for prod bootstrap)'); 279 + const target = process.env.SM_REMOTE_USER ? `${process.env.SM_REMOTE_USER}@${host}` : host; 280 + const ssh = (cmd, input) => 281 + execSync(`ssh ${target} ${JSON.stringify(cmd)}`, { 282 + input, 283 + stdio: [input ? 'pipe' : 'ignore', 'pipe', 'inherit'], 284 + }); 285 + // Ask docker where /data actually lives rather than guessing paths. 286 + const dataDir = ssh( 287 + `docker inspect podium-hv --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}'` 288 + ) 289 + .toString() 290 + .trim(); 291 + if (!dataDir) fail('could not find the /data mount of podium-hv on the server'); 292 + log(`server data dir: ${dataDir}`); 293 + ssh('docker stop podium-hv'); 294 + try { 295 + ssh( 296 + `docker run --rm -i -v ${dataDir}:/d alpine:3 sh -c 'apk add -q sqlite >/dev/null && sqlite3 /d/happyview.db'`, 297 + sql 298 + ); 299 + } finally { 300 + ssh('docker start podium-hv'); 301 + } 274 302 } 275 303 276 304 const envText = await readFile(envFile.file, 'utf8'); 277 305 const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`); 278 306 const { writeFile } = await import('node:fs/promises'); 279 307 await writeFile(envFile.file, updated.includes(rawKey) ? updated : `${envText}\nPODIUM_HV_KEY=${rawKey}\n`); 280 - log(`✓ super user ${did} seeded, API key written to .env.local`); 281 - log(' run: podium push --local'); 308 + log(`✓ super user ${did} seeded, API key written to .env.${env}`); 309 + log(` run: podium push${env === 'local' ? ' --local' : ''}`); 282 310 } 283 311 284 312 async function cmdStatus() {
+19
package-lock.json
··· 1 + { 2 + "name": "podium", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "podium", 9 + "version": "0.1.0", 10 + "license": "MIT", 11 + "bin": { 12 + "podium": "bin/podium.js" 13 + }, 14 + "engines": { 15 + "node": ">=20" 16 + } 17 + } 18 + } 19 + }