From ded58aa2ccc332084ad06b83f2e57e3146147fd7 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 04:28:00 +0000 Subject: [PATCH] Cloud container: subdomain apps, a published plugin as its own public site SUBDOMAIN_APPS=host=bundle/profile makes e.g. research-gallery. serve the /cdn build of doxa-maps-spa at its root. A separate server on its own port answers only those hosts and only with published /cdn files (no admin, PocketBase or dev server behind it), so no sign-in is skipped anywhere else. The root page gets a at the plugin's real folder, so its relative links resolve as on /cdn. --- deployment/cloud/README.md | 1 + deployment/cloud/admin.mjs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/deployment/cloud/README.md b/deployment/cloud/README.md index 0de8d5a5f..ed205bd31 100644 --- a/deployment/cloud/README.md +++ b/deployment/cloud/README.md @@ -68,6 +68,7 @@ there. Only then does it swap the new build in. | `PB_OIDC_CLIENT_SECRET` (+ `PB_OIDC_CLIENT_ID`, default `dx-maps-pb-dev`) | turns pb-dev into the team backend: laptops use it directly (`DX_PB_URL=https:///pb`), and people sign in to PocketBase through the same OIDC provider, with its own client whose redirect URI is `https:///pb/api/oauth2-redirect`. Only members of `OIDC_ALLOWED_GROUPS` get in (a sign-in hook baked into the image and pinned; the repo's own hooks never run). Password sign-in is off, new collections are superuser-only until a migration opens them, and data never leaves `/data` | | `PRODUCTION_BRANCH` | the branch `/cdn/` is built from (default `production`); the dev server keeps following `BRANCH`. Promote with `git push origin main:production` | | `CDN_AUTOBUILD_MINUTES` | rebuild `/cdn/` by itself once `PRODUCTION_BRANCH` has held a new commit this long (default `2`; `0` = only the admin button) | +| `SUBDOMAIN_APPS` | publish plugins as their own PUBLIC sites: `host=bundle/profile`, comma-separated, e.g. `research-gallery.example.org=doxa-maps-spa/doxa-maps-spa`. Served from the `/cdn` build by a separate server on `SUBDOMAIN_PORT` (default `5176`) that answers only those hosts and only with published files; point each host's proxy route at that port. No sign-in is skipped anywhere else | | `PUBLIC_API_BASE` | the public API URL written into the build's `runtime-config.js` (e.g. `https:///api-prod`) | | `BUILD_MAX_OLD_SPACE_MB` | Node heap cap for a build (default `1536`) | | `RUDDER_ENGINE` | `1` runs Rudder's voice-to-text engine here, fed by the browser's microphone (about 1 GB more; its runtime and model, ~742 MB, are baked into the image at `/opt/rudder`, and `/data/rudder` wins when it holds a model). The site serves at once; Rudder shows "starting" until its engine is built, then the dev server restarts once to start it. Give the container at least 2 vCPUs, or transcription falls behind speech | diff --git a/deployment/cloud/admin.mjs b/deployment/cloud/admin.mjs index f15593595..3da6f5c62 100644 --- a/deployment/cloud/admin.mjs +++ b/deployment/cloud/admin.mjs @@ -5,6 +5,7 @@ // 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' @@ -65,6 +66,40 @@ if (AUTO_MIN > 0) { 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. → 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 +// 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(/]*)>/i, ``) + return new Response(html, { 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) + return new Response(Bun.file(file), { headers }) + }, + }) + console.log(`[admin] subdomain apps: ${Object.entries(SUBDOMAINS).map(([h, t]) => `${h} → ${t}`).join(', ')}`) +} + Bun.serve({ hostname: '127.0.0.1', port: 5175,