···11+# Copy to .env.local (local dev) and .env.prod (production), then edit.
22+# `podium deploy` copies the right one to .env before running `sm deploy`:
33+# docker compose reads .env for interpolation, and sm rsyncs it to the server.
44+55+# Public URL of the HappyView instance (OAuth callbacks, dashboard links).
66+# Local: the loopback port published by compose, so atproto OAuth loopback
77+# 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+1111+# Session cookie signing secret, 64+ chars: `openssl rand -hex 48`
1212+PODIUM_SESSION_SECRET=
1313+1414+# Where `podium push` sends lexicons and Lua scripts.
1515+PODIUM_HV_URL=http://127.0.0.1:3300/hv
1616+# Prod: PODIUM_HV_URL=https://podium.mycopunk.it/hv
1717+1818+# Admin API key for `podium push`. Locally, `podium bootstrap <handle> --local`
1919+# fills this in; in prod create one in the HappyView dashboard (Settings >
2020+# API Keys — needs lexicons + scripts + settings + backfill permissions).
2121+PODIUM_HV_KEY=
2222+2323+# Public origins HappyView should accept (it 421s unknown Hosts; the
2424+# PUBLIC_URL host is always accepted). Comma-separated.
2525+PODIUM_DOMAINS=https://podium.bast
2626+# Prod: PODIUM_DOMAINS=https://podium.mycopunk.it
2727+2828+# Local only: host directory for service data (SQLite lives here). Must be
2929+# somewhere Docker Desktop can mount. The server sets this itself.
3030+SM_DATA_ROOT=/Users/robin/.supramundane/data
+23
Caddyfile
···11+# Internal Caddy for the podium container — TLS terminates at the front proxy.
22+:80 {
33+ encode zstd gzip
44+55+ # HappyView (dashboard, admin API, XRPC) mounted under /hv
66+ @hv path /hv /hv/*
77+ handle @hv {
88+ reverse_proxy podium-hv:3000
99+ }
1010+1111+ # Standard XRPC path for protocol-native consumers
1212+ handle /xrpc/* {
1313+ rewrite * /hv{uri}
1414+ reverse_proxy podium-hv:3000
1515+ }
1616+1717+ # The Podium frontend
1818+ handle {
1919+ root * /srv
2020+ try_files {path} /index.html
2121+ file_server
2222+ }
2323+}
+10
Dockerfile
···11+FROM node:24-alpine AS build
22+WORKDIR /app
33+COPY frontend/package.json frontend/package-lock.json ./
44+RUN npm ci
55+COPY frontend/ ./
66+RUN npm run build
77+88+FROM caddy:2-alpine
99+COPY Caddyfile /etc/caddy/Caddyfile
1010+COPY --from=build /app/dist /srv
+129-1
README.md
···11-# podium
11+22+# 🪩 Podium
33+44+A search engine for open data published on the Atmosphere: [Matadisco](https://matadisco.org/)
55+dataset records, [Astrosky](https://astrosky.eco/)/[Nebra](https://github.com/the-astrosky-ecosystem/nebra)
66+transient astronomy events, and whatever else gets added over time.
77+88+Live at `https://podium.mycopunk.it`.
99+1010+## How it works
1111+1212+```
1313+podium.mycopunk.it
1414+ │
1515+ caddy-front ──────────── reverse_proxy http://podium:80 (supramundane network)
1616+ │
1717+ ┌──────┴─────────────────────────────────────────────┐
1818+ │ podium (caddy) │
1919+ │ / → the frontend │
2020+ │ /xrpc/* → rewritten to /hv/xrpc/* │
2121+ │ /hv/* → podium-hv │
2222+ ├────────────────────────────────────────────────────┤
2323+ │ podium-hv (HappyView) │
2424+ │ indexes cx.vmx.matadisco + eco.astrosky.transient │
2525+ │ .gcn from Jetstream + backfill, SQLite on /data, │
2626+ │ serves XRPC, Lua query scripts, admin dashboard │
2727+ └────────────────────────────────────────────────────┘
2828+```
2929+3030+- **Backend** is [HappyView](https://happyview.dev/): a lexicon-driven AppView.
3131+ The collections to index, Podium's own query lexicons
3232+ (`it.mycopunk.podium.*`), and the Lua scripts that implement them all live in
3333+ this repo (`backend/`, wired up by `podium.config.json`) and are pushed to
3434+ the instance through its admin API — no pasting into dashboards.
3535+- **Frontend** is Lit 3 + [refrakt](https://github.com/gordonbrander/refrakt).
3636+- **Deployment** is a Supramundane service: `service.json` +
3737+ a hand-authored multi-container `compose.yaml` on the `supramundane` network.
3838+ HappyView's SQLite lives in `${SM_DATA_ROOT}/podium`, so it's backed up
3939+ in prod.
4040+4141+## XRPC endpoints
4242+4343+| Query | What it does |
4444+| --- | --- |
4545+| `it.mycopunk.podium.search?q=&collection=&limit=` | full-text search across collections (per-collection field lists in `backend/scripts/search.lua`) |
4646+| `it.mycopunk.podium.listRecords?collection=&limit=&cursor=` | freshest records, merged or per collection |
4747+| `it.mycopunk.podium.getRecord?uri=` | one full record by AT URI (GCN records keep their raw `data` here; list/search replace it with a `summary`) |
4848+| `it.mycopunk.podium.stats` | per-collection record counts |
4949+5050+All available on both `/xrpc/…` and `/hv/xrpc/…`.
5151+5252+## The `podium` CLI
5353+5454+`npm link` once (or call `node bin/podium.js`). Like `sm`, commands hit prod by
5555+default; add `--local` for the local stack.
5656+5757+```sh
5858+podium deploy [--local] [--no-push] # env file → .env, sm deploy, then push
5959+podium push [--local] [--watch] # sync domains, lexicons + Lua scripts to HappyView
6060+podium backfill [collection] [--local] # (re)start historical backfill
6161+podium bootstrap <handle> --local # first-boot: seed super user + API key (local only)
6262+podium status [--local] # what the instance currently has
6363+```
6464+6565+`push --watch` re-pushes on every save under `backend/` — that's the dev loop
6666+for Lua/lexicon work. Frontend dev is `npm run dev` (Vite on :5173, proxying
6767+XRPC to the local HappyView on 127.0.0.1:3300).
6868+6969+## First boot
7070+7171+**Local** (needs Docker, the `supramundane` network from a running
7272+caddy-front, and `.bast` DNS):
7373+7474+```sh
7575+cp .env.example .env.local # fill in PODIUM_SESSION_SECRET, check SM_DATA_ROOT
7676+podium deploy --local # brings the stack up (no push yet — no key)
7777+podium bootstrap robin.berjon.com --local # seeds super user + API key into .env.local
7878+podium push --local # domains, lexicons, scripts, kicks off backfill
7979+```
8080+8181+The atproto OAuth dance can't run against a `.bast` domain (the PDS must fetch
8282+the client metadata), which is why local uses `bootstrap` (a direct SQLite
8383+seed, done with the container stopped) and `PODIUM_PUBLIC_URL` points at the
8484+loopback port. The dashboard is at `http://127.0.0.1:3300/hv/dashboard/`, and
8585+logging in there with the bootstrapped handle works because the seeded user
8686+*is* that identity.
8787+8888+**Prod**:
8989+9090+```sh
9191+cp .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
9898+```
9999+100100+## Things learned the hard way
101101+102102+- **HappyView 421s any Host it doesn't know.** Its primary domain comes from
103103+ `PUBLIC_URL`; every other public origin (e.g. `https://podium.bast`) must be
104104+ registered, which `podium push` does from `PODIUM_DOMAINS`.
105105+- **Don't touch the SQLite file while the container runs.** It's on a Docker
106106+ bind mount; host-side writes against the live WAL corrupt it (this is why
107107+ `bootstrap` stops the container first). If it happens anyway: stop the
108108+ stack, delete `happyview.db*`, start, re-bootstrap, re-push.
109109+- **Re-POSTing a network lexicon is not idempotent** — it re-fetches and
110110+ re-kicks sync machinery. `podium push` therefore only creates missing ones,
111111+ and only auto-backfills on creation.
112112+- **The records in the wild don't all match the published lexicons.**
113113+ Matadisco has (at least) three shapes: `resource`/`publishedAt`(/`tags`),
114114+ and `metadata`/`created` with a text or image `preview`. Search fields and
115115+ the result cards handle all of them; keep that in mind when adding sources.
116116+- GCN `data` payloads can be tens of KB, so list/search responses strip them
117117+ server-side (`slim()` in the Lua scripts) and keep a human `summary`
118118+ extracted from the event JSON.
119119+120120+## Adding a data source
121121+122122+1. Add its NSID to `networkLexicons` in `podium.config.json` (or a local
123123+ lexicon file under `backend/lexicons/` if it isn't resolvable on-network).
124124+2. Add a source entry (collection + searchable fields) to
125125+ `backend/scripts/search.lua`, and to the `COLLECTIONS` lists in
126126+ `listRecords.lua` / `stats.lua`.
127127+3. Teach `frontend/src/components/podium-result-card.ts` how to render it
128128+ (falls back to a generic card otherwise).
129129+4. `podium push --local`, check, `podium push`.
···11+function handle()
22+ if not params.uri then error("missing required parameter: uri") end
33+ local record = db.get(params.uri)
44+ if not record then error("record not found") end
55+ return { record = record }
66+end
+55
backend/scripts/listRecords.lua
···11+-- Recent records: per collection (with cursor) or merged across all of them.
22+33+local COLLECTIONS = { "cx.vmx.matadisco", "eco.astrosky.transient.gcn" }
44+55+-- GCN records carry the full raw event JSON, which can be huge; replace it
66+-- with a short display summary (getRecord serves the full record).
77+local function slim(collection, r)
88+ if collection == "eco.astrosky.transient.gcn" and r.data ~= nil then
99+ local ok, d = pcall(json.decode, r.data)
1010+ if ok and type(d) == "table" then
1111+ r.summary = d.subject
1212+ or (d.superevent_id and ((d.alert_type and (d.alert_type .. " ") or "") .. d.superevent_id))
1313+ or d.id
1414+ end
1515+ r.data = nil
1616+ r.hasData = true
1717+ end
1818+ return r
1919+end
2020+2121+local function wrap(collection, res, out)
2222+ for _, r in ipairs(res.records or {}) do
2323+ out[#out + 1] = {
2424+ uri = r.uri,
2525+ collection = collection,
2626+ indexedAt = r.indexedAt or r.indexed_at,
2727+ record = slim(collection, r),
2828+ }
2929+ end
3030+end
3131+3232+function handle()
3333+ local limit = math.min(tonumber(params.limit) or 25, 100)
3434+3535+ if params.collection then
3636+ local res = db.query({ collection = params.collection, limit = limit, cursor = params.cursor })
3737+ local out = {}
3838+ wrap(params.collection, res, out)
3939+ return { results = toarray(out), cursor = res.cursor }
4040+ end
4141+4242+ -- Merged view: no cursor, just the freshest records across collections.
4343+ local out = {}
4444+ for _, c in ipairs(COLLECTIONS) do
4545+ local ok, res = pcall(db.query, { collection = c, limit = limit })
4646+ if ok then
4747+ wrap(c, res, out)
4848+ else
4949+ log("query failed on " .. c .. ": " .. tostring(res))
5050+ end
5151+ end
5252+ table.sort(out, function(a, b) return (a.indexedAt or "") > (b.indexedAt or "") end)
5353+ while #out > limit do table.remove(out) end
5454+ return { results = toarray(out) }
5555+end
+66
backend/scripts/search.lua
···11+-- Cross-collection search. Each source lists the record fields worth
22+-- searching (dot notation reaches into nested objects); hits are deduped
33+-- by URI and slimmed for transport.
44+--
55+-- Field choices reflect the records actually on the network, which have
66+-- drifted from the published lexicons: most Matadisco producers use
77+-- `metadata` + `preview.data` rather than `resource` + `tags`.
88+99+local SOURCES = {
1010+ { collection = "cx.vmx.matadisco", fields = { "tags", "resource", "metadata", "preview.data" } },
1111+ { collection = "eco.astrosky.transient.gcn", fields = { "topic", "data" } },
1212+}
1313+1414+-- GCN records carry the full raw event JSON, which can be huge; replace it
1515+-- with a short display summary (getRecord serves the full record).
1616+local function slim(collection, r)
1717+ if collection == "eco.astrosky.transient.gcn" and r.data ~= nil then
1818+ local ok, d = pcall(json.decode, r.data)
1919+ if ok and type(d) == "table" then
2020+ r.summary = d.subject
2121+ or (d.superevent_id and ((d.alert_type and (d.alert_type .. " ") or "") .. d.superevent_id))
2222+ or d.id
2323+ end
2424+ r.data = nil
2525+ r.hasData = true
2626+ end
2727+ return r
2828+end
2929+3030+local function collect(out, seen, collection, res)
3131+ if not res or not res.records then return end
3232+ for _, r in ipairs(res.records) do
3333+ if r.uri and not seen[r.uri] then
3434+ seen[r.uri] = true
3535+ out[#out + 1] = { uri = r.uri, collection = collection, record = slim(collection, r) }
3636+ end
3737+ end
3838+end
3939+4040+function handle()
4141+ local q = params.q
4242+ if not q or #q == 0 then error("missing required parameter: q") end
4343+ local limit = math.min(tonumber(params.limit) or 25, 100)
4444+4545+ local out, seen = {}, {}
4646+ for _, src in ipairs(SOURCES) do
4747+ if not params.collection or params.collection == src.collection then
4848+ for _, field in ipairs(src.fields) do
4949+ local ok, res = pcall(db.search, {
5050+ collection = src.collection,
5151+ field = field,
5252+ query = q,
5353+ limit = limit,
5454+ })
5555+ if ok then
5656+ collect(out, seen, src.collection, res)
5757+ else
5858+ log("search failed on " .. src.collection .. "." .. field .. ": " .. tostring(res))
5959+ end
6060+ end
6161+ end
6262+ end
6363+6464+ while #out > limit do table.remove(out) end
6565+ return { q = q, results = toarray(out) }
6666+end
+12
backend/scripts/stats.lua
···11+local COLLECTIONS = { "cx.vmx.matadisco", "eco.astrosky.transient.gcn" }
22+33+function handle()
44+ local out, total = {}, 0
55+ for _, c in ipairs(COLLECTIONS) do
66+ local ok, n = pcall(db.count, c)
77+ n = ok and n or 0
88+ out[#out + 1] = { collection = c, count = n }
99+ total = total + n
1010+ end
1111+ return { total = total, collections = toarray(out) }
1212+end
+320
bin/podium.js
···11+#!/usr/bin/env node
22+// Podium ops CLI.
33+//
44+// podium deploy [--local] [--no-push] env file → .env, sm deploy, then push
55+// podium push [--local] [--watch] sync lexicons + Lua scripts to HappyView
66+// podium status [--local] list what the instance currently has
77+//
88+// Like sm, commands target production by default; pass --local for the
99+// local stack. Backend code lives in backend/ and podium.config.json is
1010+// the manifest that maps it onto the HappyView admin API.
1111+1212+import { readFile, copyFile } from 'node:fs/promises';
1313+import { watch } from 'node:fs';
1414+import { spawnSync } from 'node:child_process';
1515+import { resolve, dirname } from 'node:path';
1616+import { fileURLToPath } from 'node:url';
1717+import process from 'node:process';
1818+1919+const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
2020+const args = process.argv.slice(2);
2121+const command = args.find((a) => !a.startsWith('-'));
2222+const flags = new Set(args.filter((a) => a.startsWith('-')));
2323+const env = flags.has('--local') ? 'local' : 'prod';
2424+2525+const log = (msg) => console.log(msg);
2626+const fail = (msg) => {
2727+ console.error(`podium: ${msg}`);
2828+ process.exit(1);
2929+};
3030+3131+async function loadEnvFile() {
3232+ const file = resolve(root, `.env.${env}`);
3333+ let text;
3434+ try {
3535+ text = await readFile(file, 'utf8');
3636+ } catch {
3737+ fail(`missing .env.${env} — copy .env.example and fill it in`);
3838+ }
3939+ const vars = {};
4040+ for (const line of text.split('\n')) {
4141+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
4242+ if (m) vars[m[1]] = m[2].replace(/^(["'])(.*)\1$/, '$2');
4343+ }
4444+ return { file, vars };
4545+}
4646+4747+async function loadConfig() {
4848+ const text = await readFile(resolve(root, 'podium.config.json'), 'utf8');
4949+ return JSON.parse(text);
5050+}
5151+5252+function makeApi({ vars }) {
5353+ const base = vars.PODIUM_HV_URL?.replace(/\/$/, '');
5454+ if (!base) fail(`PODIUM_HV_URL is not set in .env.${env}`);
5555+ if (!vars.PODIUM_HV_KEY) {
5656+ fail(
5757+ `PODIUM_HV_KEY is not set in .env.${env}\n` +
5858+ ` Log into the HappyView dashboard (${base}/), create an API key under\n` +
5959+ ` Settings > API Keys with lexicons:create/read + scripts:manage/read,\n` +
6060+ ` and put it in .env.${env}.`
6161+ );
6262+ }
6363+ return async (method, path, body) => {
6464+ const res = await fetch(`${base}${path}`, {
6565+ method,
6666+ headers: {
6767+ authorization: `Bearer ${vars.PODIUM_HV_KEY}`,
6868+ ...(body ? { 'content-type': 'application/json' } : {}),
6969+ },
7070+ body: body ? JSON.stringify(body) : undefined,
7171+ });
7272+ const text = await res.text();
7373+ let data;
7474+ try {
7575+ data = text ? JSON.parse(text) : null;
7676+ } catch {
7777+ data = text;
7878+ }
7979+ return { status: res.status, ok: res.ok, data };
8080+ };
8181+}
8282+8383+async function waitForInstance(api) {
8484+ for (let i = 0; i < 15; i++) {
8585+ try {
8686+ const res = await api('GET', '/admin/lexicons');
8787+ if (res.status === 401 || res.status === 403) {
8888+ fail('the instance rejected PODIUM_HV_KEY (401/403) — check the key and its permissions');
8989+ }
9090+ if (res.ok) return;
9191+ } catch {
9292+ // not up yet
9393+ }
9494+ if (i === 0) log('… waiting for HappyView to answer');
9595+ await new Promise((r) => setTimeout(r, 2000));
9696+ }
9797+ fail('HappyView did not answer on /admin/lexicons — is the stack up?');
9898+}
9999+100100+async function push(envFile, config) {
101101+ const api = makeApi(envFile);
102102+ await waitForInstance(api);
103103+104104+ // HappyView 421s any Host it doesn't know (its primary domain comes from
105105+ // PUBLIC_URL); make sure the public-facing domains are registered.
106106+ const wanted = (envFile.vars.PODIUM_DOMAINS ?? '')
107107+ .split(',')
108108+ .map((d) => d.trim())
109109+ .filter(Boolean);
110110+ if (wanted.length) {
111111+ const existing = await api('GET', '/admin/domains');
112112+ const have = new Set(
113113+ (Array.isArray(existing.data) ? existing.data : []).map((d) => d.url?.replace(/\/$/, ''))
114114+ );
115115+ for (const url of wanted) {
116116+ if (have.has(url.replace(/\/$/, ''))) continue;
117117+ const res = await api('POST', '/admin/domains', { url });
118118+ if (res.ok) log(`✓ domain ${url}`);
119119+ else log(`✗ domain ${url}: ${res.status} ${JSON.stringify(res.data)}`);
120120+ }
121121+ }
122122+123123+ // Re-POSTing an existing network lexicon re-fetches it and re-kicks
124124+ // jetstream/backfill machinery, so only create the missing ones.
125125+ const netExisting = await api('GET', '/admin/network-lexicons');
126126+ const netHave = new Set(
127127+ (Array.isArray(netExisting.data) ? netExisting.data : []).map((l) => l.nsid)
128128+ );
129129+ for (const { nsid, targetCollection } of config.networkLexicons ?? []) {
130130+ if (netHave.has(nsid)) {
131131+ log(`= network lexicon ${nsid}`);
132132+ continue;
133133+ }
134134+ const res = await api('POST', '/admin/network-lexicons', {
135135+ nsid,
136136+ target_collection: targetCollection ?? null,
137137+ });
138138+ if (res.ok) log(`✓ network lexicon ${nsid}`);
139139+ else log(`✗ network lexicon ${nsid}: ${res.status} ${JSON.stringify(res.data)}`);
140140+ // A fresh record collection has no history yet — kick off a backfill.
141141+ if (res.ok) await backfill(api, nsid);
142142+ }
143143+144144+ for (const { file, targetCollection, backfill } of config.lexicons ?? []) {
145145+ const lexicon = JSON.parse(await readFile(resolve(root, file), 'utf8'));
146146+ const res = await api('POST', '/admin/lexicons', {
147147+ lexicon_json: lexicon,
148148+ backfill: backfill ?? false,
149149+ target_collection: targetCollection ?? null,
150150+ });
151151+ if (res.ok) log(`✓ lexicon ${lexicon.id}`);
152152+ else log(`✗ lexicon ${lexicon.id}: ${res.status} ${JSON.stringify(res.data)}`);
153153+ }
154154+155155+ for (const { trigger, file, description } of config.scripts ?? []) {
156156+ const body = await readFile(resolve(root, file), 'utf8');
157157+ const res = await api('POST', '/admin/scripts', {
158158+ id: trigger,
159159+ script_type: 'lua',
160160+ body,
161161+ description,
162162+ });
163163+ if (res.ok) log(`✓ script ${trigger}`);
164164+ else log(`✗ script ${trigger}: ${res.status} ${JSON.stringify(res.data)}`);
165165+ }
166166+}
167167+168168+async function backfill(api, collection) {
169169+ const res = await api('POST', '/admin/backfill', collection ? { collection } : {});
170170+ if (res.ok) log(`⟳ backfill started${collection ? ` for ${collection}` : ''} (job ${res.data?.id ?? '?'})`);
171171+ else log(`✗ backfill${collection ? ` for ${collection}` : ''}: ${res.status} ${JSON.stringify(res.data)}`);
172172+}
173173+174174+async function cmdBackfill() {
175175+ const envFile = await loadEnvFile();
176176+ const api = makeApi(envFile);
177177+ await waitForInstance(api);
178178+ const collection = args.filter((a) => !a.startsWith('-'))[1];
179179+ if (collection) return backfill(api, collection);
180180+ const config = await loadConfig();
181181+ for (const { nsid } of config.networkLexicons ?? []) await backfill(api, nsid);
182182+}
183183+184184+async function cmdPush() {
185185+ const envFile = await loadEnvFile();
186186+ const config = await loadConfig();
187187+ await push(envFile, config);
188188+189189+ if (!flags.has('--watch')) return;
190190+ log(`\nwatching backend/ and podium.config.json (${env}) — ^C to stop`);
191191+ let timer = null;
192192+ const kick = (what) => {
193193+ clearTimeout(timer);
194194+ timer = setTimeout(async () => {
195195+ log(`\n↻ ${what} changed`);
196196+ try {
197197+ await push(await loadEnvFile(), await loadConfig());
198198+ } catch (err) {
199199+ log(`✗ push failed: ${err.message}`);
200200+ }
201201+ }, 250);
202202+ };
203203+ watch(resolve(root, 'backend'), { recursive: true }, (_e, f) => kick(f ?? 'backend'));
204204+ watch(resolve(root, 'podium.config.json'), () => kick('podium.config.json'));
205205+ await new Promise(() => {});
206206+}
207207+208208+async function cmdDeploy() {
209209+ const envFile = await loadEnvFile();
210210+ await copyFile(envFile.file, resolve(root, '.env'));
211211+ log(`.env ← .env.${env}`);
212212+213213+ const smArgs = ['deploy'];
214214+ if (env === 'local') smArgs.push('--local');
215215+ const res = spawnSync('sm', smArgs, { cwd: root, stdio: 'inherit' });
216216+ if (res.error?.code === 'ENOENT') fail('sm not found on PATH (npm link it from caddy-front)');
217217+ if (res.status !== 0) fail(`sm deploy exited with ${res.status}`);
218218+219219+ if (flags.has('--no-push')) return;
220220+ if (!envFile.vars.PODIUM_HV_KEY) {
221221+ log('\nNo PODIUM_HV_KEY yet, skipping backend push. First-boot bootstrap:');
222222+ log(` 1. open ${envFile.vars.PODIUM_HV_URL ?? 'the HappyView dashboard'}/`);
223223+ log(' 2. log in with your atproto handle (first login becomes super user)');
224224+ log(' 3. Settings > API Keys → create a key (lexicons:create/read, scripts:manage/read)');
225225+ log(` 4. put it in .env.${env} as PODIUM_HV_KEY and run: podium push${env === 'local' ? ' --local' : ''}`);
226226+ return;
227227+ }
228228+ await push(envFile, await loadConfig());
229229+}
230230+231231+// 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.
235235+async function cmdBootstrap() {
236236+ if (env !== 'local') fail('bootstrap only works on the local stack (use the dashboard in prod)');
237237+ const { execSync } = await import('node:child_process');
238238+ const crypto = await import('node:crypto');
239239+ const envFile = await loadEnvFile();
240240+ if (envFile.vars.PODIUM_HV_KEY) fail('.env.local already has PODIUM_HV_KEY — nothing to do');
241241+242242+ const handleArg = args.filter((a) => !a.startsWith('-'))[1];
243243+ if (!handleArg) fail('usage: podium bootstrap <atproto-handle-or-did> --local');
244244+ let did = handleArg;
245245+ if (!did.startsWith('did:')) {
246246+ const res = await fetch(
247247+ `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handleArg}`
248248+ );
249249+ if (!res.ok) fail(`could not resolve handle ${handleArg}`);
250250+ did = (await res.json()).did;
251251+ log(`${handleArg} → ${did}`);
252252+ }
253253+254254+ 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+258258+ const rawKey = `hv_${crypto.randomBytes(16).toString('hex')}`;
259259+ 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 = `
266266+INSERT OR IGNORE INTO happyview_users (id, did, is_super, created_at)
267267+ VALUES ('${crypto.randomUUID()}', '${did}', 1, datetime('now'));
268268+INSERT INTO happyview_api_keys (id, user_id, name, key_hash, key_prefix, permissions, created_at)
269269+ SELECT '${crypto.randomUUID()}', id, 'podium bootstrap', '${hash}', '${rawKey.slice(0, 11)}', '[]', datetime('now')
270270+ FROM happyview_users WHERE did = '${did}';`;
271271+ run(`sqlite3 ${JSON.stringify(db)}`, sql);
272272+ } finally {
273273+ run('docker compose start podium-hv');
274274+ }
275275+276276+ const envText = await readFile(envFile.file, 'utf8');
277277+ const updated = envText.replace(/^PODIUM_HV_KEY=.*$/m, `PODIUM_HV_KEY=${rawKey}`);
278278+ const { writeFile } = await import('node:fs/promises');
279279+ 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');
282282+}
283283+284284+async function cmdStatus() {
285285+ const envFile = await loadEnvFile();
286286+ const api = makeApi(envFile);
287287+ const [lex, scripts] = await Promise.all([
288288+ api('GET', '/admin/lexicons'),
289289+ api('GET', '/admin/scripts'),
290290+ ]);
291291+ if (!lex.ok || !scripts.ok) {
292292+ fail(`instance answered ${lex.status}/${scripts.status} — check PODIUM_HV_URL and PODIUM_HV_KEY`);
293293+ }
294294+ const lexicons = Array.isArray(lex.data) ? lex.data : lex.data?.lexicons ?? [];
295295+ const scr = Array.isArray(scripts.data) ? scripts.data : scripts.data?.scripts ?? [];
296296+ log(`lexicons (${lexicons.length}):`);
297297+ for (const l of lexicons) log(` ${l.id ?? l.nsid ?? JSON.stringify(l)}`);
298298+ log(`scripts (${scr.length}):`);
299299+ for (const s of scr) log(` ${s.id ?? JSON.stringify(s)}`);
300300+}
301301+302302+async function main() {
303303+ switch (command) {
304304+ case 'deploy':
305305+ return cmdDeploy();
306306+ case 'push':
307307+ return cmdPush();
308308+ case 'backfill':
309309+ return cmdBackfill();
310310+ case 'bootstrap':
311311+ return cmdBootstrap();
312312+ case 'status':
313313+ return cmdStatus();
314314+ default:
315315+ log('usage: podium <deploy|push|backfill|bootstrap|status> [--local] [--watch] [--no-push]');
316316+ process.exit(command ? 1 : 0);
317317+ }
318318+}
319319+320320+await main();
+38
compose.yaml
···11+services:
22+ # Public-facing container: serves the built frontend and proxies
33+ # /hv/* (and /xrpc/* rewritten) to HappyView. The caddy-front proxy
44+ # reaches it as http://podium:80 on the supramundane network.
55+ podium:
66+ build: .
77+ restart: unless-stopped
88+ container_name: podium
99+ depends_on:
1010+ - podium-hv
1111+ networks:
1212+ - default
1313+1414+ # HappyView AppView: indexes the collections, serves XRPC + dashboard.
1515+ podium-hv:
1616+ image: ghcr.io/gamesgamesgamesgamesgames/happyview:2.10.2
1717+ restart: unless-stopped
1818+ container_name: podium-hv
1919+ environment:
2020+ DATABASE_URL: "sqlite:///data/happyview.db?mode=rwc"
2121+ BASE_PATH: /hv
2222+ PUBLIC_URL: ${PODIUM_PUBLIC_URL:?PODIUM_PUBLIC_URL must be set — see .env.example}
2323+ SESSION_SECRET: ${PODIUM_SESSION_SECRET:?PODIUM_SESSION_SECRET must be set — see .env.example}
2424+ PORT: "3000"
2525+ ports:
2626+ # Loopback-only admin/dashboard access that bypasses the front proxy:
2727+ # locally this is what makes atproto OAuth loopback login work; on the
2828+ # server it stays reachable via an SSH tunnel.
2929+ - "127.0.0.1:3300:3000"
3030+ volumes:
3131+ - ${SM_DATA_ROOT:-/srv/supramundane/data}/podium:/data
3232+ networks:
3333+ - default
3434+3535+networks:
3636+ default:
3737+ name: supramundane
3838+ external: true