Closes the review of 11ba7bff4 (deployment/cloud/atomic-pull.mjs, vite.cloud.config.mjs; Vite 5.4.21).
- The gate now opens the moment git takes <git dir>/index.lock (fs.watch on the git dir, a 50 ms poll if that
watch cannot be set up), before git's first write, and stays open while the lock exists: `git reset --hard`
writes every file, public/ included (which Vite does not watch), inside that window. File events (and raw
kernel notifications) still extend it, and still open it for any other writer. A lock that comes and goes with
no file event (`git status`, as the dev plugin's /__dev-sha runs) closes it at once.
- What the container writes for itself under /app/repo leaves the watcher (server.watch.ignored in the overlay,
appended to the repo's public/ by mergeConfig): .pb/ (backend logs and data), backend/bin/ (the engine
binary), backend/pocketbase/pb_migrations/ (what PocketBase dev writes from its dashboard). They held pages
before; a landing that changes them is still gated by the lock.
- At closing only the touched files' modules are invalidated (from the file events, plus `git diff` between
HEAD before the lock and HEAD after when git moved it), so unchanged modules keep their transform and answer
304. The gate's log line names the git range and file count.
- With a restart waiting, the held requests (and any that arrive until this server closes) are answered
503 + Retry-After: 1, not passed into the closing server (504). A page gets a small page that reloads itself
once the server answers again.
- One startup line: "[atomic-pull] armed: quiet 1200 ms, cap 10000 ms, git-lock watch on".
- The cap (ATOMIC_CEILING_MS) defaults to 10000: no browser is held longer, and it still gets its one reload.
Measured on the scratch Vite 5.4.21 project under the worktree's own overlay (paths changed), with a page of 50
modules and 80 public files in a browser: 52/52 checks pass. The earlier cases (bursts, a request mid-burst, a
single edit, a broken-then-fixed module with and without a page, a config restart and a failed one, the ceiling)
plus: a git landing of 392 files (80 under public/) where requests sent at the lock are held until git lets go
and answered new, one reload, every module and public file new after it; a .pb/ + backend/bin/ + pb_migrations/
writer every 250 ms holds nothing (slowest request 17 ms); `git status` every 300 ms holds nothing; the 10 s cap
with a never-ending writer and with a stale index.lock; the startup line once per server start; a restart
mid-burst answering 503 + Retry-After: 1 and one reload on the new server. Against 11ba7bff4 the same landing
served a public bundle and a module OLD while git wrote, and the same server writer held a request 19.8 s.
The pull loop (entry.sh) runs `git reset --hard` straight into the tree Vite watches. A landing rewrites
hundreds of files one by one, and Vite answered the first of them while git was still writing the rest:
an open page was reloaded onto a mix of old and new modules, and its tab clicks then fetched nothing until
it was reloaded by hand.
deployment/cloud/atomic-pull.mjs, added to the cloud overlay only (local dev, builds and entry.sh are
unchanged; Vite restarts itself once when this config lands). The first file event opens a gate that
closes once the tree has been quiet for ATOMIC_QUIET_MS (1200), never past ATOMIC_CEILING_MS (20000,
under oauth2-proxy's 30 s upstream timeout). While it is open it holds the messages to the browsers
(update, full-reload, prune, error: at ws.send, which Vite 5's server.hot broadcasts through and which IS
environments.client.hot in Vite 6/7), every request except the backend routes (a middleware registered
ahead of all others), and a config restart. When it closes: invalidate the module graph, release the
requests, then exactly ONE full-reload (or the held restart). A held error is dropped, so none is buffered
for the next browser. Past the ceiling it passes everything through until the tree is quiet.
ATOMIC_QUIET_MS=0 or PULL_INTERVAL=0 turns it off.
Measured on a scratch Vite 5.4.21 project (the version bun.lock pins) under a copy of this overlay, with a
page of 50 modules in a browser: one `git reset --hard` of 312 files sent the page 27 full-reloads and
25 updates without the plugin, and exactly 1 full-reload with every module new with it. Bursts, a
request mid-burst, a single edit, a broken-then-fixed module (page open and no page open), a config
restart (and a failed one) and the ceiling: 27/27 checks pass.
Comments, messages and docs only; no check logic changes.
- The role-word gate is renamed check-no-internal-role-nouns (and its baseline fixture). Same
logic: it builds the word from parts so the file does not carry it, and a planted line in
title case and in capitals still fails it while WebDriver and "GPU driver" do not.
- public-hygiene-check: the two role patterns are built from parts (the regexes are byte-
identical), the header loses the forge and date wording, and the prayer comment loses its
decision date. Its output on the tree is identical in normal and --strict modes.
- landing-preconditions, check-no-public-binds, check-published-tree-weight: no fleet tool
name, no "this box", no forge name in comments and messages.
- Removed: check-backlog-refs, check-backlog-status-enum, check-done-evidence,
check-verification-tags and check-dxlife-untouched (they police a private backlog and
workspace), with their scripts, docs, gate-list entries and orphan-baseline entry, and the
committed needs-eyes pages (check-embedded-rtl --emit writes them on demand).
- package.json docs: no agent names. Docs: the stale check mentions and the snapshot
references are gone (the snapshots themselves leave in the studio P commit).
gates:pre: PASS 115, FAIL 0, NOT MEASURED 1 (browser seat).
With it on, oauth2-proxy skips its button page. For a user already signed in to the provider the sign-in is
then redirects only, and the provider's no-framing headers never apply, so a framed Studio signs itself in.
UNVERIFIED: never run live in a framed dashboard (written 2026-09-28 04:32Z, not landed until the 14:38Z merge-all order).
Checked only statically: bash -n passes, and the argument switch yields --skip-provider-button for 1 and nothing for unset or 0 under set -u.
Replayed onto development: AUTO is now defined inside the OIDC gate block (ACCESS != public), next to the ALLOW list, and passed before --upstream="$ROOT".
Default off, so no running container changes.
In ACCESS=public mode the guard refused a gated bundle's source and its /__leaf/ pages, but not
/__plugin-gallery/registry.json. That file is the raw discovery list for the smoke tools, and it still
names key-cities, its tag and its two profiles to any visitor. No data, but a gated plugin must not reach a
public surface. The Studio reads only manifest.json, which already leaves gated bundles out, so the
registry is now refused in public mode as well. Dev mode is unchanged, and the smoke tools that read the
registry run against a dev server.
Measured with the cloud config run locally:
- before: public mode served registry.json (200) naming key-cities;
- after: public mode answers 404, including with a query string and a percent-encoded name;
- manifest.json and / still answer 200 in public mode, and dev mode still serves registry.json.
- PRODUCTION_BRANCH defaulted to `production`, a branch this repo does not have (development is the
table, main is production). compose.yaml and every deployment already set main; a platform deploy
that left it unset watched a missing branch and /cdn never built. Default main in build-cdn.sh,
pb-prod-migrations.sh and admin.mjs, and in the README.
- EMBED_BASE_URL was missing from the settings table. Unset, the published snippets stay relative.
- Open mode is for a laptop or a private network: it has none of the public mode's guards. Said so.
- compose.yaml reads the repo-root .env, the same file bun run dev reads, so laptop-only settings
would reach the container. Said so in compose.yaml and the platform table.
No Vite, no pull loop and no Rudder: /cdn, PocketBase, the admin router and the sign-in stay, and / redirects to
/cdn/. The default stays on, so running containers are unchanged. Off without OIDC_ISSUER refuses to start,
because /pb and /admin would then have nothing in front of them.
Replayed onto development after the public Studio mode landed. Off together with ACCESS=public is refused
too, because the public Studio is the dev server itself. PULL_INTERVAL=0 still means no pulls, whatever
DEV_SERVER says. The setting is in the cloud README's table.
/pb already refused them to anyone who is not an admin. /api-prod, the production PocketBase's
public API, passed every /api/ path, so its superuser sign-in was reachable from the public side:
no data was exposed, but it was a place to guess the production admin's password. Now any
/api-prod/api/collections/_… path is refused (403), checked on the decoded path like the /pb gate.
No public client calls these; the one caller in the repo (pb-oidc.mjs) talks to PocketBase directly.
Found by dx-base's independent review of the public Studio (V2).
The resolved config in ACCESS=public (Vite 5.4.21) had strict file serving with /app/repo as the only
allowed root, and a deny list of Vite's defaults (.env, .env.*, certificates). The .git folder sat
inside the allowed root and was not denied, so a public Studio could serve the repository's git
internals. In public mode the deny list adds **/.git/**; the defaults are restated because setting
deny replaces them.
The container needed an OIDC provider to run PocketBase at all. With ACCESS=public:
- The Studio, every map and the backend's public API are open, with no sign-in. Vite owns
the public port and forwards /cdn/, /api-prod/, /pb/ and /admin/ to admin.mjs, whose gates
decide.
- /admin/ (publishing), PocketBase's dashboard and _superusers ask for ADMIN_USER /
ADMIN_PASSWORD over HTTP Basic, compared in constant time. The container refuses to start
without ADMIN_PASSWORD. In this mode X-Forwarded-Groups is never read: with no oauth2-proxy
in front, it is whatever a visitor sends.
- Rudder's voice engine stays off. Gated bundles (gatedProfiles) are refused even by URL:
source, @fs, dev leaf. Vite's /__open-in-editor is refused.
OIDC stays the other mode, unchanged. In BOTH modes the Studio's own /api/pb (the dev backend
plugin) now goes through admin.mjs's /pb (DX_PB_URL). The dashboard and _superusers stay
behind the same gates, and plugins that use PocketBase work in the container's Studio; they
got a 503 before.
Checked: bash -n; the mode selection (public / gated / open); a missing password stops the
container. The admin gate and the public guard, taken from these files, pass 20/20:
- password right / wrong / wrong user;
- a spoofed groups header ignored;
- 401 + Basic prompt;
- gated mode unchanged;
- no password = no admin;
- every form of a gated path refused and the rest passed.
Two holes studio found in admin.mjs, there since 2026-09-27:
- proxy() parsed the remaining path as a URL. /pb//evil.example/x is a protocol-relative URL, so
admin.mjs fetched ANOTHER host (any internal address too) with the visitor's own headers, and
returned its body. The path now goes onto a fixed http://127.0.0.1:<port> origin and is never
parsed as a URL, and a path starting with // is refused.
- The /pb/_/ and _superusers checks tested the raw path, so /pb/%5F/ and
/pb/api/collections/%5Fsuperusers/... passed them, and PocketBase decoded them to the real
thing. The checks now run on the decoded, slash-collapsed path. Encoded slashes or
backslashes, and undecodable input, get 400.
Reach today: maps-dev answers all four attack paths with its sign-in page to an anonymous
visitor (measured), so a signed-in team member could use them. In ACCESS=public they would
be anonymous; that mode is held until this lands.
Checked both ways against a PocketBase-like upstream plus an "evil" host. The old code let
/pb/%5F/, %5Fsuperusers and %2F through, fetched http://_/ and hit the evil host once. The new
code refuses them (403/400) with 0 evil hits, and health and records calls still pass.
The route table put both under OIDC_ADMIN_GROUPS. admin.mjs checks the admin group on the
build only; status answers any signed-in team member (commit SHAs and build state, nothing
more). Measured: a designer reads /admin/status (200) and is refused the build (403, "builds
are for team admins").
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/_/.
The pull loop follows BRANCH with `git reset --hard FETCH_HEAD` whenever the branch moves.
In the cloud that is the point. On a checkout mounted from someone's machine it would wipe
their commits and uncommitted work. PULL_INTERVAL=0 now skips the loop entirely: no fetch,
no reset, one line in /tmp/pull.log saying so. Every other value behaves as before.
Checked both ways on a throwaway repo whose remote had moved on and whose working copy held an
uncommitted edit: with 0 the commit and the edit stay; with 1 (the control) the loop resets
both, as it always has.
Railway, DigitalOcean and `docker build` use a root Dockerfile with no setting, and it is
Coolify's and Dokploy's default path. The file moves; nothing is copied.
- VOLUME /data is gone. Railway rejects the line, and a named volume is needed anyway
(VOLUME alone gives each new container a fresh anonymous one). The platforms mount /data.
- The baked PocketBase gate's comment named the old path. It now says the repo-root
Dockerfile, and the gate's SHA-256 pin moves with it (2987055b... -> 2eceef03...). The
hook's code is unchanged: the exact RUN passes `sha256sum -c`, and the old block still
passes its old pin.
- .dockerignore excludes everything: the Dockerfile copies nothing from the build context,
so no platform sends the whole repository to the builder.
- compose.yaml: the same image with its /data volume and memory limit; settings from .env.
- deployment/cloud/README.md gains "Where it runs": Coolify, Dokploy, Railway, a server with
Docker, Kubernetes, and why not DigitalOcean App Platform (no persistent volumes).
DEPLOY.md and two comments follow the move.
pb-prod (the public API) ran the migrations in the pulled checkout, which follows the table (development), so a
restart moved production's schema to unpublished work. It now reads /data/pb-prod-migrations/current: a snapshot
of PRODUCTION_BRANCH's migrations taken at start (the ref build-cdn.sh uses) and refreshed from the very snapshot
a publish builds. Only a change restarts pb-prod, and the restart is local: pb-prod runs in a loop, so the
container stays up. It fails closed: an error keeps the last good snapshot, and with none it is an empty folder,
never the development checkout. --automigrate=false, so a change made in prod's dashboard writes no migration.
/admin/status shows prodSchema next to published.
SUBDOMAIN_APPS=host=bundle/profile makes e.g. research-gallery.<domain> 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 <base href> at the plugin's real folder, so its relative links resolve as on /cdn.
main is the shared sandbox the dev server follows; /cdn is built only from PRODUCTION_BRANCH (default
production), which moves when someone promotes: git push origin main:production. The container watches
that branch and rebuilds /cdn once it has held a new commit for CDN_AUTOBUILD_MINUTES (default 2; 0 = the
admin button only); a failed commit is not retried automatically. The build fetches the branch into its
own ref (never touching the dev server's FETCH_HEAD) and uses that commit's own exclusion list, so
loosening it takes a promotion too. DEPLOY.md documents the promote step.
/admin/pb-signin-check (behind the site sign-in) signs in to pb-dev through the team account, with the
same realtime flow as the PocketBase SDK, and shows the email and groups it sees, or the exact refusal.
The token is never stored.
Laptops reach pb-dev's API without the site sign-in; PocketBase signs people in itself through the same
OIDC provider (its own client), members of OIDC_ALLOWED_GROUPS only, password sign-in off. The group check
is ONE hook baked into the image and pinned by SHA-256; hooks from the pulled repo still never run. The
dashboard, _superusers and system collections stay behind the site sign-in for admins. It fails closed:
without the baked gate or a working OIDC setup, /pb/ stays behind the site sign-in as before.
With two domains on one container, the engine answered the second one 403 "forbidden host", so Rudder
showed offline there. The allowed list now defaults to the hosts Coolify gives the container
(COOLIFY_FQDN), then the OIDC redirect host; always an explicit list, never a wildcard.
The pull loop rebuilt the engine when its source changed, but the old process kept running until the
next container start. Now, when the engine reports it is not in use, the old process is stopped by its
own PID and the dev server starts the new binary.
Rudder's setup ran before Vite and the sign-in proxy, so the whole site answered 502 for minutes on every
start while the engine built and ~742 MB downloaded; on the first start the download was reset mid-file and
Rudder stayed offline. Now the runtime and pinned model are baked into an early image layer (SHA-256
checked, resumed and retried on a stall), setup runs in the background, /api/rudder answers "starting"
meanwhile, and the dev server restarts once when setup ends so the engine starts.
The first build in the container hung for 18+ minutes: the image had only bun's node shim, and
`node --check` under that shim runs each script instead of parsing it. The gates also already
passed before a commit reaches main, so the container now runs clean, token, bundles, tree and
check:sri only. The public exclusions and the refuse-the-swap check still run on every build.
runtime-config.js was written with python3, which the image does not have; bun writes it now.
The build's PID goes to /tmp/build-cdn.pid so it can be stopped without touching the container.
The engine now refuses non-loopback Host headers and Vite's proxy passes the public host through, so
without this every /api/rudder call is 403 (studio, RUDSRV-STU2). entry.sh derives it from
OIDC_REDIRECT_URL when unset; the README documents it and the CPU need (it ran ~1.5x behind real time
on a loaded box).
Go 1.22 in the image; on start, backend/rudder-engine/setup-runtime.sh fetches its pinned runtime and
model (SHA-256 checked, skipped when present) into RUDDER_HOME (default /data/rudder, meant for the
persistent volume) and builds the engine; RUDDER_AUDIO=browser. The pull loop rebuilds the untracked
engine binary when backend/rudder-engine changes. /api/rudder stays behind the sign-in: it is served
by Vite on the gated / route, and the only public routes remain /cdn/ and /api-prod/api/. README:
the flag, and 3.5 GB with it on.
Bundle integrity: all 25 bundles carry bundles[].integrity (sha384) and every profiles[].embed.script
already includes integrity + crossorigin=anonymous (checked on main); the section shows the snippet,
what the browser does with it, the CORS header it needs, and check:sri. The container's PocketBase:
locked-by-default collections, the prod dashboard blocked and the dev one admin-only, and hooks OFF,
now true in code: both instances get --hooksDir pointing at a path that never exists, so a pb_hooks/
folder added to the repo can't run. Per-plugin locks and isolated edge functions are stated as not
built.
deployment/cloud: PocketBase (pinned, sha256-checked) as dev (/pb/, signed in; its /_/ dashboard for
OIDC_ADMIN_GROUPS only) and prod (/api-prod/api/ public, dashboard never exposed), with the schema
as migrations in backend/pocketbase/pb_migrations and data on the /data volume; superuser from env.
admin.mjs routes /cdn/, /api-prod/, /pb/ and /admin/ behind oauth2-proxy (public: /cdn/ and
/api-prod/api/ only). build-cdn.sh: flock (409 on a second build), a snapshot of the pulled commit,
the public exclusions applied BEFORE the build, a heap-capped build, a check of the output that
REFUSES the swap if anything excluded remains (proved with planted fixtures: a field-access file,
'Visa Requirements' content, and key-cities one level down each fail; clean passes), then an atomic
swap; runtime-config.js carries the public API URL. public-exclusions.txt is the ONE list the
giveaway export and the CDN build share. Any git host via REPO_URL/BRANCH/GIT_TOKEN.
deployment/cloud/: a bootstrap Dockerfile (Bun, git, oauth2-proxy v7.15.3) that clones the repo
and runs deployment/cloud/entry.sh from it, so the recipe updates with git; the Vite overlay
(host, allowed hosts, HMR over wss); and the pull loop that follows the branch. With no OIDC env it
serves Vite directly (laptop default unchanged). With OIDC_ISSUER set, oauth2-proxy owns the port
and Vite listens on localhost behind it: Studio, maps and the HMR websocket all sit behind one
sign-in, with PKCE S256, a confidential client and a REQUIRED group allow-list (it refuses to
start without one). Secrets reach oauth2-proxy through its environment only. README gets a short
'Run it in the cloud' section linking the recipe.