doxa-maps/deployment/cloud/admin.mjs
Doxa Maps ffebd14377 Cloud container: PocketBase's dashboard renders through /pb, its redirects keep /pb, embed-test reads the data API
Three bugs rega found on a laptop run of the container (2026-09-29):
- /pb/_/ never rendered. admin.mjs proxied with fetch(), which returns the body decompressed,
  but forwarded PocketBase's Content-Encoding: gzip and Content-Length, so the browser tried to
  gunzip plain HTML. The proxy now drops both headers (for /pb and /api-prod).
- PocketBase's own redirects left the mount: /pb/_ went to /_/, and /pb/_/index.html to
  http://127.0.0.1:8090/_/ (the internal address). Root-relative and upstream-absolute
  locations are put back under the mount: /pb/_/.
- The research host's /embed-test set no MAP_APP_API_URL, so the maps asked that static host's
  own /api (404s; dashboard-plus-grid drew 489 nodes, not 1,399). The page now sets it from the
  manifest's runtime.apiBaseUrl before any bundle loads, as every generated page does.

Checked both ways against an upstream that answers like PocketBase (gzipped HTML, redirects
to /_/). The old proxy sends a gzip label on a plain body and /_/ locations; the new one
sends a decodable body and /pb/_/.
2026-09-29 05:26:26 +00:00

169 lines
11 KiB
JavaScript

// admin.mjs — the container's internal router (127.0.0.1:5175), behind oauth2-proxy.
// /cdn/* the last good build (/data/cdn/current), PUBLIC (oauth2-proxy skips auth here)
// /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard
// /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS. With PB_OIDC_CLIENT_SECRET,
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
// /admin/build POST: build PRODUCTION_BRANCH now (OIDC_ADMIN_GROUPS only); /admin/status GET.
// The public build also follows PRODUCTION_BRANCH by itself (CDN_AUTOBUILD_MINUTES, default 2).
// SUBDOMAIN_APPS public subdomain sites for published plugins, on a separate port (see below)
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
// Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only.
import { spawn } from 'node:child_process'
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { basename, join, normalize } from 'node:path'
import { serveTreeFile } from './cdn-cache.mjs'
const env = process.env
const ADMIN = (env.OIDC_ADMIN_GROUPS || '').split(',').map((g) => g.trim()).filter(Boolean)
const CDN = '/data/cdn/current'
let last = { state: 'idle' }
const groupsOf = (req) => (req.headers.get('x-forwarded-groups') || '').split(',').map((g) => g.trim())
const isAdmin = (req) => ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g))
const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })
// fetch() hands back the body DECOMPRESSED, so the upstream Content-Encoding and Content-Length no longer describe
// what is sent on: forwarding them made browsers try to gunzip plain HTML, and PocketBase's dashboard never rendered
// (rega, 2026-09-29). And PocketBase redirects to its own root (/_ -> /_/), which is put back under the mount (/pb/_/).
async function proxy(req, url, prefix, port) {
const target = new URL(url.pathname.slice(prefix.length - 1) + url.search, `http://127.0.0.1:${port}`)
const r = await fetch(target, { method: req.method, headers: req.headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body, redirect: 'manual' })
const headers = new Headers(r.headers)
headers.delete('content-encoding')
headers.delete('content-length')
const loc = headers.get('location')
if (loc) {
const mount = prefix.slice(0, -1) // '/pb/' -> '/pb'
const upstream = `http://127.0.0.1:${port}`
const path = loc.startsWith(upstream) ? loc.slice(upstream.length) || '/' : loc
if (path.startsWith('/') && !path.startsWith('//') && !path.startsWith(`${mount}/`)) headers.set('location', mount + path)
}
return new Response(r.body, { status: r.status, headers })
}
// One public build at a time (build-cdn.sh also holds a lock), started by an admin or by the watcher below.
function startBuild(by) {
last = { state: 'building', by, branch: PROD, started: new Date().toISOString() }
const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', (d) => { log = (log + d).slice(-4000) })
child.stderr.on('data', (d) => { log = (log + d).slice(-4000) })
child.on('exit', (code) => {
last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', by, branch: PROD, code, finished: new Date().toISOString(), tail: log.slice(-1500),
commit: publishedCommit() }
})
}
const publishedCommit = () => (existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null)
// The production PocketBase's schema: the snapshot its migrations came from (pb-prod-migrations.sh), or 'empty'.
const prodSchema = () => { try { return basename(realpathSync('/data/pb-prod-migrations/current')) } catch { return null } }
// AUTO-BUILD: the public build follows PRODUCTION_BRANCH by itself. main is the shared sandbox; production
// moves only when someone promotes (git push origin main:production), so rebuilding on every move is
// the point. Debounced: build once the branch has held the same commit for CDN_AUTOBUILD_MINUTES
// (default 2; 0 = off, admin button only). A commit whose build failed is not retried automatically.
const PROD = env.PRODUCTION_BRANCH || 'production'
const AUTO_MIN = env.CDN_AUTOBUILD_MINUTES === undefined || env.CDN_AUTOBUILD_MINUTES === '' ? 2 : Number(env.CDN_AUTOBUILD_MINUTES)
const watch = { seen: null, since: 0, failed: null, error: null }
async function checkProduction() {
const r = Bun.spawnSync(['git', '-C', '/app/repo', 'ls-remote', 'origin', `refs/heads/${PROD}`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
const sha = r.success ? String(r.stdout).split(/\s/)[0] : ''
watch.error = sha ? null : `cannot read branch '${PROD}' on origin`
if (!sha) return
if (sha !== watch.seen) { watch.seen = sha; watch.since = Date.now() }
const published = publishedCommit()
if (published && sha.startsWith(published)) return
if (last.state === 'building' || watch.failed === sha) return
if (Date.now() - watch.since < AUTO_MIN * 60000) return
startBuild('auto')
const poll = setInterval(() => { if (last.state !== 'building') { clearInterval(poll); if (last.state === 'failed') watch.failed = sha } }, 5000)
}
if (AUTO_MIN > 0) {
setInterval(() => checkProduction().catch(() => {}), 30000)
console.log(`[admin] public build follows ${PROD}, ${AUTO_MIN} min after it moves`)
}
// SUBDOMAIN APPS: a published plugin as its own public site, e.g. research-gallery.<domain> → the /cdn build
// of doxa-maps-spa. SUBDOMAIN_APPS = "host=bundle/profile,host2=bundle/profile". A SEPARATE server on its own
// port, reached by the proxy only for those hosts: it serves the published /cdn files and nothing else (no
// admin, no PocketBase, no dev server), so no sign-in is skipped anywhere else. "/" is the plugin's page with a
// <base href> at its real folder, so its relative links resolve exactly as they do on /cdn.
const SUBDOMAINS = Object.fromEntries((env.SUBDOMAIN_APPS || '').split(',').map((x) => x.trim()).filter(Boolean)
.map((x) => x.split('=').map((y) => y.trim())).filter(([h, t]) => h && /^[a-z0-9-]+\/[a-z0-9-]+$/.test(t || '')))
if (Object.keys(SUBDOMAINS).length) {
Bun.serve({
hostname: '0.0.0.0',
port: Number(env.SUBDOMAIN_PORT || 5176),
idleTimeout: 0,
async fetch(req) {
if (req.method !== 'GET' && req.method !== 'HEAD') return text('read-only', 405)
const target = SUBDOMAINS[(req.headers.get('host') || '').split(':')[0].toLowerCase()]
if (!target) return text('not found', 404)
const url = new URL(req.url)
const headers = { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=300, stale-while-revalidate=86400' }
if (url.pathname === '/' || url.pathname === '/index.html') {
const leaf = join(CDN, 'dx-maps', target, 'index.html')
if (!existsSync(leaf)) return text('this app is not published yet', 503)
const html = readFileSync(leaf, 'utf8').replace(/<head([^>]*)>/i, `<head$1><base href="/dx-maps/${target}/">`)
return new Response(html, { headers: { ...headers, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' } })
}
// /embed-test: every web plugin in this host's tree, embedded as JavaScript (deployment/cloud/embed-test.html,
// read per request from the pulled checkout, so the page follows the branch without a restart).
if (url.pathname === '/embed-test' || url.pathname === '/embed-test/') {
const page = '/app/repo/deployment/cloud/embed-test.html'
if (!existsSync(page)) return text('not found', 404)
return new Response(readFileSync(page, 'utf8'), { headers: { ...headers, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' } })
}
const f = normalize(join(CDN, decodeURIComponent(url.pathname)))
if (!f.startsWith(CDN + '/dx-maps/')) return text('not found', 404)
const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f
if (!existsSync(file)) return text('not found', 404)
// cache by what the file is (cdn-cache.mjs): moving URLs revalidate in 5 min, with an ETag + 304
return serveTreeFile(req, file, file.slice(CDN.length), { 'access-control-allow-origin': '*' })
},
})
console.log(`[admin] subdomain apps: ${Object.entries(SUBDOMAINS).map(([h, t]) => `${h} → ${t}`).join(', ')}`)
}
Bun.serve({
hostname: '127.0.0.1',
port: 5175,
// No idle cut-off: PocketBase's realtime stream (/pb/api/realtime) sits quiet while someone signs in in the
// popup, and Bun's default (10 s) closed it before the sign-in result could arrive.
idleTimeout: 0,
async fetch(req) {
const url = new URL(req.url)
const p = url.pathname
if (p.startsWith('/cdn/')) {
const f = normalize(join(CDN, decodeURIComponent(p.slice(5))))
if (!f.startsWith(CDN)) return text('bad path', 400)
const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f
if (!existsSync(file)) return text('not found', 404)
// Was one day + a week's stale-while-revalidate for everything, with no ETag, so a publish took days to
// reach a browser. Now by what the file is (cdn-cache.mjs), with an ETag and 304s.
return serveTreeFile(req, file, file.slice(CDN.length), { 'access-control-allow-origin': '*' })
}
if (p.startsWith('/api-prod/')) {
if (!p.startsWith('/api-prod/api/')) return text('only /api/ is public', 403)
return proxy(req, url, '/api-prod/', 8091)
}
if (p.startsWith('/pb/')) {
if (p.startsWith('/pb/_/') && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403)
// The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these
// paths to the public (its skip routes exclude names starting with _), so the groups header is its own.
if (p.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403)
return proxy(req, url, '/pb/', 8090)
}
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
if (p === '/admin/status') return Response.json({ ...last, production: { branch: PROD, autoBuildMinutes: AUTO_MIN, head: watch.seen, published: publishedCommit(), prodSchema: prodSchema(), error: watch.error } })
if (p === '/admin/build') {
if (req.method !== 'POST') return text('POST only', 405)
if (!isAdmin(req)) return text('builds are for team admins', 403)
if (last.state === 'building') return text('a build is already running', 409)
startBuild('admin')
return text('build started', 202)
}
return text('not found', 404)
},
})
console.log('[admin] listening on 127.0.0.1:5175')