@bongos/core 1.19.708 → 1.19.710

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.708",
6
- "core_contract": "1.19.708",
7
- "source_commit": "42f201a980e11b8b417c2afa527230ed1ab05775",
5
+ "core_version": "1.19.710",
6
+ "core_contract": "1.19.710",
7
+ "source_commit": "ff37fbc25d95dd2884f9f0158cdb5338ed673774",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T06:14:54.451Z",
9
+ "built_at": "2026-09-13T07:06:06.980Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 477,
12
+ "docs_redacted": 478,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2138,
14
+ "functional_verbatim": 2140,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2639,
20
- "tree_sha256": "369223629e32da5858cc92a00ab2643eaa169ca59d1d89d6ab77ab8c50e3fd57",
19
+ "file_count": 2642,
20
+ "tree_sha256": "11ec07e4ffff8f23d2aeb9a35181c59d87255a2a80010d858b071858726a3acc",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1132,7 +1132,7 @@
1132
1132
  {
1133
1133
  "path": "docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md",
1134
1134
  "mode": "0000644",
1135
- "sha256": "1f8311bbe45204ee6f049024e3630cd0586c9bed44c78d4dc1f870b0c0c2381c"
1135
+ "sha256": "1b144566a9166db2a54c07241970d12621273e29691f799799fc5b6a39b3c440"
1136
1136
  },
1137
1137
  {
1138
1138
  "path": "docs/adr/0129-cloudbongos-control-plane-pull-deploy.md",
@@ -1212,7 +1212,7 @@
1212
1212
  {
1213
1213
  "path": "docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md",
1214
1214
  "mode": "0000644",
1215
- "sha256": "53488f1ff783c49467dd7f9bcf17f5a35c8e3161e2840a89ac5f3f571f7471d9"
1215
+ "sha256": "0eaedcf642882a857024cf68e900ecfca457ba379150a8afbd16a374c4a16c82"
1216
1216
  },
1217
1217
  {
1218
1218
  "path": "docs/adr/0146-configurable-reward-mode-cost-plus-only.md",
@@ -1914,10 +1914,15 @@
1914
1914
  "mode": "0000644",
1915
1915
  "sha256": "630f6460b3484f4cc629cce9c431f824bb8363d30a249db9e21e89863995c7ac"
1916
1916
  },
1917
+ {
1918
+ "path": "docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md",
1919
+ "mode": "0000644",
1920
+ "sha256": "96d78083fc4b04b8bdce61dee59e05465ec273759cfac325b160f68c8e5184a4"
1921
+ },
1917
1922
  {
1918
1923
  "path": "docs/adr/README.md",
1919
1924
  "mode": "0000644",
1920
- "sha256": "49273e63c50c201cd70200a4f0db7f233e496827823adb9449638eb5a0c1d030"
1925
+ "sha256": "dd5c5de0fb4c01c355d4adc9cd550717bc6d6c074bedaf6e05fbbd0385fd9138"
1921
1926
  },
1922
1927
  {
1923
1928
  "path": "docs/api-reference.md",
@@ -2802,7 +2807,7 @@
2802
2807
  {
2803
2808
  "path": "docs/module-api-changelog.md",
2804
2809
  "mode": "0000644",
2805
- "sha256": "fd622c24f29995f2c8ebdd272cf5784f05285bbafc6c3da490308ed5e1730ba7"
2810
+ "sha256": "94214ff6dfe72959e4aa130b0f40efde676215cae1cf8dcdd66ec9ca59972f0c"
2806
2811
  },
2807
2812
  {
2808
2813
  "path": "docs/modules-contract.md",
@@ -2992,7 +2997,7 @@
2992
2997
  {
2993
2998
  "path": "docs/recipes/ops-gotchas.md",
2994
2999
  "mode": "0000644",
2995
- "sha256": "d9956abefe8496cbbb8bbc94361431d40823c3a9d1fa8f4145e8b48999b54672"
3000
+ "sha256": "dadf37b7916c96aeb6e5a1d068a8b59b7b73648dbf6816f949f63fe1220fad16"
2996
3001
  },
2997
3002
  {
2998
3003
  "path": "docs/recipes/overnight-builder.md",
@@ -3859,6 +3864,11 @@
3859
3864
  "mode": "0000644",
3860
3865
  "sha256": "03df9104a0309011df1b6e96627c62cc6612f34b654134306d13fd5c89b8f038"
3861
3866
  },
3867
+ {
3868
+ "path": "migrations/core_239_claimable_respects_blocker_status.sql",
3869
+ "mode": "0000644",
3870
+ "sha256": "75a3d09bce8a40b51015054b4f4c77706ce76e7f8cb3fc4a54048c9bd33814b8"
3871
+ },
3862
3872
  {
3863
3873
  "path": "modules/agents/lib/validate.js",
3864
3874
  "mode": "0000644",
@@ -7782,12 +7792,12 @@
7782
7792
  {
7783
7793
  "path": "package-lock.json",
7784
7794
  "mode": "0000644",
7785
- "sha256": "4f9b97401aebd998dd23bf3ec95a333fd6c596c0ea5551dc02dc6e863ad92742"
7795
+ "sha256": "566cea60dfb2c1088d418f3ca787364b72cc4c83fdb73d7397f5cfbaa4a54981"
7786
7796
  },
7787
7797
  {
7788
7798
  "path": "package.json",
7789
7799
  "mode": "0000644",
7790
- "sha256": "9002e7fc0a70168d56042d71c7905aa045b541dbc36d61e88180a8dfb1e0f559"
7800
+ "sha256": "5e946e11f0cf01f78c59df42dec1f4903b79bb19b31e603be63e7997bddad050"
7791
7801
  },
7792
7802
  {
7793
7803
  "path": "public-docs/index.html",
@@ -8602,17 +8612,17 @@
8602
8612
  {
8603
8613
  "path": "scripts/gds/provision-repo.js",
8604
8614
  "mode": "0000644",
8605
- "sha256": "c87cccdd63ac6fa6d38771c3ad178e5e919f8bee62ad769b9d127819f84e2750"
8615
+ "sha256": "3612ebe8f54da7498a6c4998e0d1d1c4596a80d54f70b5543b4a46dfca2ffbcf"
8606
8616
  },
8607
8617
  {
8608
8618
  "path": "scripts/gds/provision-units.js",
8609
8619
  "mode": "0000644",
8610
- "sha256": "55c394bb932966cd2c53ab44e34d7fc23e855c1157c185456c5a630d8c3e62e1"
8620
+ "sha256": "a59615231f2843a11cf2d9d2f6f07c7aa2dacb6e83fb6126fdfeae9cfdc19abd"
8611
8621
  },
8612
8622
  {
8613
8623
  "path": "scripts/gds/provision.js",
8614
8624
  "mode": "0000644",
8615
- "sha256": "82fb65fea5dd9a33770690c902680cb428eb74b1c2abdaeab7f89f444ae1c11f"
8625
+ "sha256": "007c5cfa3b42567df0016038fc7ec5c7dbdab60ec6ec8ecd9d06386933e5641d"
8616
8626
  },
8617
8627
  {
8618
8628
  "path": "scripts/gds/publish-credential-check.js",
@@ -9407,7 +9417,7 @@
9407
9417
  {
9408
9418
  "path": "src/bongos/pool.js",
9409
9419
  "mode": "0000644",
9410
- "sha256": "c9edcf9cb1f027ca122388beabae488d2b771b9e7aee2cd12fbdb48ea6f0ded8"
9420
+ "sha256": "5c6121de5ba67f23b4609ddf06cad6fcda1dd5e19f09996616a49e23bcca38a1"
9411
9421
  },
9412
9422
  {
9413
9423
  "path": "src/bongos/prelaunch-gate.js",
@@ -9567,7 +9577,7 @@
9567
9577
  {
9568
9578
  "path": "src/module-api.js",
9569
9579
  "mode": "0000644",
9570
- "sha256": "98205e89563b71f2e6ddbe44a9639464bdc589c20f3f0a8de12a2b968ac06569"
9580
+ "sha256": "fb8072c2a17b700fa08472c181b9e7beda25058987ecfcfea20825cb86849e0d"
9571
9581
  },
9572
9582
  {
9573
9583
  "path": "src/module-loader/catalog.js",
@@ -10159,6 +10169,11 @@
10159
10169
  "mode": "0000644",
10160
10170
  "sha256": "6e5ff3acb835fd6219136ee2a5e144841076a82733915cc7111688f707464442"
10161
10171
  },
10172
+ {
10173
+ "path": "tests/claimable_blocker_status.mjs",
10174
+ "mode": "0000644",
10175
+ "sha256": "6bb10ab7ee346bbe673055e46e9fc53865540157c48c203e57c11ad51ba44336"
10176
+ },
10162
10177
  {
10163
10178
  "path": "tests/claims_bind_session.mjs",
10164
10179
  "mode": "0000644",
@@ -12132,7 +12147,7 @@
12132
12147
  {
12133
12148
  "path": "tests/provision.mjs",
12134
12149
  "mode": "0000644",
12135
- "sha256": "d70cc9bd673dce403787814849abbeef9ed0ea01f9e38a2d3288886879490830"
12150
+ "sha256": "e247471cb6b11000b0e2bcb41166c24adbbe0db902513832f5e40fe1ba655ef8"
12136
12151
  },
12137
12152
  {
12138
12153
  "path": "tests/provision_restart.mjs",
@@ -39,6 +39,8 @@ Add a single config knob, **`PROVISION_SUDO=1`** (alias `PROVISION_PRIVILEGED=1`
39
39
  - **The dedicated-droplet cloud-init path is deliberately unchanged** (`dedicatedUserData`). It runs as root on a fresh droplet — a different privilege + file-perms model — and is out of the co-tenant/standalone scope this task exercised; escalating it is a separate change.
40
40
  - **Follow-ups noted from the standup:** the live `/etc/caddy/Caddyfile` drifts from `infra/Caddyfile` with no auto-sync (a deploy-time reconcile is worth considering); the migrate-as-postgres readable-checkout precondition should be asserted by the box provisioning, not just documented.
41
41
 
42
+ > **Followed up 2026-09-12 by [ADR 0281](<redacted>.md)** (task [1003369](https://cloudbongos.com/builders#/task/1003369)), which takes the last alternative below from the other end: the *instance* now holds per-instance DB credentials (its own password-authed role) rather than the runner holding app creds. **This ADR is unchanged** — the runner still escalates via `sudo -u postgres` for `createdb`/`migrate`, and `PROVISION_SUDO` still means exactly what it means here.
43
+
42
44
  ## Alternatives considered
43
45
 
44
46
  - **Grant `lars` a PG login role + writable `/etc`.** Rejected: non-portable per-box setup, and it weakens the trust boundary — the runner shouldn't *own* `/etc` or be a standing PG superuser; `sudo -u postgres` is scoped to the two DB commands.
@@ -21,6 +21,8 @@ The co-tenant instances that federate on cloudbongos.com subdomains today (e.g.
21
21
 
22
22
  A `standalone` runs **on the hub box** ([scripts/gds/provision.js](../../scripts/gds/provision.js): `standaloneBase`, control-plane-local IP) as the **same UID as the hub** (`serviceUnit` hardcodes `User=lars`), and its deploy runs `npm ci` (not `--ignore-scripts`) in a **passwordless-sudo-capable** runner context. A malicious owner could ship a `postinstall` and get code execution as `lars` with sudo on the hub box → the hub's signing key, every instance's secrets, the hub DB. This is a pre-existing property of running owner code on the hub box at all, independent of federation, but any free "run your code for you" tier would expose it at scale.
23
23
 
24
+ > **Amended 2026-09-12 ([ADR 0281](<redacted>.md), task [1003369](https://cloudbongos.com/builders#/task/1003369)):** `serviceUnit` no longer hardcodes `User=lars` — each provisioned instance now runs as its own unix account with its own password-authed PG role, so the *running* instance is no longer the same UID as the hub. **Finding 2 stands regardless:** the exposure it names is the DEPLOY, not the steady state — `npm ci` still runs owner `postinstall` code in the passwordless-sudo-capable runner context, before the per-instance account is ever involved. The decision below is unchanged.
25
+
24
26
  The federation protocol itself is sound: the hub binds the minted audience to the instance's exact origin (the `#1002108` fix; asymmetric Ed25519 so one instance can't forge for another). A leaked per-instance client secret is contained to its own origin. The weakness is **placement** (shared cookie domain) and **execution** (owner code on the hub box), not the protocol.
25
27
 
26
28
  ## Decision
@@ -0,0 +1,56 @@
1
+ # ADR 0281 — An instance's identity is its own unix account and its own password-authed Postgres role
2
+
3
+ **Status:** Accepted — implemented 2026-09-12 (task [1003369](https://cloudbongos.com/builders#/task/1003369) · goal 1000094, audit ref `B3` of the 2026-08-29 security audit).
4
+
5
+ ## Context
6
+
7
+ [ADR 0111](<redacted>.md) made instance standup automatic and [ADR 0128](<redacted>.md) settled how the *runner* escalates to perform it. Neither settled who the *instance* is once it is running, and the answer was: everybody is the same person.
8
+
9
+ `serviceUnit` hardcoded `User=lars` / `Group=lars`, and **co-tenant is the default shape** — so on a shared box every instance's Node process ran under one uid. Two consequences, both reachable by any code an instance runs, neither of them a bug in any route:
10
+
11
+ - **Sibling environments.** A unix user may read `/proc/<pid>/environ` of its own processes. Every instance's `EnvironmentFile` carries `GITHUB_APP_PRIVATE_KEY`, `<PREFIX>_HUB_CLIENT_SECRET` and its OAuth client secret ([ADR 0141](<redacted>.md) §4, [ADR 0133](<redacted>.md)). One uid meant every instance could read every sibling's.
12
+ - **Sibling databases.** `src/bongos/pool.js` connected by **passwordless peer auth** over the local socket, with `PGDATABASE` supplied as a systemd `Environment=` default. Peer auth answers "which unix user are you", so any code running as `lars` could open a pool against **any** database on the box by naming it — including the control plane's own `builders` and `builder_sessions`.
13
+
14
+ The per-instance `web.env` (`chmod 600`) and the per-instance database were already correct. They were sound walls with one door, and every instance had the key.
15
+
16
+ ADR 0128 anticipated the DB half in its own alternatives: *"Do createdb/migrate via the app `DATABASE_URL` … Left open for a future instance that wires DB creds into the runner's box.env."* This is that change, from the other end — the instance gets credentials, not the runner.
17
+
18
+ ## Decision
19
+
20
+ **Each instance gets its own unix account and its own password-authenticated Postgres role, both derived from its slug, both created by the provision run.**
21
+
22
+ **Naming.** `instanceUser(inst)` → `bongos-<slug>`, folded to `bongos-<head>-<8 hex of sha256(slug)>` when the slug would push it past utmp's 32 characters. Prefixed, never the bare slug: a project may legally be called `postgres` or `lars`, and a bare-slug scheme would hand it an existing account. The hash suffix is not decoration — two slugs sharing a 16-character prefix would otherwise fold onto one account, which is precisely the shared identity being removed. An invalid slug **throws** rather than producing a name.
23
+
24
+ **The unix account** is a system account with `/usr/sbin/nologin` and a home at `/var/lib/<user>` — deliberately **not** under `/home`, because the unit sets `ProtectHome=read-only` and the server writes inside its config home on first boot (`secret-box.js` provisions `builder-secret.key` there). A `/home` account would make that write `EROFS` and the instance would come up unable to hold a secret.
25
+
26
+ **The unit** carries the hardening block the sibling backup unit already had — `ProtectSystem=strict`, `ProtectHome=read-only`, `PrivateTmp=true`, alongside the existing `NoNewPrivileges=true` — with `ReadWritePaths` as the entire allow-list: the state dir, plus an **optional** `-<workDir>/config` (the modules toggle writes `config/modules.json` at runtime; the `-` keeps a shape without one from failing to mount). **The checkout the service runs is deliberately not writable by it.**
27
+
28
+ **The Postgres role** is created or rotated per run: `LOGIN` with a password, owner of its own database, `REVOKE CONNECT … FROM PUBLIC` — and **not** `SUPERUSER`, `CREATEDB`, `CREATEROLE` or `BYPASSRLS`. The revoke is the half that isolates: Postgres grants `CONNECT` on every database to `PUBLIC` by default, so per-role passwords **alone** would leave a sibling one peer-auth hop from any other instance's data. The grants run in a **second** statement against the instance DB, **after** the migrate, because the privileged migrate runs as `postgres` and therefore owns every table it creates; `ALTER DEFAULT PRIVILEGES` aims the same grant forward so the next migration needs no second visit.
29
+
30
+ **The password** is minted per provision run (32 bytes, base64url) and never stored: the same run sets it on the role and writes it into the `0600` `web.env`, so **every provision rotates the credential** and no plaintext copy outlives the run outside that file. `pool.js` reads `PGUSER`/`PGPASSWORD` **explicitly** rather than leaving them to `pg`'s env defaults — the whole defect was a connection whose identity nothing in the code stated, so the identity is now stated where a test can assert it.
31
+
32
+ **Where the credential may travel is part of the decision, not an afterthought.** Two paths carry the rendered command, and both would have persisted the password somewhere this ADR cannot lock down:
33
+
34
+ - **The runner’s own log.** `makeExec`’s `run()` prints the raw command on both the dry-run and the success path, so the role DDL — which embeds the password — would land in the journal. The call site passes **`shown`**, the same mechanism `makeWriteFile` already uses for the `web.env` body, and `dbRoleCmdShown()` lives beside `dbRoleCmd` so a label cannot outlive the secret it hides. A `shown` call site also suppresses stderr, since a failing shell may echo the line back.
35
+ - **DigitalOcean cloud-init user-data.** DO **retains** user-data and serves it back through its API, its console and the droplet’s own metadata endpoint — a permanent second copy. So the dedicated shape takes **`peerOnly`**: the role, its DB ownership and the `PUBLIC` revoke are all still created, and only the password is omitted. A dedicated droplet is **single-tenant**, so the co-tenancy vector B3 describes does not exist on it, and the role peer-auths to its own unix account. Baking a credential into a retained blob to defend against siblings that are not there would be strictly worse than the thing it protects against.
36
+
37
+ **Absence is the old behavior.** No password ⇒ no `PGUSER`/`PGPASSWORD` lines in `web.env` and no keys in the pool config, so an instance provisioned before this keeps exactly the peer auth it had. This cannot brick a box mid-rollout.
38
+
39
+ ## Consequences
40
+
41
+ - **The two named vectors close.** `/proc/<pid>/environ` is readable only by the process's own uid, so sibling environments are gone; a sibling holds no password for another's role and `PUBLIC` no longer carries `CONNECT`, so sibling databases are gone.
42
+ - **The SQL contains no double quote, and that is load-bearing.** Every literal is dollar-quoted and every identifier goes through `format(%I)`, because these commands are embedded in the dedicated droplet's cloud-init as `- [ bash, -lc, "…" ]` — a YAML **double-quoted** scalar. A plain `"role"` identifier reads fine in `psql` and silently truncates the cloud-init line. `seedFirstVersionCmd` dollar-quotes for the same reason; a test now asserts the absence rather than leaving it to habit.
43
+ - **A partial, stated honestly: on-disk co-tenant reads.** The instance account is added to the app-user group (`usermod -aG`) so it can read the checkout it runs from — without it the unit fails at exec with `EACCES`. That grants exactly the read the app user's group already had, so **file-level** co-tenant separation is not what this change delivers; the process and database boundaries are. Narrowing it further means per-instance ownership of the checkout, which belongs with the shared-checkout question, not here.
44
+ - **A generated secret is only as private as the narrowest channel it crosses.** Both leaks above were found by the ship grade, not by the design, and neither was a typo: each was a call site that never opted into a redaction mechanism the codebase already had. The regression guard is written as an **invariant** rather than an assertion about one line — `provisionInstance` is run and *every* command embedding a dollar-quoted secret must carry a clean `shown` — so the next secret-bearing call site is caught by the same test.
45
+ - **Re-provision is the rotation path**, and it is idempotent throughout: `id -u` short-circuits an existing account, `install -d` re-asserts the state dir, and the role DDL upserts. A re-provision repairs a hand-edited box rather than failing on it.
46
+ - **`provision.js` sits two lines under the 1,500-line ratchet.** The generators and their full rationale live in `provision-repo.js`; the runner carries the wiring and a pointer. The next change there will have to move something out.
47
+ - **The non-privileged path is unchanged in kind.** The DDL escalates through the same `asPostgres()` helper ADR 0128 introduced, so a runner without `PROVISION_SUDO` renders the same commands without `sudo` — as broken, and no more broken, than it already was for `createdb`.
48
+
49
+ ## Alternatives considered
50
+
51
+ - **A `DATABASE_URL` in `web.env` instead of discrete `PG*` vars.** `pool.js` already prefers it, so it would have needed no pool change at all. Rejected: the socket path has to ride as `?host=…` or a percent-encoded authority, and the exact parse is `pg-connection-string`'s business rather than something the test could pin — trading a stated, assertable identity for a URL whose behavior we would be trusting.
52
+ - **Per-role passwords without `REVOKE CONNECT … FROM PUBLIC`.** Rejected on the mechanism: it reads like isolation and is not, because peer auth plus the default `PUBLIC` grant walks straight around the password.
53
+ - **`ProtectSystem=full`, or `strict` with the working directory writable.** Rejected: the first leaves everything outside `/usr` writable, and the second makes the code the service executes writable by the service — an obvious foothold in a change whose whole subject is containment.
54
+ - **Keeping `User=lars` and isolating with a second mechanism** (a PG role alone, or namespaces). Rejected: the shared uid *is* the `/proc` vector; nothing layered on top removes it.
55
+ - **Generating the dedicated droplet’s password on the droplet** (`openssl rand` inside a `runcmd`), instead of `peerOnly`. Rejected: it would have to be written into a `web.env` that cloud-init does not currently produce at all, so it buys a password for a single-tenant box by adding an untested file-writing leg to the one path ADR 0128 explicitly left alone.
56
+ - **Storing the generated password in `provisioning_instances`.** Rejected: it would put a live credential in the control-plane database in plaintext to buy stability the rotation does not need — the role is upserted and the env file rewritten in the same pass, so nothing needs to remember it.
@@ -372,3 +372,4 @@ This keeps the decision history honest and traceable.
372
372
  | 0278 | [**A gated project still takes applications, and the exemption is scoped to the verb** ([task 1003525](https://cloudbongos.com/builders#/task/1003525) · the apply write itself in [task 1003624](https://cloudbongos.com/builders#/task/1003624) · goal 1000106 — *Working area 1, Project creation*; owner decision 2026-09-11). A project's owner sets who may SEE it (`platformVisibility`, [ADR 0192](<redacted>.md)) and who may JOIN it (`joinability`, [ADR 0194](<redacted>.md)) independently — and set to their middle values, members-only AND apply-to-join, the project took no applications at all: the member door refused every cookie-less request with `401` before the public `POST <api>/access-requests` could answer, because that write was not on the exempt list. The two settings composed into **"nobody can apply"**, which nobody chose. It survived because nothing LIED about it — the hub's join box relayed the project's own `401` honestly as `members_only`, and the hall's landing, where the apply form lives, is itself behind the door; the composition was simply unreachable. Found by the R14 proof ([task 1002333](https://cloudbongos.com/builders#/task/1002333)). ADR 0192 §3 had fixed the exempt list at "the door, the manifest, the probes and the downloads — and nothing wider" and left widening it as an owner call, which is what this is. **Decision: yes — and BOTH halves are exempted, each scoped to one path and one verb.** `POST <api>/access-requests` (it grants nothing — an application is a row in a queue the owner still reviews, [ADR 0201](<redacted>.md), already public on every non-gated project) and `GET <api>/access-requests/status` (without it the answer is half an answer: `bongos login` cannot re-poll the device flow after a `not_approved` — the `device_code` is spent — so an applicant would file a request and then wait on an approval they can never observe). `EXEMPT` entries may now be `{ re, methods }` beside the bare `RegExp`s, and `isExempt(path, method)` takes the verb as an OPTIONAL second argument that **fails closed** for a scoped entry when none is given, so the one-argument static callers cannot accidentally widen. **The verb is load-bearing, not tidiness:** the bare `GET` on `<api>/access-requests` is the OWNER'S QUEUE (`requireBuilder` + `access_request.review`), the surface listing would-be builders by name with their vouch state — a path-only exemption would have silently taken the member door off the front of it, leaving one layer where there were two, and the queue's own `requirePermission` still holding is exactly what makes that loss easy to miss. **It opens no oracle the gate was closing:** the status route's boolean twin `GET <api>/auth/web/admission-status` is ALREADY reachable on a gated project inside the `auth/*` subtree §3 exempts whole (§3 records that cost in as many words), and the two share ONE per-IP budget on purpose ([ADR 0209](<redacted>.md)) so neither can be alternated against the other. What it DOES add, stated as the honest cost: applicant detail — `pending`/`dismissed`/`none` over the twin's bare `admitted`. Whether that answer should collapse is ADR 0209's still-open owner question and is deliberately NOT decided here. No hub change: `joinRelayOutcome` maps the RELAYED status, so it carries the project's real answer the moment the `401` stops. Rejected: "gated means gated" — hide *Apply to join* and say so in the manage blurb (coherent, and the call went the other way); exempting the path without the verb; exempting the write alone; collapsing the status response while the route happened to be open (that is how a deferred decision gets made by accident).](<redacted>.md) | project visibility / join door / member door |
373
373
  | 0279 | [**An upgrade is proven by the served version, not by a health check** ([task 1002884](https://cloudbongos.com/builders#/task/1002884) · goal 1000090 — *Working area 4, Bongos Core distribution*; from idea 1000682). On 2026-08-11 the auto-upgrade sweep printed `✓ upgrade complete — core 1.19.13 → 1.19.56`, wrote a success row to `core_upgrades` and exited 0 while live kept serving **1.19.13**. Three shipped checks formed a closed loop that could not see the failure they existed to catch: the `systemctl restart` failed with `Interactive authentication required` (a `User=` unit, no TTY) and `upgrade.js` treated it as a WARNING and fell through; `readInstalledCoreVersion()` then confirmed the version on **disk**, where `npm install` had correctly put it; and `pollHealth()` got a 200 from the **still-running old process**, because a health check confirms a port is served, never *what* serves it. A lying tool is worse than a broken one — nothing goes looking. The damage outlived the incident: `subscribe.js` had already routed the unattended lane around `bongos upgrade` in favour of `go-live.js`, citing this false-pass in a comment. **Decision: a bump is confirmed by asking the running process what version it is.** (1) A failed restart enters the same auto-rollback path as a failed install/migrate/health and exits non-zero — rolling back rather than merely erroring keeps disk and process consistent, since disk-ahead-of-process is the state that made the incident invisible. (2) The served version is read back from `/version` (`coreVersion`, since 1.17.2, prelaunch-gate exempt), defaulting to the `--health-url` origin and overridable via `--version-url` — derived rather than opt-in because every existing call site passes only `--health-url`, and a check you must opt into is off exactly where it is needed. (3) A **mismatch** fails (proof of failure → roll back); an **unreadable** endpoint only warns (absence of proof — refusing every such bump would regress harder than the false-pass), except under an explicit `--version-url`, which asks for proof and therefore gets a failure. That strict mode is what the unattended subscription lane now passes, its roster already carrying the URL. **Escalation is `sudo -n`, not a hand-placed polkit rule:** `restartService()` retries a failed restart through `sudo -n` when not root — the idiom `dev.js`/`dev-lib.js` already use and consistent with `provision.js`, `upgrade.js` having been the one place that restarted without escalating. The live `/etc/polkit-1/rules.d/<redacted>.rules` mitigation is superseded: a rebuilt box inherits code, not hand-placed `/etc` files (polkit route kept in [`docs/recipes/instance-service-restart.md`](../recipes/instance-service-restart.md)). `--no-health-check` stays the single escape hatch and now waives the read-back too. Rejected: comparing `startedAt` (cannot distinguish a restart onto the same old core from one onto the new); making an unreadable endpoint fatal by default; requiring `--version-url` everywhere (absent from every current call site — the same "off where it matters" failure in a new costume); keeping polkit as the answer.](<redacted>.md) | core distribution / upgrade verification |
374
374
  | 0280 | [**The unattended lane may drive a co-tenant, because the roster entry IS the per-instance decision** ([task 1003843](https://cloudbongos.com/builders#/task/1003843) · goal 1000106; from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone). `subscribe.js` keeps a SECOND COPY of go-live's target contract, and it drifted: `GO_LIVE_REQUIRED` still listed `deployTimer` after 1003521 made it optional, so `goLiveTargetFor()` returned `missing: ['deployTimer']` for every provisioned co-tenant, and `rawTopologyFor()`'s fixed field list dropped `pinMode` before the target was built — the same stale-normalizer trap that function's own comment was written to fix, one field later. **1003521 deferred this as a policy judgement call; reading the fallback shows the premise was wrong.** `mapped.ok === false` never meant "skip this instance" — it meant "deploy it the WEAKER way": a bare `bongos upgrade`, no backup, no deploy-timer guard, and a pin COMMITTED AND PUSHED into the co-tenant's own repo, which is exactly what `pinMode` exists to prevent. The lane was already auto-deploying co-tenants; the only open question was whether it did so well. A THIRD defect sat behind the other two and would have turned the silent downgrade into a hard failure had only the first been fixed: the normalizer yields `deployTimer: null`, an explicit null SURVIVES `JSON.stringify` into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. A FOURTH sat PAST the go-live call and is what separates "reachable" from "safe": after any successful upgrade the lane runs its own pin net, `commitPin()`, which commits the pin files and PUSHES them to `origin` — a net that predates `pinMode`. Under `local-commit` go-live leaves the tree clean so it no-ops by luck; under **`leave-dirty`** the pin is in the working tree BY DESIGN, so the net committed it and pushed it into the customer's own repo — the lane honouring `pinMode` right up to the moment it undid it. **Decision: the lane may drive a co-tenant and needs no new gate, because listing an instance in `config/update-subscriptions.json` already IS the explicit per-instance operator decision** — the roster ships empty, nothing is touched until an operator lists it, and the routine is autonomy-gated (`requiresAutonomy: true`, default OFF) on top of that; a second consent gate asks the same person the same question twice. So: `GO_LIVE_REQUIRED` now MIRRORS go-live's `REQUIRED_FIELDS`, with a test reading BOTH source files and failing on drift; `deployTimer` moves to the optional-field loop so a co-tenant OMITS the key rather than carrying a null; `rawTopologyFor` carries `pinMode`; `resolveGoLive` gains a `needsCoTenant` probe leg; and `goLiveEngineFor()` makes the decision ONCE for both the dry run and the apply path, which had re-derived it separately. The pin net now obeys the same `pinMode` go-live was given — `commitPin()` takes `push` (default true, so the platform instance is unchanged), `local-commit` commits without pushing, `leave-dirty` skips the net, the rollback tidy follows the same rule, and the "not durable" advice stops telling an operator to `git push` a repo we do not own; its refusal to touch a worktree carrying NON-pin changes is unchanged in every mode. **The probe is CONDITIONAL on purpose** — demanding `pinMode` of every target would strip the go-live path from any instance between the `--target-json` release and 1003521's, trading a live regression for a hypothetical one. Rejected: a second consent gate (an `allowCoTenant` flag); deferring to the unspecced fleet control plane ([task 1001948](https://cloudbongos.com/builders#/task/1001948)) — which does not leave co-tenants alone, it leaves them pushing pins into customer repos; dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else (the fix as proposed — trades a silent downgrade for a hard rejection); probing unconditionally; reaching co-tenant mode WITHOUT teaching the durability net about it (nominal support that breaks its own promise seconds later); and teaching `loadSubscriptions()` about `pinMode` instead, since that normalizer ships in the control plane's vendored core that nobody bumps — the exact trap `rawTopologyFor` routes around.](<redacted>.md) | core distribution / unattended deploy |
375
+ | 0281 | [**An instance’s identity is its own unix account and its own password-authed Postgres role** ([task 1003369](https://cloudbongos.com/builders#/task/1003369) · goal 1000094, audit ref `B3` of the 2026-08-29 security audit). [ADR 0111](<redacted>.md) made standup automatic and [ADR 0128](<redacted>.md) settled how the RUNNER escalates; neither settled who the INSTANCE is once it runs, and the answer was that everybody was the same person. `serviceUnit` hardcoded `User=lars`, and **co-tenant is the default shape** — so on a shared box every instance’s process ran under one uid, and `pool.js` connected by **passwordless peer auth**. Two consequences, neither a bug in any route: a unix user may read `/proc/<pid>/environ` of its own processes, so every instance could read every sibling’s `GITHUB_APP_PRIVATE_KEY`, hub client secret and OAuth secret; and peer auth answers “which unix user are you”, so any code as `lars` could open a pool against **any** database on the box by naming it — including the control plane’s own `builders` and `builder_sessions`. The `0600` `web.env` and the per-instance DB were already correct: sound walls with one door, and every instance had the key. **Decision: each instance gets its own unix account and its own password-authed PG role, both derived from its slug, both created by the provision run.** The account is `bongos-<slug>`, hash-folded past utmp’s 32 chars — prefixed because a project may legally be called `postgres`, and hash-suffixed because two slugs sharing a 16-char prefix would otherwise fold onto ONE account, which is the shared identity being removed. Its home is `/var/lib/<user>` and deliberately **not** under `/home`, since the unit sets `ProtectHome=read-only` and the server writes `builder-secret.key` inside its config home on first boot — a `/home` account would make that `EROFS`. The unit gains `ProtectSystem=strict` / `ProtectHome=read-only` / `PrivateTmp=true` with `ReadWritePaths` as the whole allow-list; **the checkout the service runs is deliberately not writable by it**. The role is `LOGIN` + password, owns its own DB, and is NOT superuser/CREATEDB/CREATEROLE — with **`REVOKE CONNECT … FROM PUBLIC` as the half that actually isolates**, because PG grants `CONNECT` to `PUBLIC` by default and per-role passwords alone leave a sibling one peer-auth hop away. Grants run in a SECOND statement **after** the migrate, since the privileged migrate runs as `postgres` and owns every table it creates; `ALTER DEFAULT PRIVILEGES` aims the same grant forward. The password is minted per run and never stored — the same run sets it on the role and rewrites the `0600` env file, so **every provision rotates it** — and `pool.js` reads `PGUSER`/`PGPASSWORD` **explicitly** rather than via `pg`’s env defaults, because the whole defect was a connection whose identity nothing in the code stated. **Absence is the old behavior:** no password ⇒ no credential lines and no pool keys, so an instance provisioned before this keeps its peer auth and the rollout cannot brick a box. Two consequences worth the record. (1) **The generated SQL contains no double quote, and that is load-bearing** — every literal is dollar-quoted and every identifier goes through `format(%I)`, because these commands are embedded in the dedicated droplet’s cloud-init as `- [ bash, -lc, "…" ]`, a YAML DOUBLE-QUOTED scalar: a plain `"role"` identifier reads fine in `psql` and silently truncates the line. A test asserts the absence rather than leaving it to habit. (2) **A partial, stated honestly** — the account is added to the app-user group so it can read the checkout it runs from (without it the unit fails at exec with `EACCES`), so FILE-level co-tenant separation is not what this delivers; the process and database boundaries are. Rejected: a `DATABASE_URL` in `web.env` (needs no pool change, but trades a stated, assertable identity for a socket-path parse that is `pg-connection-string`’s business); per-role passwords WITHOUT the `PUBLIC` revoke (reads like isolation, and peer auth walks around it); `ProtectSystem=full`, or `strict` with the working directory writable (makes the code the service executes writable by the service, in a change whose subject is containment); keeping `User=lars` and isolating with a second mechanism (the shared uid IS the `/proc` vector); storing the password in `provisioning_instances` (a live credential in plaintext, to buy stability the rotation does not need).](<redacted>.md) | tenant isolation / provisioning / DB auth |
@@ -1875,5 +1875,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1875
1875
  landed since 1.19.706 with no explicit bump. run 34741980536. (task 1002620)
1876
1876
  1.19.708 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1877
1877
  landed since 1.19.707 with no explicit bump. run 34742245823. (task 1002620)
1878
+ 1.19.709 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1879
+ landed since 1.19.708 with no explicit bump. run 34743928060. (task 1002620)
1880
+ 1.19.710 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1881
+ landed since 1.19.709 with no explicit bump. run 34744369035. (task 1002620)
1878
1882
  ---------------------------------------------------------------------------
1879
1883
  ```
@@ -25,6 +25,20 @@ Organized by topic, not chronology. New entries go in the matching section; crea
25
25
  - **Why it bites:** The `pg` library's defaults assume a standard TCP+password setup, which is the most common configuration but not the cheapest one for a single-machine deploy. Peer auth is more secure and frictionless once you know the socket path. The error message points at SASL, not at the host setting, which buries the real cause.
26
26
  - **Where this lives in the codebase:** [`src/db.js`](../../src/db.js) has the explicit socket host.
27
27
 
28
+ ### Re-provisioning an EXISTING instance moves its `HOME`, orphaning any stored builder secrets
29
+
30
+ - **Symptom:** after a re-provision, an instance that had builder API keys stored can no longer decrypt them. Nothing errors at startup — the instance comes up healthy and simply behaves as though no key was ever saved.
31
+ - **Root cause:** task [1003369](https://cloudbongos.com/builders#/task/1003369) ([ADR 0281](../adr/<redacted>.md)) gives each instance its own unix account with `HOME=/var/lib/bongos-<slug>`. [`src/bongos/secret-box.js`](../../src/bongos/secret-box.js) keeps its master key at `~/.config/<configDir>/builder-secret.key`, so the key an instance wrote under the OLD shared home (`/home/lars/.config/…`) is no longer on its `HOME` path. `provisionMasterKey()` finds nothing, mints a fresh key, and every previously encrypted secret is now undecryptable ciphertext.
32
+ - **Who this hits:** only a **re-provision of an instance that has already stored secrets**. A fresh standup has nothing to lose, and an instance whose `web.env` sets `BUILDER_SECRET_KEY` is unaffected — that env wins over the file.
33
+ - **Fix (before the re-provision, one command as root):**
34
+ ```bash
35
+ install -D -o bongos-<slug> -g bongos-<slug> -m 600 \
36
+ /home/lars/.config/<configDir>/builder-secret.key \
37
+ /var/lib/bongos-<slug>/.config/<configDir>/builder-secret.key
38
+ ```
39
+ `<configDir>` is the instance’s `branding.configDir` (vanilla: `cloudbongos`). Do it **first** — `provisionMasterKey()` writes with the `wx` flag and refuses to clobber, so a key placed after the instance has already minted its own will not be picked up.
40
+ - **Why it bites:** the failure is silent on both ends. The old key is not deleted, the new one is valid, and the only visible symptom is secrets that quietly stopped working.
41
+
28
42
  ---
29
43
 
30
44
  ## Colyseus
@@ -0,0 +1,67 @@
1
+ -- core_239 — claimable_tasks must filter on the blocker being OPEN, not on the
2
+ -- link row existing (task 1003863).
3
+ --
4
+ -- THE DEFECT. core_231's claimable_tasks excludes a task if ANY task_blockers row
5
+ -- exists for it, with no check on the blocker's status:
6
+ --
7
+ -- AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
8
+ --
9
+ -- Nothing ever deletes that link: there is no `DELETE FROM task_blockers` in
10
+ -- migrations/ or modules/. So when core_223's trg_promote_tasks_on_blocker_resolve
11
+ -- flips a task 'blocked' -> 'ready' on its last blocker resolving, the link row
12
+ -- survives and this view keeps hiding the task. A task that has ever been blocked
13
+ -- is promoted to ready and then never appears in /builder-start again.
14
+ --
15
+ -- It stays claimable BY ID — modules/lifecycle/db-claims.js checks b.status='open'
16
+ -- correctly (task 1002650) — so the row is workable but unfindable. Same family as
17
+ -- bug 1003553, inverted: there the feed advertises what the claim path refuses;
18
+ -- here the claim path accepts what the feed hides.
19
+ --
20
+ -- LATENT, NOT HARMLESS. Every blocker on the instance was status='open' when this
21
+ -- was written, so the bad branch had never executed. Eleven tasks were linked to
22
+ -- open blockers at that moment, and each would have vanished from the queue the
23
+ -- moment its blocker resolved — including task 1001440 behind blocker 1000144.
24
+ --
25
+ -- THE LEFT JOIN IS LOAD-BEARING. Legacy markdown-anchor links carry
26
+ -- task_blockers.blocker_ref with NO blockers row (core_223's header, same scope as
27
+ -- 176). An inner join would drop those rows from the NOT EXISTS and thereby
28
+ -- UNHIDE their tasks — a silent widening of the feed. `LEFT JOIN` plus
29
+ -- `b.id IS NULL OR b.status = 'open'` keeps an unresolvable legacy link blocking,
30
+ -- which is exactly today's behaviour. Fail closed on anything we cannot resolve.
31
+ --
32
+ -- Scope: this view only. The OTHER gap in the same view — it never filters
33
+ -- unshipped dependencies either (finding #60 of task 1000379) — is a separate
34
+ -- clause with a separate fix and is deliberately NOT bundled here.
35
+ --
36
+ -- Idempotent: DROP VIEW IF EXISTS + CREATE, the same shape core_231 used. The
37
+ -- column list is `t.*` plus the calibrated-minutes column, byte-identical to
38
+ -- core_231 so the wildcard re-expands to every current column (core_227's header
39
+ -- records six columns that silently never reached the feed by diverging here).
40
+
41
+ BEGIN;
42
+
43
+ DROP VIEW IF EXISTS claimable_tasks;
44
+ CREATE VIEW claimable_tasks AS
45
+ SELECT t.*,
46
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
47
+ FROM tasks t
48
+ WHERE t.status = 'ready'
49
+ AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
50
+ AND NOT EXISTS (
51
+ SELECT 1
52
+ FROM task_blockers tb
53
+ LEFT JOIN blockers b ON b.id = tb.blocker_id
54
+ WHERE tb.task_id = t.id
55
+ AND (b.id IS NULL OR b.status = 'open')
56
+ )
57
+ AND NOT EXISTS (
58
+ SELECT 1 FROM claims c
59
+ JOIN tasks t2 ON t2.id = c.task_id
60
+ WHERE c.released_at IS NULL
61
+ AND t2.touches && t.touches
62
+ );
63
+
64
+ COMMIT;
65
+
66
+ INSERT INTO schema_migrations (version) VALUES ('core_239_claimable_respects_blocker_status')
67
+ ON CONFLICT (version) DO NOTHING;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.708",
3
+ "version": "1.19.710",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.708",
9
+ "version": "1.19.710",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.708",
3
+ "version": "1.19.710",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10,6 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
+ const crypto = require('node:crypto');
13
14
  const fs = require('node:fs');
14
15
  const os = require('node:os');
15
16
  const path = require('node:path');
@@ -111,6 +112,177 @@ function standaloneMigrateCmd(inst, { privileged = false } = {}) {
111
112
  // transfer from primea"): its bespoke pull-deploy regenerated nothing.
112
113
  function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/regen-instance-docs.js`; }
113
114
 
115
+ // ── Per-instance identity: its OWN unix account + its OWN password-authed PG role ──
116
+ // (task 1003369, audit ref B3 of the 2026-08-29 security audit.)
117
+ //
118
+ // THE HOLE THIS CLOSES. Every instance service used to run `User=lars/Group=lars`, and
119
+ // co-tenant is the DEFAULT shape — so on a shared box every instance ran as the SAME
120
+ // unix user. A unix user may read /proc/<pid>/environ of its own processes, which means
121
+ // any instance could read every SIBLING's environment: GITHUB_APP_PRIVATE_KEY, the
122
+ // <PREFIX>_HUB_CLIENT_SECRET, every OAuth secret web.env carries. The DB half was the
123
+ // same shape one layer down: pool.js connected by PASSWORDLESS peer auth, so any code
124
+ // running as lars could open a pool against any sibling database — including the control
125
+ // plane's own `builders` and `builder_sessions`. Neither was a bug in a route; it was the
126
+ // identity every instance shared. Giving each instance its own unix account and its own
127
+ // password-authed role is what makes the boundary real.
128
+ //
129
+ // NAME SHAPE. A unix account name is capped at 32 chars (utmp) and must match
130
+ // [a-z_][a-z0-9_-]*; a slug is 2–40 of [a-z0-9-] (isValidSlug). So the name cannot be
131
+ // the slug verbatim, and it must not be the BARE slug either — that would let a project
132
+ // named `postgres` or `lars` claim an existing account. Prefixed + length-folded:
133
+ // short slug → bongos-<slug>
134
+ // long slug → bongos-<slug truncated>-<8 hex of sha256(slug)> (still <= 32)
135
+ // The hash suffix keeps two slugs that share a 16-char prefix from folding onto one
136
+ // account — which would hand each the other's identity, the very thing being fixed.
137
+ const INSTANCE_USER_PREFIX = 'bongos-';
138
+ const INSTANCE_USER_MAX = 32; // utmp's cap; useradd enforces it
139
+
140
+ // The unix account an instance's service (and only that instance's service) runs as.
141
+ // Deterministic — the runner derives it fresh every run rather than storing it, so a
142
+ // re-provision can never address a different account than the one it created. PURE.
143
+ function instanceUser(inst) {
144
+ const slug = String((inst && inst.slug) || '');
145
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug)) {
146
+ throw new Error(`instanceUser: refusing to derive an account name from an invalid slug ${JSON.stringify(slug)}`);
147
+ }
148
+ const whole = INSTANCE_USER_PREFIX + slug;
149
+ if (whole.length <= INSTANCE_USER_MAX) return whole;
150
+ const digest = crypto.createHash('sha256').update(slug).digest('hex').slice(0, 8);
151
+ // Budget: prefix + head + '-' + 8 hex === INSTANCE_USER_MAX. Trailing dashes are
152
+ // stripped so the fold can never emit `--` (which useradd's NAME_REGEX rejects).
153
+ const head = slug.slice(0, INSTANCE_USER_MAX - INSTANCE_USER_PREFIX.length - 9).replace(/-+$/, '');
154
+ return `${INSTANCE_USER_PREFIX}${head}-${digest}`;
155
+ }
156
+
157
+ // The instance account's HOME — and the ONE directory the hardened unit leaves
158
+ // writable. It is deliberately NOT under /home: the unit sets ProtectHome=read-only
159
+ // (so one instance cannot read the operator's or a sibling's home), and the server DOES
160
+ // write inside its config home at runtime — src/bongos/secret-box.js provisions
161
+ // builder-secret.key under ~/.config/<configDir> on first boot. A /home account would
162
+ // make that write EROFS and the instance would come up unable to hold a secret. PURE.
163
+ function instanceStateDir(inst) { return path.posix.join('/var/lib', instanceUser(inst)); }
164
+
165
+ // The instance's OWN Postgres login role. Same name as the unix account so an operator
166
+ // reading `ps` and `pg_stat_activity` sees one identity, not two — a dash is legal in a
167
+ // quoted PG identifier, and every SQL site below quotes it. PURE.
168
+ function instanceDbRole(inst) { return instanceUser(inst); }
169
+
170
+ // A fresh password for that role. base64url ([A-Za-z0-9_-]) on purpose: it needs no
171
+ // escaping in the env file that carries it, in the dollar-quoted SQL literal that sets
172
+ // it, or in a connection URL — the three places it travels. 32 bytes ~ 256 bits.
173
+ // Generated per provision run, not stored: every run ALTERs the role and rewrites
174
+ // web.env in the same pass, so the credential rotates on its own.
175
+ function generateDbPassword() { return crypto.randomBytes(32).toString('base64url'); }
176
+
177
+ // Create the instance's unix account if it is not already there, with its state dir.
178
+ // IDEMPOTENT by construction — `id -u` short-circuits an existing account, and the
179
+ // `install -d` re-asserts ownership and mode on every run, so a re-provision repairs a
180
+ // hand-edited box instead of failing on it. A SYSTEM account with /usr/sbin/nologin:
181
+ // nothing should ever log in as an instance.
182
+ function ensureInstanceUserCmd(inst, { privileged = false } = {}) {
183
+ const user = instanceUser(inst), dir = instanceStateDir(inst), sudo = privileged ? 'sudo ' : '';
184
+ return `id -u ${user} >/dev/null 2>&1 || ${sudo}useradd --system --shell /usr/sbin/nologin --home-dir ${dir} ${user}; ` +
185
+ `${sudo}install -d -o ${user} -g ${user} -m 0700 ${dir}`;
186
+ }
187
+
188
+ // Let the instance account READ the checkout it runs from. The co-tenant shape's
189
+ // WorkingDirectory is this control-plane checkout and the standalone shape's is
190
+ // /srv/<base>/<slug>; both are owned by the app user, and a fresh system account is in
191
+ // neither the owner nor the group, so without this the unit fails at exec with EACCES.
192
+ // Group membership grants exactly the read the app user's group already has — it does
193
+ // NOT re-open the two vectors this task closes: /proc/<pid>/environ stays readable only
194
+ // by the process's own uid, and the DB now demands a password no sibling holds.
195
+ function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
196
+ return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
197
+ }
198
+
199
+ // Create-or-rotate the instance's PG login role and hand it its OWN database — and
200
+ // nothing else. Three statements, one psql, ON_ERROR_STOP:
201
+ // 1. CREATE ROLE ... LOGIN PASSWORD, or ALTER ... PASSWORD when it already exists (the
202
+ // re-provision path — this is what rotates the credential).
203
+ // 2. ALTER DATABASE ... OWNER TO — the role owns its own DB, so the app's DDL needs no
204
+ // superuser, and in PG15+ it inherits the public schema through pg_database_owner.
205
+ // 3. REVOKE CONNECT ... FROM PUBLIC — the half that actually isolates. Postgres grants
206
+ // CONNECT on every database to PUBLIC by default, so per-role passwords ALONE would
207
+ // still leave a sibling one peer-auth hop from any other instance's data.
208
+ // NOT a superuser and NOT CREATEDB/CREATEROLE: an instance that is compromised gets its
209
+ // own data and no path to anyone else's.
210
+ //
211
+ // Runs against `postgres`, not the instance DB: ALTER DATABASE ... OWNER cannot run from
212
+ // inside the database it renames the owner of.
213
+ //
214
+ // QUOTING, which is load-bearing in a way that is easy to undo by accident. Every literal
215
+ // is DOLLAR-QUOTED and every identifier goes through format(%I), so the rendered command
216
+ // contains no single quote and NO DOUBLE QUOTE. It has to survive three executors
217
+ // unchanged — inline /bin/sh, `bash -s` over SSH, and, the strict one, `- [ bash, -lc,
218
+ // "..." ]` inside the dedicated droplet's cloud-init, which is a YAML DOUBLE-QUOTED
219
+ // scalar. A plain "role" identifier reads fine in psql and silently truncates the YAML.
220
+ // seedFirstVersionCmd dollar-quotes for the same reason; a test asserts the absence.
221
+ // `peerOnly` is the DEDICATED shape's mode, and it exists because of where that
222
+ // command TRAVELS (task 1003369). A dedicated droplet's steps ride DigitalOcean
223
+ // cloud-init user-data, which DO RETAINS and serves back through its API, its console
224
+ // and the droplet's own metadata endpoint — so a password embedded there is a second,
225
+ // permanent copy of the credential in a place this task cannot lock down, which is
226
+ // strictly worse than what it protects against. A dedicated droplet is SINGLE-TENANT:
227
+ // there are no siblings on it, so the co-tenancy vector B3 describes does not exist,
228
+ // and the role can peer-auth to its own unix account. The role, its DB ownership and
229
+ // the PUBLIC revoke are still created — only the password is omitted, deliberately.
230
+ function dbRoleCmd(inst, { password = null, peerOnly = false, privileged = false } = {}) {
231
+ const role = instanceDbRole(inst), db = dbName(inst);
232
+ if (peerOnly) {
233
+ if (password) throw new Error('dbRoleCmd: peerOnly takes no password — it exists to keep one out of cloud-init user-data');
234
+ } else if (!/^[A-Za-z0-9_-]+$/.test(String(password || ''))) {
235
+ throw new Error('dbRoleCmd: password must be a non-empty base64url string (generateDbPassword)');
236
+ }
237
+ // Two shapes of the same upsert. peerOnly omits the PASSWORD clause entirely rather
238
+ // than passing an empty one — `WITH LOGIN` leaves any existing password untouched.
239
+ const login = peerOnly
240
+ ? { clause: 'WITH LOGIN', args: '' }
241
+ : { clause: 'WITH LOGIN PASSWORD %L', args: `, $p$${password}$p$` };
242
+ const sql =
243
+ `DO $do$ BEGIN ` +
244
+ `IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $r$${role}$r$) THEN ` +
245
+ `EXECUTE format($f$ALTER ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
246
+ `ELSE ` +
247
+ `EXECUTE format($f$CREATE ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
248
+ `END IF; ` +
249
+ `EXECUTE format($f$ALTER DATABASE %I OWNER TO %I$f$, $d$${db}$d$, $r$${role}$r$); ` +
250
+ `EXECUTE format($f$REVOKE CONNECT ON DATABASE %I FROM PUBLIC$f$, $d$${db}$d$); ` +
251
+ `END $do$;`;
252
+ return asPostgres(`psql -v ON_ERROR_STOP=1 -d postgres -c '${sql}'`, privileged);
253
+ }
254
+
255
+ // What the runner LOGS in place of dbRoleCmd, whose SQL embeds the password. Lives here,
256
+ // beside the command, so the two cannot drift: a `shown` that outlived the secret it hides
257
+ // would be a silent re-leak. Names every effect the real command has. PURE.
258
+ function dbRoleCmdShown(inst) {
259
+ return `psql -d postgres -c '<upsert role ${instanceDbRole(inst)} + own ${dbName(inst)} + revoke PUBLIC connect>' (password redacted)`;
260
+ }
261
+
262
+ // Hand the instance role the objects the migrate just created. This is NOT belt-and-braces:
263
+ // the privileged migrate runs as `postgres`, so every table and sequence it creates is
264
+ // OWNED BY postgres. Owning the DATABASE does not carry ownership of objects inside it, so
265
+ // without these grants the instance would authenticate perfectly and then fail its first
266
+ // SELECT. The ALTER DEFAULT PRIVILEGES pair is the same statement aimed forward: it applies
267
+ // to objects the CURRENT role creates later, which is exactly what the next migrate does —
268
+ // so a schema migration shipped six months from now needs no second visit here.
269
+ //
270
+ // Runs against the instance DB (schema grants are per-database), and AFTER the migrate.
271
+ function dbRoleGrantsCmd(inst, { privileged = false } = {}) {
272
+ const role = instanceDbRole(inst), db = dbName(inst);
273
+ // Same quoting rule as dbRoleCmd: format(%I), never a double-quoted identifier.
274
+ const g = (stmt) => `EXECUTE format($f$${stmt} %I$f$, $r$${role}$r$); `;
275
+ const sql =
276
+ `DO $do$ BEGIN ` +
277
+ g('GRANT ALL ON SCHEMA public TO') +
278
+ g('GRANT ALL ON ALL TABLES IN SCHEMA public TO') +
279
+ g('GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO') +
280
+ g('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO') +
281
+ g('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO') +
282
+ `END $do$;`;
283
+ return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${db} -c '${sql}'`, privileged);
284
+ }
285
+
114
286
  // ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
115
287
  // The MISSING first leg of the greenfield "one guided flow": provision.js used to
116
288
  // THROW if the instance repo wasn't already cloned. These generate + run the scaffold
@@ -831,13 +1003,22 @@ module.exports = {
831
1003
  buildCorePinRefreshCommit,
832
1004
  dbCreateCmd,
833
1005
  dbName,
1006
+ dbRoleCmd,
1007
+ dbRoleCmdShown,
1008
+ dbRoleGrantsCmd,
834
1009
  deployKeyPath,
835
1010
  deployKeyTitle,
1011
+ ensureInstanceUserCmd,
836
1012
  ensurePrivateRepoAccess,
1013
+ generateDbPassword,
1014
+ grantInstanceRepoReadCmd,
837
1015
  installedCorePackDir,
838
1016
  installedCoreTarball,
1017
+ instanceDbRole,
839
1018
  instanceInitSpec,
840
1019
  instanceRepoRemote,
1020
+ instanceStateDir,
1021
+ instanceUser,
841
1022
  migrateCmd,
842
1023
  onboardMode,
843
1024
  parseTargetRef,
@@ -11,10 +11,11 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
- const { APP_USER, CORE_PKG_DIR, dbName, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
14
+ const { APP_USER, CORE_PKG_DIR, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
15
15
 
16
16
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
17
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
18
+ const user = instanceUser(inst), stateDir = instanceStateDir(inst);
18
19
  const standalone = inst.hosting_shape === 'standalone';
19
20
  const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
20
21
  const execStart = standalone
@@ -28,10 +29,21 @@ Requires=postgresql.service
28
29
 
29
30
  [Service]
30
31
  Type=simple
31
- User=lars
32
- Group=lars
32
+ # This instance's OWN unix account, never the shared app user (task 1003369, audit B3).
33
+ # Co-tenant is the default shape, so a shared User= let every instance read every
34
+ # sibling's /proc/<pid>/environ — GITHUB_APP_PRIVATE_KEY, the hub client secret, every
35
+ # OAuth cred in the EnvironmentFile below. A distinct uid is what closes that: /proc
36
+ # environ is readable only by the process's own uid.
37
+ User=${user}
38
+ Group=${user}
33
39
  WorkingDirectory=${workDir}
40
+ # Read by PID 1 as root BEFORE the privilege drop, so 0600 root-owned stays correct —
41
+ # the instance account itself never needs read on its own secrets file.
34
42
  EnvironmentFile=${envFile}
43
+ # HOME is the state dir, NOT /home: the server writes inside its config home on first
44
+ # boot (secret-box.js provisions builder-secret.key there) and ProtectHome below makes
45
+ # /home read-only. Without this the instance comes up unable to hold a secret.
46
+ Environment=HOME=${stateDir}
35
47
  Environment=PGDATABASE=${dbName(inst)}
36
48
  Environment=PORT=${inst.port}
37
49
  ExecStart=${execStart}
@@ -40,6 +52,15 @@ RestartSec=3
40
52
  StandardOutput=journal
41
53
  StandardError=journal
42
54
  NoNewPrivileges=true
55
+ # The same hardening the sibling backup unit already carries. ReadWritePaths is the
56
+ # whole allow-list under ProtectSystem=strict: the state dir, plus the instance's own
57
+ # config/ (the modules toggle writes config/modules.json at runtime). The checkout the
58
+ # service RUNS is deliberately not writable by it. The leading dash on config/ keeps a
59
+ # shape that has none from failing to mount rather than failing to start.
60
+ ProtectSystem=strict
61
+ ProtectHome=read-only
62
+ PrivateTmp=true
63
+ ReadWritePaths=${stateDir} -${workDir}/config
43
64
 
44
65
  [Install]
45
66
  WantedBy=multi-user.target
@@ -168,7 +189,7 @@ function provisionedEnvPrefix() {
168
189
  // the hall on this host + OAuth uses the real callback origin — without them the pack
169
190
  // stays at neutral localhost and the hall never loads (task 1972). No domain yet
170
191
  // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
171
- function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null, ownerLogin = null } = {}) {
192
+ function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null, ownerLogin = null, dbPassword = null } = {}) {
172
193
  const prefix = provisionedEnvPrefix(), { settingsEnvLines } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
173
194
  const origin = inst.domain ? `https://${inst.domain}` : '';
174
195
  const originLines = origin
@@ -239,13 +260,20 @@ GITHUB_APP_ID=${appId}
239
260
  GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
240
261
  `
241
262
  : '';
263
+ // The per-instance DB credential (task 1003369, audit B3). Present only once the
264
+ // runner has created the role — a first dry-run and any legacy caller omit it, and the
265
+ // instance then falls back to the peer auth it used before, so this can never brick a
266
+ // box mid-migration. src/bongos/pool.js reads both names explicitly.
267
+ const dbLines = dbPassword
268
+ ? `PGUSER=${instanceDbRole(inst)}\nPGPASSWORD=${dbPassword}\n`
269
+ : '';
242
270
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
243
271
  ${credComment}
244
272
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
245
273
  ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
246
274
  ${firstAdminLine}${originLines}${appLines}PORT=${inst.port}
247
275
  PGDATABASE=${dbName(inst)}
248
- ${fedLines}${settingsEnvLines(inst, prefix)}`;
276
+ ${dbLines}${fedLines}${settingsEnvLines(inst, prefix)}`;
249
277
  }
250
278
  function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
251
279
  return path.posix.join(etcBase, inst.slug, 'web.env');
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
52
  const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
- const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
53
+ const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
 
@@ -232,6 +232,9 @@ async function provisionInstance(inst, deps) {
232
232
  const boxExec = standalone ? controlExec : exec;
233
233
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
234
234
  const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
235
+ // One fresh per-instance DB password per run (task 1003369, see provision-repo.js): step
236
+ // 2b sets it on the role, step 3 writes it to web.env, so the two cannot disagree.
237
+ const dbPassword = generateDbPassword();
235
238
  log(`provision ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
236
239
 
237
240
  // Idempotency: an already-active instance is a no-op success (re-drained intent).
@@ -374,6 +377,15 @@ async function provisionInstance(inst, deps) {
374
377
  else log(' [code] would refresh the committed @bongos/core pin if it trails the running core (task 1003050)');
375
378
  boxExec(standaloneInstallCmd(), { cwd: root });
376
379
  }
380
+ // ── step 1c: the instance's OWN unix account (task 1003369, audit B3) ─────
381
+ // Idempotent; the group-add is what lets a fresh system account READ the checkout it runs
382
+ // from — without it the unit fails at exec with EACCES. Rationale: provision-repo.js.
383
+ if (local) {
384
+ log(` [user] ensure unix account ${instanceUser(inst)} + its state dir`);
385
+ boxExec(ensureInstanceUserCmd(inst, { privileged }));
386
+ boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
387
+ } else { log(' (instance unix account created via cloud-init on the droplet)'); }
388
+
377
389
  log(' [db] create + GDS-only migrate');
378
390
  if (local) {
379
391
  boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
@@ -395,9 +407,21 @@ async function provisionInstance(inst, deps) {
395
407
  }
396
408
  } else { log(' (db create + migrate + first-version seed run via cloud-init on the droplet)'); }
397
409
 
410
+ // ── step 2b: the instance's OWN password-authed PG role (task 1003369, audit B3) ──
411
+ // AFTER the migrate: it runs as `postgres`, which therefore OWNS the tables it creates.
412
+ if (local) {
413
+ log(' [db] create/rotate the per-instance role + revoke PUBLIC connect');
414
+ // `shown` is MANDATORY: dbRoleCmd embeds the password, and run() logs the raw command
415
+ // on BOTH the dry-run and success paths — the exact leak this task exists to prevent
416
+ // (makeWriteFile passes `shown` for the web.env body for the same reason, and a `shown`
417
+ // call site also suppresses stderr). The GRANTS carry no secret and log in full.
418
+ boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
419
+ boxExec(dbRoleGrantsCmd(inst, { privileged }));
420
+ } else { log(' (per-instance DB role created via cloud-init on the droplet)'); }
421
+
398
422
  // ── step 3: config + per-instance secrets ─────────────────────────────────
399
423
  log(' [config] per-instance web.env');
400
- if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db) }), { mode: 0o600, sudo: true });
424
+ if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
401
425
  else log(' (baked into cloud-init)');
402
426
 
403
427
  // ── step 4: systemd unit + enable ─────────────────────────────────────────
@@ -570,6 +594,10 @@ function dedicatedUserData(inst) {
570
594
  // enable the timer alongside the web unit. Mirrors provisionInstance's step 4b.
571
595
  const svcB64 = Buffer.from(backupService(i), 'utf8').toString('base64');
572
596
  const timerB64 = Buffer.from(backupTimer(i), 'utf8').toString('base64');
597
+ // task 1003369: the same role the local leg creates at step 2b, same position around the
598
+ // migrate, but PASSWORD-LESS (peerOnly) — DO retains user-data and serves it back, and a
599
+ // dedicated droplet is single-tenant. Whole argument on dbRoleCmd.
600
+ const roleLines = ` - [ bash, -lc, "${dbRoleCmd(i, { peerOnly: true })}" ]\n - [ bash, -lc, "${dbRoleGrantsCmd(i)}" ]\n`;
573
601
  return `#cloud-config
574
602
  write_files:
575
603
  - path: ${backupServicePath(i)}
@@ -581,10 +609,12 @@ write_files:
581
609
  permissions: '0644'
582
610
  content: ${timerB64}
583
611
  runcmd:
612
+ - [ bash, -lc, "${ensureInstanceUserCmd(i)}" ]
613
+ - [ bash, -lc, "${grantInstanceRepoReadCmd(i)}" ]
584
614
  - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
585
615
  - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
586
616
  - [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
587
- - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
617
+ ${roleLines} - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
588
618
  - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
589
619
  `;
590
620
  }
@@ -1441,6 +1471,8 @@ module.exports = {
1441
1471
  CONFIG, MAX_INTENT_ATTEMPTS, provisionerBotEmail,
1442
1472
  // pure step generators (tested directly)
1443
1473
  dbName, dbCreateCmd, migrateCmd, seedFirstVersionCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
1474
+ // per-instance unix account + password-authed PG role (task 1003369, audit B3)
1475
+ instanceUser, instanceStateDir, instanceDbRole, generateDbPassword, ensureInstanceUserCmd, grantInstanceRepoReadCmd, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd,
1444
1476
  backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
1445
1477
  standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
1446
1478
  // standalone scaffold (ADR 0108 / 0125, task 2055; consumer-core fix task 1002275)
@@ -45,6 +45,20 @@ if (process.env.DATABASE_URL) {
45
45
  // Peer auth via the local Unix socket on the droplet; PG* env overrides for other environments.
46
46
  host: process.env.PGHOST || '/var/run/postgresql',
47
47
  database: process.env.PGDATABASE || instanceDbName(),
48
+ // The per-instance DB credential (task 1003369, audit B3). A provisioned instance's
49
+ // web.env carries PGUSER/PGPASSWORD naming a role that owns its OWN database and
50
+ // has CONNECT on no other — so a co-tenant box no longer lets one instance open a
51
+ // pool against a sibling's data, or against the control plane's own builders and
52
+ // builder_sessions, simply by running as the same unix user.
53
+ //
54
+ // Named EXPLICITLY rather than left to pg's env defaults, which would resolve the
55
+ // same two vars invisibly. The whole defect this closes was a connection whose
56
+ // identity nothing in the code stated, so the identity is stated here, and a test
57
+ // can assert it. Absent (self-host, dev, an instance provisioned before this landed)
58
+ // the keys are omitted entirely and pg falls back to exactly the peer auth it used
59
+ // before — this cannot brick a box whose role has not been created yet.
60
+ ...(process.env.PGUSER ? { user: process.env.PGUSER } : {}),
61
+ ...(process.env.PGPASSWORD ? { password: process.env.PGPASSWORD } : {}),
48
62
  });
49
63
  }
50
64
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.708'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.710'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,98 @@
1
+ // tests/claimable_blocker_status.mjs
2
+ //
3
+ // Task 1003863 — claimable_tasks must hide a task only while a blocker on it is
4
+ // still OPEN, not forever because a link row exists. Static shape guard for
5
+ // core_239, in the style of blocker_resolve_guard.mjs: no live DB, assert the SQL
6
+ // TEXT so a future edit cannot silently drop the fix or "simplify" the LEFT JOIN
7
+ // back into the bug. Runtime behaviour is proven against real Postgres at deploy.
8
+ //
9
+ // THE BUG THIS PINS. core_231's view excluded a task if ANY task_blockers row
10
+ // existed, and nothing ever deletes that row. core_223's trigger flips the task
11
+ // 'blocked' -> 'ready' when its last blocker resolves, so the task became ready
12
+ // and permanently invisible to /builder-start — workable by id, unfindable in the
13
+ // feed. Latent when found (every blocker was open), but eleven tasks were one
14
+ // resolve away from vanishing.
15
+ //
16
+ // Run: node tests/claimable_blocker_status.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { readFileSync, readdirSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+ import { makeRunner } from './helpers.mjs';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const repoRoot = join(__dirname, '..');
26
+ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
27
+
28
+ const MIG = 'core_239_claimable_respects_blocker_status.sql';
29
+ const mig = read('migrations', MIG);
30
+ // Comments carry the rationale; strip them when asserting on the executable body
31
+ // so a claim in prose can never stand in for the real SQL.
32
+ const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
33
+
34
+ const { test, summary } = makeRunner();
35
+
36
+ await test('239 replaces claimable_tasks, in core_231-s drop-and-create shape', () => {
37
+ assert.match(body, /DROP VIEW IF EXISTS claimable_tasks;/);
38
+ assert.match(body, /CREATE VIEW claimable_tasks AS/);
39
+ });
40
+
41
+ await test('the blocker clause filters on the blocker being OPEN, not on the link existing', () => {
42
+ assert.match(body, /FROM task_blockers tb/, 'it still looks at the link table');
43
+ assert.match(body, /blockers b ON b\.id = tb\.blocker_id/, 'and resolves the blocker itself');
44
+ assert.match(body, /b\.status = 'open'/, 'so a RESOLVED blocker stops hiding the task');
45
+ });
46
+
47
+ await test('the join is a LEFT JOIN with the fail-closed arm — an inner join would UNHIDE legacy links', () => {
48
+ // Legacy markdown-anchor links carry task_blockers.blocker_ref with no blockers
49
+ // row (core_223 header, same scope as 176). An inner join drops them from the
50
+ // NOT EXISTS and silently widens the feed. This is the one line most likely to
51
+ // be "tidied" later, so it is asserted from both directions.
52
+ assert.match(body, /LEFT JOIN blockers b/, 'LEFT, so an unresolvable link survives the join');
53
+ assert.match(body, /b\.id IS NULL OR b\.status = 'open'/, 'and an unresolvable link still blocks');
54
+ assert.doesNotMatch(
55
+ body,
56
+ /(?<!LEFT )JOIN blockers b ON b\.id = tb\.blocker_id/,
57
+ 'a bare inner JOIN onto blockers would unhide every legacy link'
58
+ );
59
+ });
60
+
61
+ await test('the view-s OTHER guards survive the rewrite', () => {
62
+ // A view rewrite replaces the whole definition, so dropping a sibling clause is
63
+ // a silent regression with no error anywhere. Each of these was its own fix.
64
+ assert.match(body, /t\.status = 'ready'/, 'only ready tasks are claimable');
65
+ assert.match(body, /NOT LIKE '\\_\\_smoke\\_\\_%'/, 'smoke rows stay out of the feed');
66
+ assert.match(body, /c\.released_at IS NULL/, 'and the touches-overlap parallel-safety guard holds');
67
+ assert.match(body, /t2\.touches && t\.touches/, 'against other live claims');
68
+ });
69
+
70
+ await test('the column list stays the t.* wildcard (core_227-s silent-column lesson)', () => {
71
+ // core_227's header records six columns that never reached the feed because a
72
+ // rewrite spelled the list out instead of re-expanding it.
73
+ assert.match(body, /SELECT t\.\*,/, 'the wildcard re-expands to every current column');
74
+ assert.match(body, /pms_calibrated_minutes\(t\.est_minutes, t\.kind\) AS est_minutes_calibrated/);
75
+ });
76
+
77
+ await test('239 is the LAST definition of claimable_tasks in migrations/', () => {
78
+ // If a later migration redefines the view, it inherits whatever that one says —
79
+ // which is exactly how 177 silently dropped migration 176's blocker clause from
80
+ // task_is_fully_unblocked. Catch the next one at the source.
81
+ const defs = readdirSync(join(repoRoot, 'migrations'))
82
+ .filter((f) => f.endsWith('.sql'))
83
+ .filter((f) => /CREATE VIEW claimable_tasks/.test(read('migrations', f)))
84
+ .sort();
85
+ assert.ok(defs.includes(MIG), 'this migration defines the view');
86
+ assert.equal(defs[defs.length - 1], MIG,
87
+ `a later migration redefines claimable_tasks and must carry the blocker-status clause: ${defs.join(', ')}`);
88
+ });
89
+
90
+ await test('the feed and the claim path enforce the SAME rule', () => {
91
+ // The whole bug class is the two gates disagreeing. db-claims.js got this right
92
+ // for the by-id path in task 1002650; this pins that they still match.
93
+ const claims = read('modules', 'lifecycle', 'db-claims.js');
94
+ assert.match(claims, /b\.status = 'open'/,
95
+ 'the by-id claim path checks blocker STATUS, and the view must not be laxer or stricter');
96
+ });
97
+
98
+ summary();
@@ -2446,5 +2446,232 @@ await ta('teardownInstance(standalone): no registered key / no token → teardow
2446
2446
  assert.ok(b.cmds.some((c) => /rm -f \S+\/\.git\/cloudbongos-deploy-key \S+\.pub/.test(c)), 'local key halves removed even with no credential');
2447
2447
  });
2448
2448
 
2449
+
2450
+ console.log('\nper-instance unix account + password-authed PG role (task 1003369, audit B3):');
2451
+
2452
+ t('instanceUser: prefixed, never the bare slug — a project called `postgres` cannot claim that account', () => {
2453
+ assert.equal(P.instanceUser(coTenant), 'bongos-mercury');
2454
+ assert.equal(P.instanceUser({ slug: 'postgres' }), 'bongos-postgres');
2455
+ assert.notEqual(P.instanceUser({ slug: 'lars' }), 'lars');
2456
+ });
2457
+
2458
+ t('instanceUser: a 40-char slug folds to a LEGAL account name — <=32 chars, no trailing/double dash', () => {
2459
+ const slug = 'a'.repeat(39) + 'b'; // the isValidSlug maximum
2460
+ const u = P.instanceUser({ slug });
2461
+ assert.ok(u.length <= 32, `account name must fit utmp's 32 chars, got ${u.length} (${u})`);
2462
+ assert.match(u, /^[a-z][a-z0-9-]*[a-z0-9]$/, 'must satisfy useradd NAME_REGEX');
2463
+ assert.ok(!u.includes('--'), 'no double dash');
2464
+ });
2465
+
2466
+ t('instanceUser: two slugs sharing a long prefix get DIFFERENT accounts — a fold collision would hand each the other\'s identity', () => {
2467
+ const a = 'production-instance-alpha-one'; // 29 chars, identical for 26 of them
2468
+ const b = 'production-instance-alpha-two';
2469
+ assert.notEqual(P.instanceUser({ slug: a }), P.instanceUser({ slug: b }));
2470
+ });
2471
+
2472
+ t('instanceUser: an invalid slug THROWS rather than deriving an account name from it', () => {
2473
+ assert.throws(() => P.instanceUser({ slug: '../../etc/passwd' }), /invalid slug/);
2474
+ assert.throws(() => P.instanceUser({ slug: 'Has Spaces' }), /invalid slug/);
2475
+ assert.throws(() => P.instanceUser({ slug: '' }), /invalid slug/);
2476
+ });
2477
+
2478
+ t('instanceStateDir is NOT under /home — ProtectHome=read-only would make the first-boot secret write EROFS', () => {
2479
+ const dir = P.instanceStateDir(coTenant);
2480
+ assert.equal(dir, '/var/lib/bongos-mercury');
2481
+ assert.ok(!dir.startsWith('/home'), 'the writable state dir must survive ProtectHome=read-only');
2482
+ });
2483
+
2484
+ t('generateDbPassword: base64url only, so it needs no escaping in the env file, the SQL literal or a URL', () => {
2485
+ for (let i = 0; i < 20; i++) {
2486
+ const pw = P.generateDbPassword();
2487
+ assert.match(pw, /^[A-Za-z0-9_-]+$/, `not base64url: ${pw}`);
2488
+ assert.ok(pw.length >= 40, `too short to be 32 bytes: ${pw.length}`);
2489
+ }
2490
+ assert.notEqual(P.generateDbPassword(), P.generateDbPassword(), 'must not be deterministic');
2491
+ });
2492
+
2493
+ t('ensureInstanceUserCmd: idempotent SYSTEM account, nologin, state dir re-asserted every run', () => {
2494
+ const c = P.ensureInstanceUserCmd(coTenant, { privileged: true });
2495
+ assert.match(c, /id -u bongos-mercury >\/dev\/null 2>&1 \|\|/, 'an existing account short-circuits');
2496
+ assert.match(c, /useradd --system/, 'a system account, not a login user');
2497
+ assert.match(c, /--shell \/usr\/sbin\/nologin/, 'nothing should ever log in as an instance');
2498
+ assert.match(c, /install -d -o bongos-mercury -g bongos-mercury -m 0700 \/var\/lib\/bongos-mercury/);
2499
+ assert.ok(!P.ensureInstanceUserCmd(coTenant).includes('sudo '), 'unprivileged renders without sudo');
2500
+ });
2501
+
2502
+ t('grantInstanceRepoReadCmd: adds the instance account to the app-user group so the unit can exec its WorkingDirectory', () => {
2503
+ assert.equal(P.grantInstanceRepoReadCmd(coTenant, { privileged: true }), 'sudo usermod -aG lars bongos-mercury');
2504
+ });
2505
+
2506
+ t('dbRoleCmd: LOGIN + password, upserted — CREATE on a fresh box, ALTER on a re-provision (the rotation path)', () => {
2507
+ const c = P.dbRoleCmd(coTenant, { password: 'pw_ABC-123', privileged: true });
2508
+ assert.match(c, /sudo -u postgres psql -v ON_ERROR_STOP=1 -d postgres/, 'role DDL runs on `postgres`, not the instance DB');
2509
+ assert.match(c, /SELECT 1 FROM pg_roles WHERE rolname = \$r\$bongos-mercury\$r\$/);
2510
+ assert.match(c, /CREATE ROLE %I WITH LOGIN PASSWORD %L/);
2511
+ assert.match(c, /ALTER ROLE %I WITH LOGIN PASSWORD %L/);
2512
+ assert.ok(c.includes('$p$pw_ABC-123$p$'), 'the password rides a dollar-quoted literal');
2513
+ });
2514
+
2515
+ t('dbRoleCmd: the role is NOT a superuser and gets no CREATEDB/CREATEROLE — a compromised instance reaches its own data and no further', () => {
2516
+ const c = P.dbRoleCmd(coTenant, { password: P.generateDbPassword(), privileged: true });
2517
+ assert.ok(!/SUPERUSER/i.test(c), 'never a superuser');
2518
+ assert.ok(!/CREATEDB/i.test(c), 'no CREATEDB');
2519
+ assert.ok(!/CREATEROLE/i.test(c), 'no CREATEROLE');
2520
+ assert.ok(!/BYPASSRLS/i.test(c), 'no BYPASSRLS');
2521
+ });
2522
+
2523
+ t('dbRoleCmd: REVOKEs CONNECT from PUBLIC — the half that isolates, since PG grants it to PUBLIC by default', () => {
2524
+ const c = P.dbRoleCmd(coTenant, { password: 'pw', privileged: true });
2525
+ assert.match(c, /REVOKE CONNECT ON DATABASE %I FROM PUBLIC/, 'without this a sibling peer-auths straight in');
2526
+ assert.match(c, /ALTER DATABASE %I OWNER TO %I/, 'the role owns its own DB');
2527
+ });
2528
+
2529
+ t('dbRoleCmd: refuses a password that is not base64url — no caller can smuggle a quote into the SQL', () => {
2530
+ assert.throws(() => P.dbRoleCmd(coTenant, { password: "x'; DROP DATABASE mercury; --" }), /base64url/);
2531
+ assert.throws(() => P.dbRoleCmd(coTenant, { password: '' }), /base64url/);
2532
+ assert.throws(() => P.dbRoleCmd(coTenant, {}), /base64url/);
2533
+ });
2534
+
2535
+ t('dbRoleGrantsCmd: runs on the INSTANCE db and grants forward too — the migrate runs as postgres, so it owns the tables', () => {
2536
+ const c = P.dbRoleGrantsCmd(coTenant, { privileged: true });
2537
+ assert.match(c, /-d mercury/, 'schema grants are per-database');
2538
+ assert.match(c, /GRANT ALL ON ALL TABLES IN SCHEMA public TO %I/, 'the tables the migrate already created');
2539
+ assert.match(c, /ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO %I/, 'and the ones the NEXT migrate will');
2540
+ assert.match(c, /GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO %I/);
2541
+ });
2542
+
2543
+ t('BOTH role commands render with NO double quote — they have to survive a YAML double-quoted cloud-init scalar', () => {
2544
+ const dq = String.fromCharCode(34);
2545
+ for (const c of [P.dbRoleCmd(coTenant, { password: 'pw_ABC-123' }), P.dbRoleGrantsCmd(coTenant)]) {
2546
+ assert.ok(!c.includes(dq), `a double-quoted identifier silently truncates the cloud-init line: ${c}`);
2547
+ }
2548
+ });
2549
+
2550
+ t('serviceUnit: runs as the instance\'s OWN account, never the shared app user (the /proc/<pid>/environ vector)', () => {
2551
+ const u = P.serviceUnit(coTenant, { etcBase: '/etc' });
2552
+ assert.match(u, /^User=bongos-mercury$/m);
2553
+ assert.match(u, /^Group=bongos-mercury$/m);
2554
+ assert.ok(!/^User=lars$/m.test(u), 'a shared User= is the defect this closes');
2555
+ assert.ok(!/^Group=lars$/m.test(u));
2556
+ });
2557
+
2558
+ t('serviceUnit: two instances on one box get two DIFFERENT accounts — co-tenant is the default shape', () => {
2559
+ const a = P.serviceUnit(coTenant, { etcBase: '/etc' });
2560
+ const b = P.serviceUnit({ ...coTenant, slug: 'venus', db_name: 'venus' }, { etcBase: '/etc' });
2561
+ assert.match(a, /^User=bongos-mercury$/m);
2562
+ assert.match(b, /^User=bongos-venus$/m);
2563
+ });
2564
+
2565
+ t('serviceUnit: carries the hardening block, with HOME + ReadWritePaths pointing at the state dir', () => {
2566
+ const u = P.serviceUnit(coTenant, { etcBase: '/etc' });
2567
+ assert.match(u, /^ProtectSystem=strict$/m);
2568
+ assert.match(u, /^ProtectHome=read-only$/m);
2569
+ assert.match(u, /^PrivateTmp=true$/m);
2570
+ assert.match(u, /^NoNewPrivileges=true$/m);
2571
+ assert.match(u, /^Environment=HOME=\/var\/lib\/bongos-mercury$/m, 'the config-home write must not land under the read-only /home');
2572
+ assert.match(u, /^ReadWritePaths=\/var\/lib\/bongos-mercury /m, 'the state dir is writable');
2573
+ });
2574
+
2575
+ t('serviceUnit: ReadWritePaths does NOT make the checkout the service runs writable by it', () => {
2576
+ const inst = { ...coTenant, hosting_shape: 'standalone' };
2577
+ const u = P.serviceUnit(inst, { etcBase: '/etc' });
2578
+ const root = P.standaloneRoot(inst);
2579
+ const rw = u.split('\n').find((l) => l.startsWith('ReadWritePaths='));
2580
+ assert.ok(rw.includes(`-${root}/config`), 'only config/, and optional so a shape without one still mounts');
2581
+ assert.ok(!rw.split(' ').includes(root), 'the code the service runs stays read-only to it');
2582
+ });
2583
+
2584
+ t('webEnvBody: carries PGUSER + PGPASSWORD for the instance role when the runner minted one', () => {
2585
+ const b = P.webEnvBody(coTenant, { dbPassword: 'pw_ABC-123' });
2586
+ assert.match(b, /^PGUSER=bongos-mercury$/m);
2587
+ assert.match(b, /^PGPASSWORD=pw_ABC-123$/m);
2588
+ assert.match(b, /^PGDATABASE=mercury$/m);
2589
+ });
2590
+
2591
+ t('webEnvBody: NO password ⇒ no PG credential lines — an instance provisioned before this keeps its peer auth', () => {
2592
+ const b = P.webEnvBody(coTenant);
2593
+ assert.doesNotMatch(b, /^PGUSER=/m);
2594
+ assert.doesNotMatch(b, /^PGPASSWORD=/m);
2595
+ assert.match(b, /^PGDATABASE=mercury$/m, 'the DB name is unchanged either way');
2596
+ });
2597
+
2598
+ t('dedicatedUserData: the droplet runs the SAME account + role steps, in the same order around the migrate', () => {
2599
+ const ud = P.dedicatedUserData(coTenant);
2600
+ const at = (needle) => ud.indexOf(needle);
2601
+ assert.ok(at('useradd --system') > -1, 'the droplet creates the account too');
2602
+ assert.ok(at('usermod -aG lars') > -1);
2603
+ assert.ok(at('useradd --system') < at('createdb mercury'), 'account before the DB');
2604
+ assert.ok(at('migrate.sh') < at('CREATE ROLE %I'), 'the role grants must follow the migrate that created the tables');
2605
+ assert.ok(at('CREATE ROLE %I') < at('GRANT ALL ON ALL TABLES'), 'role before its grants');
2606
+ });
2607
+
2608
+ t('dedicatedUserData: NEVER bakes a password into cloud-init — DO retains user-data and serves it back', () => {
2609
+ const ud = P.dedicatedUserData(coTenant);
2610
+ // The dollar-quoted password literal dbRoleCmd uses. Its absence is the assertion.
2611
+ assert.ok(!ud.includes('$p$'), 'a credential in user-data is a permanent copy we cannot lock down');
2612
+ assert.match(ud, /CREATE ROLE %I WITH LOGIN\$f\$/, 'the role is still created, password-less (single-tenant droplet)');
2613
+ assert.match(ud, /REVOKE CONNECT ON DATABASE %I FROM PUBLIC/, 'and PUBLIC still loses CONNECT');
2614
+ });
2615
+
2616
+ t('dbRoleCmd(peerOnly): omits the PASSWORD clause entirely, and REFUSES a password', () => {
2617
+ const c = P.dbRoleCmd(coTenant, { peerOnly: true });
2618
+ assert.ok(!/PASSWORD/.test(c), 'no PASSWORD clause at all — not an empty one');
2619
+ assert.match(c, /CREATE ROLE %I WITH LOGIN\$f\$/);
2620
+ assert.match(c, /ALTER DATABASE %I OWNER TO %I/, 'ownership and the revoke are unchanged');
2621
+ assert.throws(() => P.dbRoleCmd(coTenant, { peerOnly: true, password: 'pw_ABC' }), /peerOnly takes no password/);
2622
+ });
2623
+
2624
+ t('dbRoleCmdShown: the label the runner logs names every effect and carries NO password', () => {
2625
+ const pw = P.generateDbPassword();
2626
+ const real = P.dbRoleCmd(coTenant, { password: pw });
2627
+ const shown = P.dbRoleCmdShown(coTenant);
2628
+ assert.ok(real.includes(pw), 'precondition: the real command does embed the secret');
2629
+ assert.ok(!shown.includes(pw), 'the logged label must not');
2630
+ assert.ok(!shown.includes('$p$'), 'nor the dollar-quoted literal around it');
2631
+ for (const effect of ['bongos-mercury', 'mercury', 'revoke PUBLIC connect']) {
2632
+ assert.ok(shown.includes(effect), `the label must still name: ${effect}`);
2633
+ }
2634
+ });
2635
+
2636
+
2637
+ await ta('provisionInstance: EVERY secret-bearing command opts into `shown`, so no password reaches the runner log', async () => {
2638
+ // The generator tests prove the label is clean; this proves the CALL SITE uses it.
2639
+ // Stated as an invariant rather than one assertion about one line: any future command
2640
+ // that embeds a dollar-quoted secret is caught by the same test, which is the failure
2641
+ // mode worth pinning — the leak was not a typo, it was a call site that never opted in.
2642
+ const { deps } = recorder();
2643
+ const seen = [];
2644
+ deps.exec = (cmd, opts = {}) => { seen.push({ cmd, opts }); return { ok: true, stdout: '' }; };
2645
+ await P.provisionInstance(coTenant, deps);
2646
+
2647
+ const secretBearing = seen.filter((c) => c.cmd.includes('$p$'));
2648
+ assert.ok(secretBearing.length >= 1, 'precondition: the run really does issue a password-bearing command');
2649
+ for (const { cmd, opts } of secretBearing) {
2650
+ assert.ok(opts.shown, `a command embedding a secret must pass \`shown\`: ${cmd.slice(0, 60)}…`);
2651
+ assert.ok(!opts.shown.includes('$p$'), 'the label must not carry the dollar-quoted secret');
2652
+ // The password itself, pulled back out of the real command, must not be in the label.
2653
+ const pw = (cmd.match(/\$p\$([^$]+)\$p\$/) || [])[1];
2654
+ assert.ok(pw && pw.length > 20, 'precondition: recovered the generated password from the command');
2655
+ assert.ok(!opts.shown.includes(pw), 'the label must not carry the password');
2656
+ }
2657
+ });
2658
+
2659
+ await ta('provisionInstance: the password IS written to web.env, which makeWriteFile logs by path not body', async () => {
2660
+ const { deps } = recorder();
2661
+ const bodies = [];
2662
+ deps.writeFile = (path, body, opts) => { bodies.push({ path, body, opts }); return { ok: true }; };
2663
+ const seen = [];
2664
+ deps.exec = (cmd, opts = {}) => { seen.push({ cmd, opts }); return { ok: true, stdout: '' }; };
2665
+ await P.provisionInstance(coTenant, deps);
2666
+
2667
+ const env = bodies.find((b) => /web\.env$/.test(b.path));
2668
+ assert.ok(env, 'wrote a web.env');
2669
+ assert.equal(env.opts && env.opts.mode, 0o600, 'web.env stays chmod 600');
2670
+ const pw = (seen.map((c) => c.cmd).join('\n').match(/\$p\$([^$]+)\$p\$/) || [])[1];
2671
+ assert.ok(pw, 'recovered the run\'s generated password');
2672
+ assert.ok(env.body.includes(`PGPASSWORD=${pw}`), 'the SAME password the role was set to reaches web.env — one mint per run');
2673
+ assert.ok(env.body.includes('PGUSER=bongos-mercury'));
2674
+ });
2675
+
2449
2676
  console.log(`\nprovision: ${passed} passed, ${failed} failed`);
2450
2677
  process.exit(failed ? 1 : 0);