@bongos/core 1.19.744 → 1.19.746

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.744",
6
- "core_contract": "1.19.744",
7
- "source_commit": "a1c9e8b6b38e48c9f3a6040ff29b76ee3457b808",
5
+ "core_version": "1.19.746",
6
+ "core_contract": "1.19.746",
7
+ "source_commit": "4d1f75b454f00b81bcda96e1dadbd49e1eee1731",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-15T15:04:25.874Z",
9
+ "built_at": "2026-09-15T20:43:25.357Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 479,
12
+ "docs_redacted": 480,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2107,
14
+ "functional_verbatim": 2109,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2611,
20
- "tree_sha256": "4cfb37d1d47bb997d911c6ae24958d363737b154c725bfb76fb65f3e71c92449",
19
+ "file_count": 2614,
20
+ "tree_sha256": "0006716e0db1f92079035942152af287a85a3e3e57b5c3b7a1328b4e760d2957",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1122,7 +1122,7 @@
1122
1122
  {
1123
1123
  "path": "docs/adr/0127-cross-box-fleet-observability.md",
1124
1124
  "mode": "0000644",
1125
- "sha256": "c1e2191b0c1022707f4c7c745488d8aa6c29e94bc09b66d3b1aa9e66dd3cecc2"
1125
+ "sha256": "1c2e85af986ca3f072b0c5c0d497a96333452fcd44bec1abee1fbf96f8fde8eb"
1126
1126
  },
1127
1127
  {
1128
1128
  "path": "docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md",
@@ -1924,10 +1924,15 @@
1924
1924
  "mode": "0000644",
1925
1925
  "sha256": "7060acdff9fde6dbe4831ea1873e5f75bc25acf6e3207a084ac3ddb33ee1a2ff"
1926
1926
  },
1927
+ {
1928
+ "path": "docs/adr/0284-the-control-plane-carries-no-metrics-stack.md",
1929
+ "mode": "0000644",
1930
+ "sha256": "9280dd05ac1061a7f4da41ea665612be9cee16631ed2b27a513c3f8ec084b51a"
1931
+ },
1927
1932
  {
1928
1933
  "path": "docs/adr/README.md",
1929
1934
  "mode": "0000644",
1930
- "sha256": "872911d695cd9e3bf27b5bb4995acb2e814c73bbb7cabac2f7852ad10e3e1710"
1935
+ "sha256": "b058befbb0e111c4fba17e3890b02abeda9e828c9f419028183e2cf1f3b02f41"
1931
1936
  },
1932
1937
  {
1933
1938
  "path": "docs/api-reference.md",
@@ -1942,7 +1947,7 @@
1942
1947
  {
1943
1948
  "path": "docs/architecture.md",
1944
1949
  "mode": "0000644",
1945
- "sha256": "031fb6b89ccacb52b01508accfc18d740f1ce4d55c3ac74192bb32b976b09627"
1950
+ "sha256": "0946126f632fda787c80f6f7e2249dc5dd3eb28581c3a86022d0313ec5826fe3"
1946
1951
  },
1947
1952
  {
1948
1953
  "path": "docs/branding-contract.md",
@@ -2492,7 +2497,7 @@
2492
2497
  {
2493
2498
  "path": "docs/module-api-changelog.md",
2494
2499
  "mode": "0000644",
2495
- "sha256": "8f98164df753a9a4a8c77ade44d450eeb65eee2987dcc5b46d0d251f2a62d399"
2500
+ "sha256": "cf0a15e10db3248e30d6aeb4f4fbc13de0e190a7322c17ebdf59c6c39adb1a4e"
2496
2501
  },
2497
2502
  {
2498
2503
  "path": "docs/modules-contract.md",
@@ -6229,6 +6234,11 @@
6229
6234
  "mode": "0000644",
6230
6235
  "sha256": "f4562bd90fdae26c5f7c51851bd2ad7752535bd0dcfb62dd567c11e76330c42b"
6231
6236
  },
6237
+ {
6238
+ "path": "modules/provisioning/migrations/provisioning_015_domain_unique.sql",
6239
+ "mode": "0000644",
6240
+ "sha256": "136b0b7e0485c1602a403767863137da0536e058b8913228d3a3e5a6ad56f512"
6241
+ },
6232
6242
  {
6233
6243
  "path": "modules/provisioning/module.json",
6234
6244
  "mode": "0000644",
@@ -6257,7 +6267,7 @@
6257
6267
  {
6258
6268
  "path": "modules/provisioning/provisioning.js",
6259
6269
  "mode": "0000644",
6260
- "sha256": "9e6d1356fa046f97f3141c353302637dad2deed4c2460d124c8d7636b0ff0065"
6270
+ "sha256": "b3a1261dbdefd526a93d75c8f46a7b896f2331292b41a919a73dde1ffba45eed"
6261
6271
  },
6262
6272
  {
6263
6273
  "path": "modules/provisioning/rate-limit.js",
@@ -6282,7 +6292,7 @@
6282
6292
  {
6283
6293
  "path": "modules/provisioning/routes/provisioning.js",
6284
6294
  "mode": "0000644",
6285
- "sha256": "eac6fb471e91a08d2c1003f4245140b2a995ae892140d625c116143a4a6610e1"
6295
+ "sha256": "a3812af0cc12dfafff47c1b9b03edea6ef61f1f4728acead1628d0b1a1669432"
6286
6296
  },
6287
6297
  {
6288
6298
  "path": "modules/provisioning/starter-bundles.js",
@@ -7542,12 +7552,12 @@
7542
7552
  {
7543
7553
  "path": "package-lock.json",
7544
7554
  "mode": "0000644",
7545
- "sha256": "7d86631d2cad28001db3920822fd96bb638cede683d627fe0101445320c12d7e"
7555
+ "sha256": "c2d3b75314692c4e7d9658defd555fda8c648e0ef6521738743eb110277c2868"
7546
7556
  },
7547
7557
  {
7548
7558
  "path": "package.json",
7549
7559
  "mode": "0000644",
7550
- "sha256": "d4c3e36f1af2829fc4bc7d9b6dc29be667c75e6d5fd870855398bc5e13380ddc"
7560
+ "sha256": "a35739522d6c79e6cfe8eea8ed34a9e16df1a559bbe89d1589b45e20c7f49860"
7551
7561
  },
7552
7562
  {
7553
7563
  "path": "public-docs/index.html",
@@ -9347,7 +9357,7 @@
9347
9357
  {
9348
9358
  "path": "src/module-api.js",
9349
9359
  "mode": "0000644",
9350
- "sha256": "6cec169dc7c62404bb81e3b7fc00506de07048b3a0ce2ef59f892d467b9e0c3b"
9360
+ "sha256": "9e340d75ea572651792a34774d767edca2c2cac266024d56b3265064ab95d9bc"
9351
9361
  },
9352
9362
  {
9353
9363
  "path": "src/module-loader/catalog.js",
@@ -12009,6 +12019,11 @@
12009
12019
  "mode": "0000644",
12010
12020
  "sha256": "62ea8ffc2c21c351289dc7f96cab38fcd5824afba1fed0df7a545cd5df9e396c"
12011
12021
  },
12022
+ {
12023
+ "path": "tests/provisioning_domain_unique.mjs",
12024
+ "mode": "0000644",
12025
+ "sha256": "d1c9456ad442caf2485c5c38e5bad98ef6c834589e405eade59cbeffec534ea0"
12026
+ },
12012
12027
  {
12013
12028
  "path": "tests/provisioning_github_app_routes.mjs",
12014
12029
  "mode": "0000644",
@@ -1,6 +1,6 @@
1
1
  # ADR 0127 — Cross-box fleet observability on the control-plane droplet
2
2
 
3
- Status: Accepted (2026-07-05, task [#2059](https://example.com/builders#/task/2059))
3
+ Status: Superseded by [ADR 0284](0284-the-control-plane-carries-no-metrics-stack.md) (2026-09-15) — Grafana + Prometheus were removed from the control plane: ~300 MB on a box shared with every customer project, watching none of them. Accepted 2026-07-05, task [#2059](https://example.com/builders#/task/2059).
4
4
 
5
5
  ## Context
6
6
 
@@ -0,0 +1,33 @@
1
+ # ADR 0284 — The control plane carries no metrics stack
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-15
5
+ - **Task:** [1003876](https://cloudbongos.com/builders#/task/1003876) (goal 1000106). Makes [1002068](https://cloudbongos.com/builders#/task/1002068) (rotate the metrics credentials) moot; it was abandoned and its blocker resolved.
6
+ - **Supersedes:** [ADR 0127](0127-cross-box-fleet-observability.md) — Grafana + Prometheus on the control-plane droplet.
7
+
8
+ ## Context
9
+
10
+ ADR 0127 put Grafana and Prometheus on the control-plane droplet behind `metrics.cloudbongos.com`. Since then every provisioned project has landed on that same `s-1vcpu-2gb` box (control-plane-local `standalone` shape), and it ran out of headroom (task 1003875 added the swap it never had).
11
+
12
+ Measured on 2026-09-15:
13
+
14
+ - **Cost on the shared box:** Grafana 187 MB RSS (the largest process there) plus Prometheus 110 MB, about 15% of RAM, plus 2.5 GB of Prometheus data on disk.
15
+ - **Coverage:** Prometheus scraped four targets: `cloudbongos`, the two instances on the old co-hosting box, and itself. **None of the provisioned customer projects were scraped.** The dashboards watched the platform's past, not the fleet it now hosts.
16
+ - **Consumers:** nothing in the core reads Prometheus or Grafana. The dashboard was the only consumer, and the Grafana journal (kept since 2026-07-15) shows no login events.
17
+ - **Trust boundary:** an internal tool shared a kernel, a unix box and a Postgres install with tenant projects, so a Grafana CVE would have become a customer-data question. Its credentials had already been exposed in a session (task 1002068).
18
+
19
+ ## Decision
20
+
21
+ **Remove it outright rather than moving it** (the owner's call, choosing between "drop it" and "move it to its own ~$6/mo droplet"). On the control plane:
22
+
23
+ - the `metrics.cloudbongos.com` Caddy site block is gone (validated and reloaded; a backup is at `/etc/caddy/Caddyfile.bak-task-1003876`);
24
+ - `grafana-server` and `prometheus` are stopped, disabled and purged, with their data and config dirs, the Grafana systemd drop-in that held its `GF_*` secrets, and the Grafana apt source.
25
+
26
+ The in-app exporter stays. Every instance still serves `/metrics` ([`src/bongos/metrics.js`](../../src/bongos/metrics.js), loopback or `METRICS_TOKEN` Bearer), so observability can come back **off** the control plane without touching any instance.
27
+
28
+ ## Consequences
29
+
30
+ - **+** Memory available on the shared box rose from 554 MB to 1158 MB with no customer-visible change and no downtime. 2.5 GB of disk freed.
31
+ - **+** One less internet-facing, credentialed surface on the most-trusted host in the fleet.
32
+ - **−** No dashboards and no metrics history. If fleet metrics are wanted again, run the scraper on a separate box and point it at instances with `METRICS_TOKEN`, never on the control plane.
33
+ - **−** Leftovers outside the control plane's own config, filed as [task 1003924](https://cloudbongos.com/builders#/task/1003924): the `metrics.cloudbongos.com` DNS record (an unmatched host now gets Caddy's empty 200), the `metrics-fwd-*` socat forwarders and ufw rules on the co-hosting box (they expose whole app ports over the VPC to a scraper that no longer exists), and the instance-owned `infra/grafana`, `infra/prometheus` and `infra/Caddyfile` metrics block (ADR 0150 — not in this repo).
@@ -201,7 +201,7 @@ This keeps the decision history honest and traceable.
201
201
  | 0124 | [Typed API response bodies — follow-up to ADR 0118 for goal 36's `api-client-sdk` (task [#2061](https://example.com/builders#/task/2061), from idea 519, BONGOS-V1). Task 2050 typed every request body but success RESPONSES stayed the `ApiResponse` (`= any`) alias — routes declare no response schema. Decision: infer the success shape STATICALLY from each route's inline `res.json({ … })` object literal (reusing the request extractor's `captureBalanced`/`splitTopLevel` machinery; never executes a route) → a `<OperationId>Response` component on the `200` `content`, and the client returns `Promise<ThatShape>`. Only KEYS are provable (values type `unknown` unless trivially safe — a boolean literal, or the ADR-0119 `pageMeta()` `page` block → a shared `PageInfo`). Degrade-gracefully: a bare value (`res.json(row)`), a top-level spread, an empty `{}`, or a computed key keeps `ApiResponse` — we only ever narrow `any`, never mistype. 162/221 endpoints now typed; runtime JS unchanged (only `openapi.json` + `index.d.ts`). Rejected: a per-route `responseSchema` export (duplicates the `res.json` truth + 200-file churn, anti-code-first), executing routes to observe responses (breaks the zero-execution contract), tracing variables to type values (brittle data-flow analysis), `additionalProperties:false` on responses (a false compile error if an unparsed branch adds a key).](0124-typed-api-response-bodies.md) | API contract / goal 36 |
202
202
  | 0125 | [The control plane scaffolds + pushes a standalone instance's repo (task [#2055](https://example.com/builders#/task/2055), BONGOS-V1, goal 35's `project-creation-flow`). `provision.js` used to THROW if a standalone instance's repo wasn't already hand-cloned at `/srv/cloudbongos/<slug>` — a manual dead-end between `bongos init` and provision. Decision: the provisioning runner SCAFFOLDS the repo itself (`scaffoldStandaloneRepo`) before the existing code-sync/db/service steps: clone the empty `target_ref` (forcing branch `main` on the unborn HEAD) → build the core tarball (`package-core.js`) → materialize `config/` + `.claude/` + a `file:`-pinned `package.json` + lockfile via `bongos init --vendor-core` (task 2053, `--no-seed`) → commit + push (with a `node_modules`-excluding `.gitignore`; vendored tarball + lockfile stay tracked). Idempotent (skips once `package.json` exists) + dry-run-safe (injected `exec`/`writeFile`). Git transport reuses the existing `github-deploy` SSH alias (no secret in any command) — the one operational grant is that the control plane's deploy key gets WRITE on each instance repo (per-repo, like the platform repo). `deploy-instance.sh` already existed at `scripts/deploy/` (ADR 0108 §4 / task 1884; the task description predated it) — it's the ongoing consumer deploy, not the first-time scaffold; no `infra/` duplicate created. Rejected: a control-plane PAT/App-token over HTTPS (token-leak risk in the URL + a new broad secret class — a GitHub App is the scalable follow-up), keeping the "clone it yourself first" throw (the dead-end itself), scaffolding from a template fork (a template repo to maintain vs `--vendor-core`'s single-source tree).](0125-control-plane-scaffolds-standalone-instance-repo.md) | Cloud Bongos / onboarding |
203
203
  | 0126 | [Dedicated cloudbongos.com control-plane droplet (task [#2063](https://example.com/builders#/task/2063), BONGOS-V1, goal 26). cloudbongos.com ran as a co-tenant on the OTB game droplet with the account-wide DO+Cloudflare tokens co-located on that same shared box — the powerful tokens sat next to the game and third-party co-hosted instances. Owner pulled the infra separation forward (accepting a 2nd droplet): a new dedicated droplet `REDACTED_IP` (nyc3, 2GB, tag `cloudbongos-control`, own `cloudbongos_ed25519` SSH) runs cloudbongos.com + `builders.`/`status.` AND is the sole token-holding control plane (`/etc/cloudbongos/box.env` holds the new account-wide CF token IP-locked to it + the DO token — where `provision.js` provisions subdomains). Co-hosting box keeps the game + `emersonian-circles.`/`staging.`; the co-tenant unit was decommissioned at cutover. Lift-and-shift (same repo clone, not the ADR 0108 pinned-core standalone). De-risks R89. Follow-ups: new-box deploy automation ([#2064](https://example.com/builders#/task/2064)) + strip all cloud tokens off the co-hosting box ([#2065](https://example.com/builders#/task/2065)). IPv6 egress disabled so the IPv4-locked CF token isn't spuriously rejected.](0126-dedicated-cloudbongos-control-plane-droplet.md) | infra / Cloud Bongos |
204
- | 0127 | [Cross-box fleet observability on the control-plane droplet (task [#2059](https://example.com/builders#/task/2059), BONGOS-V1). The Grafana/Prometheus stack (task 2020) exports Bongos-API metrics = *platform* observability, so it was re-homed off `metrics.example.com` onto the control-plane box at `metrics.cloudbongos.com`, watching the whole fleet. After ADR 0126 split the fleet across two droplets, Prometheus (on the control-plane box) scrapes `cloudbongos` locally and the co-hosting box's `example`+`emersonian` over the private VPC via `socat` TCP forwarders (`10.108.0.2:9101/9102`), ufw-locked to the control-plane IP. socat (raw relay) preserves the exporter's loopback-no-token semantics → no token, no game restart; the VPC+ufw is the boundary; the `METRICS_TOKEN` Bearer path is reserved for future off-VPC instances. Dashboards gained a `project` template var. Trade-off: socat exposes the whole app port (not just `/metrics`) to the trusted control-plane box over the VPC — accepted (that box already holds the account-wide tokens). Follow-up: token/DNS-SD discovery for provisioned instances.](0127-cross-box-fleet-observability.md) | infra / Cloud Bongos |
204
+ | 0127 | [**Superseded by ADR 0284** (2026-09-15 — the stack was removed). Cross-box fleet observability on the control-plane droplet (task [#2059](https://example.com/builders#/task/2059), BONGOS-V1). The Grafana/Prometheus stack (task 2020) exports Bongos-API metrics = *platform* observability, so it was re-homed off `metrics.example.com` onto the control-plane box at `metrics.cloudbongos.com`, watching the whole fleet. After ADR 0126 split the fleet across two droplets, Prometheus (on the control-plane box) scrapes `cloudbongos` locally and the co-hosting box's `example`+`emersonian` over the private VPC via `socat` TCP forwarders (`10.108.0.2:9101/9102`), ufw-locked to the control-plane IP. socat (raw relay) preserves the exporter's loopback-no-token semantics → no token, no game restart; the VPC+ufw is the boundary; the `METRICS_TOKEN` Bearer path is reserved for future off-VPC instances. Dashboards gained a `project` template var. Trade-off: socat exposes the whole app port (not just `/metrics`) to the trusted control-plane box over the VPC — accepted (that box already holds the account-wide tokens). Follow-up: token/DNS-SD discovery for provisioned instances.](0127-cross-box-fleet-observability.md) | infra / Cloud Bongos |
205
205
  | 0128 | [Provisioning runner privilege + DB-auth model (task [#2066](https://example.com/builders#/task/2066), BONGOS-V1, goal 35's `project-creation-flow`). [ADR 0111](0111-instance-hosting-provisioning-module.md)'s `provision.js` was built + unit-tested but NEVER run end-to-end (existing instances were hand-wired); the first real greenfield standup ([#2053](https://example.com/builders#/task/2053)–[#2056](https://example.com/builders#/task/2056)) exposed that the runner (`User=lars`, non-root) can't do its own mutating steps: `/etc` writes (`EACCES`), `systemctl` reloads, and `createdb`/`migrate` (peer-auth-as-lars, but lars isn't a usable PG role — the app uses `DATABASE_URL`). Decision: a `PROVISION_SUDO=1` knob (alias `PROVISION_PRIVILEGED`), **default OFF** (bare commands + direct `fs` writes — tests/root/co-tenant-legacy byte-unchanged); ON escalates exactly those steps — `/etc` writes via temp-file + `sudo install -D -m <mode>`, `sudo systemctl`/`sudo rm`, and `createdb`/`migrate` as `sudo -u postgres` (migrate passes env via `env …`+`bash …` to survive sudo's env reset). Dry-run always logs the exact command. The runner unit gains `Environment=PROVISION_SUDO=1` and **must** flip `NoNewPrivileges` false (a `true` value sets `PR_SET_NO_NEW_PRIVS`, silently blocking the setuid `sudo`). Preconditions: passwordless sudo for the runner user + a postgres-readable checkout. Rejected: granting `lars` a PG role + writable `/etc` (non-portable, weakens the trust boundary), running the whole runner as root (over-privileged vs per-step sudo), createdb/migrate via the app `DATABASE_URL` (valid alt, but the runner holds no app DB creds today). The dedicated-droplet cloud-init path is left unchanged (root context, out of scope).](0128-provisioning-runner-privilege-and-db-auth-model.md) | infra / Cloud Bongos |
206
206
  | 0129 | [cloudbongos.com control-plane box deploys by a pull timer, not the CI push (task [#2064](https://example.com/builders#/task/2064), BONGOS-V1, follows ADR 0126). After the dedicated-droplet migration, the co-hosting box's CI deploy only touched example.com — nothing pulled `main` onto the new box, so cloudbongos.com drifted. Chosen: a pull-based `systemd` timer (`infra/cloudbongos-deploy.{service,timer}` → `scripts/deploy/deploy-cloudbongos.sh`: fetch+reset → npm ci if lockfile changed → `MEDUSA_GDS_ONLY` GDS-only migrate of the `cloudbongos` DB → restart `cloudbongos` → 3002 healthz), every ~2 min with a fast no-op path. Rejected extending the CI push: that would store THIS box's prod SSH key as a GitHub Actions secret — more sprawl, against ADR 0126's blast-radius reduction; the pull uses the box's existing `github-deploy` key and touches no shared secret. Two divergences from `deploy-prod.sh`: the executed script is a stable copy at `/home/lars/deploy.sh` (so `git reset` can't corrupt the running script), and the SEC [#863](https://example.com/builders#/task/863) audit gate is non-fatal here (the cloudbongos DB can't resolve OTB author ranks; the authoritative gate runs on the co-hosting box + CI against the same main). One-time operator bootstrap; self-heals after.](0129-cloudbongos-control-plane-pull-deploy.md) | infra / Cloud Bongos |
207
207
  | 0130 | [provision.js remote-exec over SSH — the control plane orchestrates co-tenant workloads on the co-hosting box (task [#2065](https://example.com/builders#/task/2065), BONGOS-V1, follows ADR 0126). To reach ZERO cloud tokens on the co-hosting box, the provisioning runner must move to the token-holding control plane yet still drive co-tenants that RUN on the co-hosting box. Decision: thread a target through `provision.js`'s injected executors — box-target `exec`/`writeFile` run box-mutating steps (createdb, migrate, /etc, systemctl, Caddy, healthz) on `PROVISION_REMOTE_HOST` over `ssh <host> bash -s` (stdin-fed, no quoting; files base64→`sudo tee`); `controlExec`/`controlWriteFile` keep the token step (Cloudflare DNS hook) + the standalone repo scaffold LOCAL where the token/toolchain live. Default OFF (unset ⇒ byte-identical local, today's behavior). `box.js` needs no change (pure DO/CF API — runs from the control plane with the tokens). `PROVISION_PUBLIC_IP` = the co-hosting box IP (co-tenant A records point where they run). Ships a control-plane runner unit (`infra/provision-intent-runner-cloudbongos.{service,timer}`, later superseded by `cohost-provision-intent-runner.*` — ADR 0132). Creates a control-plane→co-hosting SSH trust edge (token-holder commands token-free box, never reverse). Activation (SSH trust · runner move · token removal+rotation · rollback-DB drop) is a sequenced OWNER op — blocker attached to 2065.](0130-provision-remote-exec-over-ssh.md) | infra / Cloud Bongos |
@@ -375,3 +375,4 @@ This keeps the decision history honest and traceable.
375
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](0111-instance-hosting-provisioning-module.md) made standup automatic and [ADR 0128](0128-provisioning-runner-privilege-and-db-auth-model.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).](0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) | tenant isolation / provisioning / DB auth |
376
376
  | 0282 | [**A module that spends money for its callers DECLARES so, and the core refuses to enable it where anyone can sign up and nobody is on the hook** ([task 1003884](https://cloudbongos.com/builders#/task/1003884), owner decision 2026-09-13). A fire from `modules/agents` costs ~$0.90, `POST /agents/:name/invoke` answers **202** so the caller does not even wait, and the route is gated at `requireBuilder` and nothing else — deliberately, because a v1 agent is hard-read-only by plumbing. That bounds CAPABILITY, not SPEND, and `lib/fire-budget.js` says so in its own header: it is a blast radius, explicitly NOT a budget, and it defers the real cap to `agents_runs.cost_usd`. The cap did not exist. The exposure is three reasonable facts composed: a fire is expensive and async; invoke admits any **any-builder**; and with the join door `open`, any GitHub account IS one seconds after sign-up. **Decision: the MODULE declares `"spend": { "requiresPayer": true }` and the CORE refuses to enable it when the composed join door (ADR 0247 — the door, never the `joinability` knob alone) is `open` with no configured payer.** The declaration lives in the manifest because fitness Check 10 forbids the kernel from naming a discovered module key — a constraint that turns out to be the better design, since the next metered module inherits the guard by declaring one line rather than by someone remembering to widen a list. The refusal is at **config load**, so the routes never mount and the migrations never run: a per-request check would let the module mount, advertise its routes and read as enabled everywhere the UI looks, then refuse callers one at a time — a broken feature, not a closed door. It is applied AFTER config, env and default alike, because `<PREFIX>_MODULE_<KEY>` is exactly the knob a hurried deploy reaches for and a wall env could outrank is a suggestion. An UNREADABLE posture refuses too, inverting this file’s usual fail-to-unchanged default: "I could not tell whether strangers can sign up" has to mean "assume they can", and the cost of being wrong is one module resolving off. `spend_payer` (`none|project|builder`) joins `SETTINGS_VOCAB` on ADR 0241’s transport, and `agents_runs` gains `payer_kind` + `payer_builder_id` — **stored, not derived**, because the setting is a live knob and deriving would make last month’s bill change when an owner flips it. `none` is NOT a third payer: a run always has a real one and `none` attributes to the project, so a CLOSED-door instance is unchanged and `none` is fatal only where it is also dangerous. An EVENT fire has no caller, so it bills the project rather than nobody — otherwise the event path is the one unbounded route. The per-payer monthly ceiling (`lib/spend-ceiling.js`) is a SECOND wall that must never merge into the first: a rate ceiling cannot bound a bill (~$650/month/builder at 10 fires an hour) and a spend ceiling cannot bound a loop (it reads spend already landed). It records NOTHING — the ledger is the tally, and a second tally is the disagreeing answer fire-budget’s header refuses to become. It honours the finding of abandoned [task 1001393](https://cloudbongos.com/builders#/task/1001393), which caught the autonomy brake reading month-to-date spend from a DB it could not reach and **failing open**: every unknown refuses (no cap, non-finite cap, no payer, a read that threw, a sum absent or unparseable), answering **402** rather than fire-budget’s 429 so "the money is gone" and "too fast" stay distinguishable. Writing the absent-sum clause down was not paranoia — the first draft admitted the fire on a null sum because `Number(null)` is `0`, and the test for that clause is what caught it. The overshoot is STATED: in-flight fires are invisible to the read, bounded by concurrency × ~$0.90, and what bounds concurrency is fire-budget — each wall’s blind spot is the other’s window. The cost-needs-a-payer constraint is added `NOT VALID` so it binds every future write without failing the migration of an instance whose history predates the column. **What it does not do:** nobody is billed; `builder` attributes spend, it does not collect.](0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md) | modules / spend attribution / agents |
377
377
  | 0283 | [**The core publishes publicly, because access was never the thing protecting it** ([task 1003872](https://cloudbongos.com/builders#/task/1003872) · goal 1000090 — *Working area 4, Bongos Core distribution*; owner decision 2026-09-13, resolving [task 1003502](https://cloudbongos.com/builders#/task/1003502)). [ADR 0134](0134-private-first-npm-distribution.md) chose private-first and named "public at launch" as a later deliberate step; this is that step. The restricted posture had a cost that grew quietly because every symptom read as a different bug: a tokenless `npm install` returns a flat **E404**, indistinguishable from "no such package", so every co-tenant and downstream upgrade needed a credential to move at all; the nightly auto-upgrade sweep **died on 2026-08-25 and reported `failed=0`** (its unit passed no `NPM_TOKEN`, the env-fed `.npmrc` expanded to empty, and a skip is not a failure in that script's accounting, so nothing complained while the instance stopped receiving core updates); an Anthropic-hosted cloud session could not install the dependency at all; and a fresh scaffold's README step 1 IS `npm install`, which is also what installs the `bongos` command — so without a token an owner got no working instance rather than a stale one. [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md)'s `@cloudbongos/cli`, require-closure delazification and all, exists ONLY because of this. **The decisive point is that access control was never what protected the artifact:** three gates decide what is IN the tarball — the in-build no-leak gate (branding literals + credential shapes, fail-closed, 2,624 files clean), the prior-instance leak scan (**fatal by default since [task 1003870](https://cloudbongos.com/builders#/task/1003870)**, `--no-leak-scan` the only escape), and the fixture confirmation of [task 1003871](https://cloudbongos.com/builders#/task/1003871) (all 14 credential-shape files checked against each provider's real key format: 11 too short to have ever been valid, 3 alphabet sequences or Amazon's own published doc example). `--access restricted` decides who may FETCH it; those three decide what is IN it, and conflating them is what made a launch-timing choice feel like a security control. **Decision: publish public — at all three sites.** `publishConfig.access` and `publishArgs` both say `public` (belt-and-braces of the restricted era pointed the other way, so neither drifting alone reverts it), **and so does `publish.yml`** — the site that ACTUALLY releases, `package-core --publish` being the manual owner path. Flipping only `package-core.js` would have left every real release restricted while the code claimed otherwise: exactly task 1003870's shape, where the workflow did not arm the gate its own header comment advertised. A test reads the workflow and fails if `restricted` reappears anywhere in the lane. **The one-time registry flip stays a separate owner step** (`npm access set status=public`): `--access` governs what THAT publish declares and has historically been a no-op when re-publishing an already-private package — the package read `private` on the registry when this was written — so it is verified by a tokenless install from a clean `HOME`, never by the flag's presence. **Provenance stays off:** the PACKAGE is public, the SOURCE REPO is not, and npm blocks provenance on the repo's visibility — turning it on before the source repository goes public fails every publish. The npm path deliberately does not run [ADR 0099](0099-delayed-redacted-mirror-export.md)'s mirror pipeline (`package-core` runs its own redaction: 476 docs redacted, 24 agent-docs stubbed), which is why gates 1–3 carry the whole safety burden and had to land first. **Going public is effectively one-way** — a published tarball is mirrored and cached beyond our control and `npm unpublish` has a 72-hour window that does not reach mirrors. **Does NOT fix the 22 co-tenants:** twenty pin `file:vendor/bongos-core-<v>.tgz`, a local path with no registry channel to follow, so public access is necessary but not sufficient. Rejected: a **rank-gated registry proxy** (server holds the token, builders authenticate with their existing Bongos session, per-request rank check — the RIGHT design if the core must stay private, rejected because it manages the problem going public deletes); **handing builders the read token gated by rank** (rank-gating the handout does not rank-gate the use — a token once held is held forever and revocation means rotating it for everyone: access control in name only); keeping the vendored tarball as the answer (it is why twenty co-tenants are frozen at their provisioning date); making the REPO public in the same change (a larger decision, not required for any of this, and separating them keeps the provenance question honest).](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) | core distribution / npm access |
378
+ | 0284 | [**The control plane carries no metrics stack** ([task 1003876](https://cloudbongos.com/builders#/task/1003876) · goal 1000106). Supersedes ADR 0127. Grafana (187 MB, the box's largest process) + Prometheus (110 MB, 2.5 GB data) were removed from the `s-1vcpu-2gb` control plane every provisioned project shares; they scraped none of those projects and nothing in the core read them. Owner chose remove over move-to-own-droplet. The `/metrics` exporter stays in every instance, so a scraper can return from a box that is not the control plane. Leftovers (DNS record, co-hosting-box socat forwarders, instance `infra/` copies) filed as a follow-up.](0284-the-control-plane-carries-no-metrics-stack.md) | infra / Cloud Bongos |
@@ -15,7 +15,7 @@
15
15
  - **Server stack:** Node.js 22 LTS · PostgreSQL 16 · Caddy 2.11.2. `example.service` (systemd) runs `node server.js` as user `lars` on `127.0.0.1:3000`; restart-on-failure.
16
16
  - **Hardening:** `lars` user with passwordless sudo; SSH key-only (root login disabled, password auth disabled); ufw allows 22/80/443; fail2ban active; unattended-upgrades enabled; timezone `America/New_York`.
17
17
  - **TLS:** Origin cert at `/etc/caddy/origin.crt` + `/etc/caddy/origin.key` for the example apexes on the co-hosting box. **cloudbongos.com's apexes moved to the dedicated control-plane droplet** ([ADR 0126](adr/0126-dedicated-cloudbongos-control-plane-droplet.md)) with their own copy of the Cloudflare Origin Cert (SAN `*.cloudbongos.com, cloudbongos.com`) at `/etc/caddy/cloudbongos-origin.{crt,key}` on `REDACTED_IP`. Cloudflare SSL/TLS mode = Full (strict). Renewal is a non-issue for ~15 years. **Provisioned instance hosts use a different, coexisting posture** ([ADR 0111](adr/0111-instance-hosting-provisioning-module.md) §4 / task P4): Caddy **on-demand ACME** (Let's Encrypt) via the global `on_demand_tls { ask … }` block, gated to hostnames the `provisioning` module actually stood up (`/api/bongos/provisioning/tls-check`). Their DNS records are **proxied=false (grey cloud)** so the ACME challenge reaches Caddy — the opposite of the origin-cert apexes; do not enable the proxy on them or renewal breaks silently ([ADR 0003](adr/0003-cloudflare-origin-cert-over-letsencrypt.md)). Per-instance site snippets live in `/etc/caddy/sites/*.caddy` (`import`ed by `infra/Caddyfile`). **Tradeoff (accepted, SEC review of task 1945):** `proxied=false` publishes the box's real origin IP in public DNS for every co-tenant provisioned hostname — defeating, for those names, the Cloudflare-proxy IP-hiding the apexes enjoy (a co-tenant host resolves to the same `REDACTED_IP` that serves the main game). This is inherent to on-demand ACME (the challenge must hit the origin) and acceptable under the budget/trust model; prefer a **dedicated droplet** (its own IP) for any instance expected to draw hostile attention.
18
- - **Platform observability (task 2020 stood it up; task 2059 re-homed it cross-project onto the control-plane box; [ADR 0127](adr/0127-cross-box-fleet-observability.md)):** this watches the **Bongos API** (`gds_http`/`tasks`/`claims`/`builders`/`auth`), not any one game, so it lives **on the control-plane box** (REDACTED_IP) at **`https://metrics.cloudbongos.com`** and observes the **whole fleet**. **Prometheus** (`127.0.0.1:9090`, loopback-only) scrapes every instance's `/metrics` ([`src/bongos/metrics.js`](../src/bongos/metrics.js)), each tagged with a `project` label ([`infra/prometheus/prometheus.yml`](../infra/prometheus/prometheus.yml)): **cloudbongos** locally (`:3002`); **example + emersonian-circles** on the co-hosting box, reached over the **private VPC** via `socat` forwarders on `10.108.0.2:9101/9102` (→ their loopback `:3000`/`:3004`), ufw-locked to the control-plane box's private IP — raw TCP relays, so the exporter still sees a loopback request and needs no token; nothing is public. **Grafana** (`127.0.0.1:3003`, loopback-only) renders the 4 provisioned-as-code dashboards (folder **Cloud Bongos**: system-overview, error-watch, builder-activity, auth-security), each with a **`project`** template variable for per-instance / fleet views. Reverse-proxied on the `*.cloudbongos.com` origin cert — Archon-only, gated by Caddy `basic_auth` (user `archon`) **and** Grafana's own admin login (both secrets owner-held, never in the repo). Grafana config is a systemd `GF_*` env drop-in (`grafana-server.service.d/override.conf`, `chmod 600`). ~256 MB RAM. A future dedicated-droplet instance is added as a `METRICS_TOKEN` Bearer target instead of a VPC forwarder.
18
+ - **Platform observability — REMOVED 2026-09-15 ([ADR 0284](adr/0284-the-control-plane-carries-no-metrics-stack.md), task 1003876):** the control plane runs **no Grafana and no Prometheus**, and `metrics.cloudbongos.com` has no Caddy site. They cost ~300 MB RSS + 2.5 GB of data on the box every provisioned project shares, and scraped none of those projects. Every instance still exports `/metrics` ([`src/bongos/metrics.js`](../src/bongos/metrics.js), loopback or `METRICS_TOKEN`), so a scraper can return from a box that is *not* the control plane. The rest of this bullet is the **historical** ADR 0127 layout, kept so its leftovers (co-hosting-box forwarders, instance `infra/` copies) can be found: this watched the **Bongos API** (`gds_http`/`tasks`/`claims`/`builders`/`auth`), not any one game, so it lived **on the control-plane box** (REDACTED_IP) at **`https://metrics.cloudbongos.com`** and observed the **whole fleet**. **Prometheus** (`127.0.0.1:9090`, loopback-only) scrapes every instance's `/metrics` ([`src/bongos/metrics.js`](../src/bongos/metrics.js)), each tagged with a `project` label ([`infra/prometheus/prometheus.yml`](../infra/prometheus/prometheus.yml)): **cloudbongos** locally (`:3002`); **example + emersonian-circles** on the co-hosting box, reached over the **private VPC** via `socat` forwarders on `10.108.0.2:9101/9102` (→ their loopback `:3000`/`:3004`), ufw-locked to the control-plane box's private IP — raw TCP relays, so the exporter still sees a loopback request and needs no token; nothing is public. **Grafana** (`127.0.0.1:3003`, loopback-only) renders the 4 provisioned-as-code dashboards (folder **Cloud Bongos**: system-overview, error-watch, builder-activity, auth-security), each with a **`project`** template variable for per-instance / fleet views. Reverse-proxied on the `*.cloudbongos.com` origin cert — Archon-only, gated by Caddy `basic_auth` (user `archon`) **and** Grafana's own admin login (both secrets owner-held, never in the repo). Grafana config is a systemd `GF_*` env drop-in (`grafana-server.service.d/override.conf`, `chmod 600`). ~256 MB RAM. A future dedicated-droplet instance is added as a `METRICS_TOKEN` Bearer target instead of a VPC forwarder.
19
19
 
20
20
  **Application stack:**
21
21
 
@@ -1962,5 +1962,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1962
1962
  landed since 1.19.742 with no explicit bump. run 34984876076. (task 1002620)
1963
1963
  1.19.744 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1964
1964
  landed since 1.19.743 with no explicit bump. run 34985887902. (task 1002620)
1965
+ 1.19.745 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1966
+ landed since 1.19.744 with no explicit bump. run 34990123950. (task 1002620)
1967
+ 1.19.746 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1968
+ landed since 1.19.745 with no explicit bump. run 35021312150. (task 1002620)
1965
1969
  ---------------------------------------------------------------------------
1966
1970
  ```
@@ -0,0 +1,46 @@
1
+ -- provisioning_015_domain_unique.sql — database uniqueness on an instance's
2
+ -- ADDRESS, closing the read-then-write race both write paths still carry
3
+ -- (task 1002901; source idea 1000722, the task 1002704 review).
4
+ --
5
+ -- THE GAP. `slug` has carried `UNIQUE` since provisioning_001_tables.sql:35;
6
+ -- `domain` was declared plainly at :49 and never constrained. Both address write
7
+ -- paths enforce uniqueness in APPLICATION code with an unguarded read-then-write:
8
+ -- the create route calls getInstanceByDomain and then INSERTs, and the
9
+ -- domain-attach route calls getInstanceByDomain and then updateInstanceDomain.
10
+ -- Two concurrent requests interleaving between the read and the write both pass
11
+ -- their check, and one address ends up held by two instances.
12
+ --
13
+ -- WHY IT MATTERS MORE NOW. Task 1002704 put the domain write on the HOT PATH:
14
+ -- auto-assign stamps defaultDomainForSlug(slug, apex) on EVERY creation, so the
15
+ -- race is no longer an occasional bring-your-own step — it is every project
16
+ -- anyone creates. Working area 1's done-when is that any new user can create a
17
+ -- project; two projects answering at one hostname is that promise breaking under
18
+ -- exactly the concurrency a growing platform produces.
19
+ --
20
+ -- THE APPLICATION CHECKS STAY. This index is a BACKSTOP, not a replacement: the
21
+ -- read-then-write checks produce the good error message ("That address is
22
+ -- already held by another instance"), and this makes the guarantee real when two
23
+ -- requests interleave. The routes map this index's 23505 onto that same
24
+ -- `domain_taken` 409, so a caller cannot tell which layer caught it.
25
+ --
26
+ -- WHY PARTIAL — `status <> 'torn_down'` IS LOAD-BEARING, NOT A TIDY-UP.
27
+ -- A torn-down instance KEEPS its domain by design (ADR 0181 Decision 2: the
28
+ -- revive path re-uses that exact hostname, and task 1002897 automates only the
29
+ -- DNS-park and Caddy-removal legs while confirming that retention). An
30
+ -- UNCONDITIONAL unique index would therefore permanently block re-creating a
31
+ -- project at an address that was once torn down — a worse bug than the race it
32
+ -- closes. The partial form mirrors the ux_provisioning_intents_open precedent at
33
+ -- provisioning_001_tables.sql:129.
34
+ --
35
+ -- WHY lower(domain). It matches getInstanceByDomain's existing lookup
36
+ -- (`WHERE lower(domain) = $1`), so the index and the application check agree on
37
+ -- what "the same address" means. Without it, 'Mercury.cloudbongos.com' and
38
+ -- 'mercury.cloudbongos.com' would be two rows to the index and one address to
39
+ -- every reader.
40
+ --
41
+ -- SAFE TO CREATE. Re-verified live against the fleet at claim time (2026-09-15):
42
+ -- 23 instances, 6 rows fall under this predicate, all distinct, no case
43
+ -- variants. `IF NOT EXISTS` keeps the migration idempotent across re-runs.
44
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_instances_domain
45
+ ON provisioning_instances (lower(domain))
46
+ WHERE domain IS NOT NULL AND status <> 'torn_down';
@@ -781,6 +781,23 @@ async function getInstanceBySlug(db, slug) {
781
781
  return rows[0] || null;
782
782
  }
783
783
 
784
+ // The partial unique index provisioning_015 puts on lower(domain) — the database
785
+ // backstop behind both address write paths' read-then-write check (task 1002901).
786
+ // Named once, here, so the migration that CREATES it and the predicate that
787
+ // RECOGNIZES it cannot drift: a rename in one and not the other would leave
788
+ // isDomainTaken() silently never matching. The test reads the .sql and pins both.
789
+ const DOMAIN_UNIQUE_INDEX = 'ux_provisioning_instances_domain';
790
+
791
+ // Did this error come from two requests racing for one address? The slug UNIQUE
792
+ // and the domain index share error code 23505, so the code alone cannot tell them
793
+ // apart — `constraint` is what names which one actually fired. Both address write
794
+ // paths check uniqueness in application code first and produce the good message;
795
+ // this predicate is how the clash that slips through that window still answers
796
+ // `domain_taken` instead of surfacing as a raw 500 (task 1002901).
797
+ function isDomainTaken(e) {
798
+ return !!e && e.code === '23505' && e.constraint === DOMAIN_UNIQUE_INDEX;
799
+ }
800
+
784
801
  // The instance currently holding `domain` (case-insensitive), if any — backs the
785
802
  // domain-attach route's uniqueness check so one owner's address can never silently
786
803
  // shadow another's (task 1002692). Caller normalizes; matching is defensive anyway.
@@ -1073,11 +1090,21 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
1073
1090
  );
1074
1091
  return { instance: rows[0], created: true };
1075
1092
  } catch (e) {
1076
- // 23505 = unique_violation on the slug UNIQUE constraint.
1093
+ // 23505 = unique_violation, and since provisioning_015 TWO indexes raise it here
1094
+ // — slug UNIQUE and ux_provisioning_instances_domain. Resolve by SLUG FIRST,
1095
+ // whichever one Postgres named. That order is load-bearing: an idempotent
1096
+ // re-request duplicates the slug AND the <slug>.<apex> address auto-assign
1097
+ // stamped on the first one (task 1002704), so it violates BOTH and Postgres
1098
+ // reports only the index it checked first. Branching on `e.constraint` would
1099
+ // turn that re-request into a 409 whenever the domain index won the race — a
1100
+ // coin-flip bug that appears only once an address is assigned.
1077
1101
  if (e && e.code === '23505') {
1078
1102
  const existing = await getInstanceBySlug(db, slug);
1079
1103
  if (existing) return { instance: existing, created: false };
1080
1104
  }
1105
+ // No row under this slug, so a 23505 can only be the domain index: a real race
1106
+ // for an address taken between the route's check and this INSERT. It propagates
1107
+ // as-is, and the route maps it via isDomainTaken() onto the same domain_taken 409.
1081
1108
  throw e;
1082
1109
  }
1083
1110
  }
@@ -1453,6 +1480,8 @@ module.exports = {
1453
1480
  normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
1454
1481
  // pure — GitHub App Manifest flow (task 2080)
1455
1482
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
1483
+ // pure — the address race's database backstop (task 1002901, provisioning_015)
1484
+ DOMAIN_UNIQUE_INDEX, isDomainTaken,
1456
1485
  // data access
1457
1486
  isProvisionedHostname,
1458
1487
  getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
@@ -416,9 +416,7 @@ module.exports = function provisioningRoutes() {
416
416
  // never silently shadow another's.
417
417
  if (domain) {
418
418
  const holder = await provisioning.getInstanceByDomain(pool, domain);
419
- if (holder && holder.slug !== slug) {
420
- return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
421
- }
419
+ if (holder && holder.slug !== slug) return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
422
420
  }
423
421
  }
424
422
  const { instance, created } = await provisioning.createInstance(pool, {
@@ -522,6 +520,8 @@ module.exports = function provisioningRoutes() {
522
520
  ...(modulesIgnored ? { modules_ignored: modulesIgnored } : {}),
523
521
  });
524
522
  } catch (err) {
523
+ // Another instance took the address between the check above and the INSERT (task 1002901).
524
+ if (provisioning.isDomainTaken(err)) return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
525
525
  log.error('[provisioning] POST /provisioning/instances', err);
526
526
  res.fail('request_failed', { status: 500, message: 'internal error' });
527
527
  }
@@ -1170,9 +1170,7 @@ module.exports = function provisioningRoutes() {
1170
1170
  const releasing = current && current !== assignedDefault
1171
1171
  && provisioning.DOMAIN_RELEASE_FROM.has(inst.status) ? current : null;
1172
1172
  const holder = await provisioning.getInstanceByDomain(pool, domain);
1173
- if (holder && String(holder.id) !== String(inst.id)) {
1174
- return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
1175
- }
1173
+ if (holder && String(holder.id) !== String(inst.id)) return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
1176
1174
  if (!provisioning.DOMAIN_ATTACH_FROM.has(inst.status)) {
1177
1175
  return res.fail('domain_not_attachable', { status: 409,
1178
1176
  message: `Instance is '${inst.status}' — an address can be attached once it settles ('active') or before standup ('requested').`,
@@ -1229,6 +1227,8 @@ module.exports = function provisioningRoutes() {
1229
1227
  res.json({ ok: true, changed: true, queued, domain, replaced: releasing,
1230
1228
  message: MESSAGES[`${queued ? 'queued' : 'recorded'}:${releasing ? 'repoint' : 'first'}`] });
1231
1229
  } catch (err) {
1230
+ // Same race on the attach side: the index refuses the UPDATE, so nothing is recorded and nothing is enqueued.
1231
+ if (provisioning.isDomainTaken(err)) return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
1232
1232
  log.error('[provisioning] POST /provisioning/instances/:id/domain', err);
1233
1233
  res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
1234
1234
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.744",
3
+ "version": "1.19.746",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.744",
9
+ "version": "1.19.746",
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.744",
3
+ "version": "1.19.746",
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",
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.744'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.746'; // 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,274 @@
1
+ // tests/provisioning_domain_unique.mjs
2
+ //
3
+ // The database backstop behind an instance's ADDRESS (task 1002901; source idea
4
+ // 1000722, the task 1002704 review).
5
+ //
6
+ // Both address write paths enforced uniqueness in application code with an
7
+ // unguarded read-then-write — getInstanceByDomain, then INSERT (create) or
8
+ // updateInstanceDomain (attach). Two concurrent requests interleaving between the
9
+ // read and the write both passed, and one address ended up held by two instances.
10
+ // provisioning_015 adds the partial unique index that makes the guarantee real;
11
+ // these tests hold the four things that index changed.
12
+ //
13
+ // Covers:
14
+ // • the migration — partial on status <> 'torn_down' (ADR 0181 Decision 2 keeps
15
+ // a torn-down instance's address for the revive path, so an UNCONDITIONAL
16
+ // index would permanently block re-creating there), keyed on lower(domain) to
17
+ // match getInstanceByDomain, and idempotent
18
+ // • DOMAIN_UNIQUE_INDEX / isDomainTaken — the predicate that tells the domain
19
+ // index's 23505 apart from the slug UNIQUE's, which share the error code
20
+ // • createInstance's SLUG-FIRST resolution — the ordering that keeps an
21
+ // idempotent re-request idempotent when it violates BOTH indexes at once
22
+ // • both routes translating the index's refusal into the same domain_taken 409
23
+ // the read-then-write check produces
24
+ //
25
+ // DB-free: the service slice drives the REAL createInstance through a fake `db`
26
+ // that throws like Postgres; the routes run over the real mounted router with
27
+ // data-access fns stubbed (the provisioning_domain_attach.mjs pattern). No network.
28
+ //
29
+ // Run: node tests/provisioning_domain_unique.mjs
30
+
31
+ import { strict as assert } from 'node:assert';
32
+ import { createRequire } from 'node:module';
33
+ import fs from 'node:fs';
34
+ import http from 'node:http';
35
+ import path from 'node:path';
36
+
37
+ const require = createRequire(import.meta.url);
38
+ const provisioning = require('../modules/provisioning/provisioning.js');
39
+
40
+ let passed = 0, failed = 0;
41
+ function t(name, fn) {
42
+ try { fn(); passed++; console.log(` PASS ${name}`); }
43
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
44
+ }
45
+ async function ta(name, fn) {
46
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
47
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
48
+ }
49
+
50
+ // A unique_violation shaped like the one `pg` raises: the code alone cannot say
51
+ // WHICH index fired, which is the whole reason isDomainTaken reads `constraint`.
52
+ function uniqueViolation(constraint) {
53
+ return Object.assign(new Error(`duplicate key value violates unique constraint "${constraint}"`),
54
+ { code: '23505', constraint });
55
+ }
56
+ const DOMAIN_VIOLATION = () => uniqueViolation('ux_provisioning_instances_domain');
57
+ const SLUG_VIOLATION = () => uniqueViolation('provisioning_instances_slug_key');
58
+
59
+ console.log('\nthe migration — provisioning_015_domain_unique.sql:');
60
+
61
+ const MIGRATION = fs.readFileSync(
62
+ path.resolve('modules/provisioning/migrations/provisioning_015_domain_unique.sql'), 'utf8');
63
+
64
+ t('creates the index this module names in code — one spelling, not two', () => {
65
+ assert.match(MIGRATION, new RegExp(`CREATE UNIQUE INDEX IF NOT EXISTS ${provisioning.DOMAIN_UNIQUE_INDEX}\\b`),
66
+ 'the migration and DOMAIN_UNIQUE_INDEX must agree, or isDomainTaken silently never matches');
67
+ });
68
+
69
+ t('is PARTIAL on status <> torn_down — a torn-down address is kept for the revive path (ADR 0181 D2)', () => {
70
+ assert.match(MIGRATION, /WHERE\s+domain IS NOT NULL AND status <> 'torn_down'/,
71
+ 'an unconditional index would permanently block re-creating a project at a torn-down address');
72
+ });
73
+
74
+ t('is keyed on lower(domain) so the index and getInstanceByDomain mean the same address', () => {
75
+ assert.match(MIGRATION, /ON provisioning_instances \(lower\(domain\)\)/);
76
+ });
77
+
78
+ t('is idempotent — IF NOT EXISTS, so a re-run is a no-op', () => {
79
+ assert.match(MIGRATION, /IF NOT EXISTS/);
80
+ });
81
+
82
+ console.log('\nisDomainTaken — telling two 23505s apart:');
83
+
84
+ t('the domain index firing is a domain clash', () => {
85
+ assert.equal(provisioning.isDomainTaken(DOMAIN_VIOLATION()), true);
86
+ });
87
+
88
+ t('the SLUG unique firing is NOT — same error code, different index, different answer', () => {
89
+ assert.equal(provisioning.isDomainTaken(SLUG_VIOLATION()), false,
90
+ 'mapping a slug clash to domain_taken would tell an owner the wrong thing is taken');
91
+ });
92
+
93
+ t('a 23505 naming no constraint is not assumed to be ours', () => {
94
+ assert.equal(provisioning.isDomainTaken(Object.assign(new Error('x'), { code: '23505' })), false);
95
+ });
96
+
97
+ t('any other error code is not a clash — not even one naming our index', () => {
98
+ // A 23503 (foreign-key violation) can name this index in `constraint` too, so the
99
+ // predicate must read BOTH fields; matching on the name alone would map an
100
+ // unrelated database fault onto a 409 that tells the caller the wrong thing.
101
+ assert.equal(provisioning.isDomainTaken(
102
+ Object.assign(new Error('fk'), { code: '23503', constraint: 'ux_provisioning_instances_domain' })), false);
103
+ assert.equal(provisioning.isDomainTaken(new Error('boom')), false);
104
+ });
105
+
106
+ t('null / undefined never throw — the predicate runs inside a catch', () => {
107
+ assert.equal(provisioning.isDomainTaken(null), false);
108
+ assert.equal(provisioning.isDomainTaken(undefined), false);
109
+ });
110
+
111
+ console.log('\ncreateInstance — SLUG-FIRST resolution (the coin-flip bug this ordering prevents):');
112
+
113
+ // The real createInstance, driven through a fake db: the INSERT throws like
114
+ // Postgres, and the SELECT the catch falls back to answers for the slug.
115
+ function fakeDb({ insertThrows, slugRow }) {
116
+ const calls = [];
117
+ return {
118
+ calls,
119
+ query: async (sql, params) => {
120
+ calls.push(sql.trim().split('\n')[0]);
121
+ if (/^INSERT INTO provisioning_instances/.test(sql.trim())) throw insertThrows;
122
+ if (/SELECT \* FROM provisioning_instances WHERE slug = \$1/.test(sql)) {
123
+ return { rows: slugRow ? [{ ...slugRow, slug: params[0] }] : [] };
124
+ }
125
+ return { rows: [] };
126
+ },
127
+ };
128
+ }
129
+
130
+ await ta('a re-request violating BOTH indexes stays idempotent when Postgres names the DOMAIN one', async () => {
131
+ // Auto-assign stamps <slug>.<apex> on every creation (task 1002704), so an
132
+ // idempotent re-request duplicates the slug AND its address. Postgres reports
133
+ // only the index it checked first; branching on e.constraint would turn the
134
+ // re-request into a 409 whenever the domain index won that race.
135
+ const db = fakeDb({ insertThrows: DOMAIN_VIOLATION(), slugRow: { id: 18, owner_builder_id: 7 } });
136
+ const out = await provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'mercury', domain: 'mercury.example.com' });
137
+ assert.equal(out.created, false, 'a re-request is not a new project');
138
+ assert.equal(out.instance.id, 18, 'and it returns the EXISTING row, not an error');
139
+ });
140
+
141
+ await ta('the same re-request is idempotent when Postgres names the SLUG one (unchanged behaviour)', async () => {
142
+ const db = fakeDb({ insertThrows: SLUG_VIOLATION(), slugRow: { id: 18, owner_builder_id: 7 } });
143
+ const out = await provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'mercury', domain: 'mercury.example.com' });
144
+ assert.equal(out.created, false);
145
+ assert.equal(out.instance.id, 18);
146
+ });
147
+
148
+ await ta('a REAL address race — domain taken, slug free — propagates for the route to map', async () => {
149
+ const db = fakeDb({ insertThrows: DOMAIN_VIOLATION(), slugRow: null });
150
+ await assert.rejects(
151
+ () => provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'venus', domain: 'mercury.example.com' }),
152
+ (e) => provisioning.isDomainTaken(e),
153
+ 'no row under this slug means the clash can only be the address');
154
+ });
155
+
156
+ await ta('an unrelated database error is never swallowed as a re-request', async () => {
157
+ const db = fakeDb({ insertThrows: Object.assign(new Error('connection terminated'), { code: '57P01' }), slugRow: null });
158
+ await assert.rejects(
159
+ () => provisioning.createInstance(db, { ownerBuilderId: 7, slug: 'venus' }),
160
+ /connection terminated/);
161
+ });
162
+
163
+ // ── route slice ────────────────────────────────────────────────────────────
164
+ let instance = null;
165
+ let createThrows = null;
166
+ let updateThrows = null;
167
+ let updateCalls = [];
168
+ let enqueueCalls = [];
169
+
170
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
171
+ provisioning.getInstanceByDomain = async () => null; // the check PASSES — the race is after it
172
+ provisioning.getInstanceBySlug = async () => null;
173
+ provisioning.createInstance = async () => { if (createThrows) throw createThrows; return { instance: { id: 1 }, created: true }; };
174
+ provisioning.updateInstanceDomain = async (_db, id, domain, releasing = null) => {
175
+ updateCalls.push({ id, domain, releasing });
176
+ if (updateThrows) throw updateThrows;
177
+ return { ...instance, id, domain };
178
+ };
179
+ provisioning.enqueueIntent = async (_db, id, action) => { enqueueCalls.push({ id, action }); return { intent: { id: 9, action, state: 'pending' }, created: true }; };
180
+ provisioning.getOpenIntent = async () => null;
181
+ provisioning.recordEvent = async () => {};
182
+
183
+ const api = require('../src/module-api.js');
184
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
185
+ api.requirePermission = () => (_req, _res, next) => next();
186
+ api.branding = () => ({
187
+ domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' },
188
+ identity: { productName: 'Cloud Bongos' },
189
+ });
190
+
191
+ // Both routes are rate-limited per builder and every test here posts as builder
192
+ // 7, so the real cap would throttle the suite rather than the behaviour under
193
+ // test (the provisioning_domain_attach.mjs precedent).
194
+ const rateLimit = require('../modules/provisioning/rate-limit.js');
195
+ const realMakeAskRateLimiter = rateLimit.makeAskRateLimiter;
196
+ rateLimit.makeAskRateLimiter = (opts = {}) => realMakeAskRateLimiter({ ...opts, max: 10_000 });
197
+
198
+ const express = require('express');
199
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
200
+ const app = express();
201
+ app.use(express.json());
202
+ app.use((_req, res, next) => {
203
+ res.fail = (code, opts, details) => {
204
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
205
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
206
+ };
207
+ next();
208
+ });
209
+ app.use('/api/bongos', provisioningRoutes());
210
+ const server = app.listen(0);
211
+ await new Promise((r) => server.once('listening', r));
212
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
213
+
214
+ function post(p, body = {}) {
215
+ return new Promise((resolve, reject) => {
216
+ const payload = JSON.stringify(body);
217
+ const req = http.request(`${base}${p}`, { method: 'POST',
218
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } }, (res) => {
219
+ let d = ''; res.on('data', (c) => { d += c; });
220
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
221
+ });
222
+ req.on('error', reject);
223
+ req.end(payload);
224
+ });
225
+ }
226
+
227
+ function reset() { instance = null; createThrows = null; updateThrows = null; updateCalls = []; enqueueCalls = []; }
228
+
229
+ console.log('\nPOST /provisioning/instances — the index refusing a raced creation:');
230
+
231
+ await ta('the index catching the race answers domain_taken 409, not a raw 500', async () => {
232
+ reset();
233
+ createThrows = DOMAIN_VIOLATION();
234
+ const res = await post('/provisioning/instances', { slug: 'venus', domain: 'mercury.example.com', no_address: false });
235
+ assert.equal(res.status, 409, res.raw);
236
+ assert.equal(res.body.error.code, 'domain_taken',
237
+ 'the caller must not be able to tell which layer caught it — only the timing differs');
238
+ });
239
+
240
+ await ta('any OTHER failure is still the honest 500 — the mapping is narrow', async () => {
241
+ reset();
242
+ createThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
243
+ const res = await post('/provisioning/instances', { slug: 'venus', domain: 'mercury.example.com' });
244
+ assert.equal(res.status, 500, res.raw);
245
+ assert.equal(res.body.error.code, 'request_failed');
246
+ });
247
+
248
+ console.log('\nPOST /provisioning/instances/:id/domain — the index refusing a raced attach:');
249
+
250
+ await ta('the index catching the race answers domain_taken 409, and nothing is queued', async () => {
251
+ reset();
252
+ instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
253
+ updateThrows = DOMAIN_VIOLATION();
254
+ const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
255
+ assert.equal(res.status, 409, res.raw);
256
+ assert.equal(res.body.error.code, 'domain_taken');
257
+ assert.equal(enqueueCalls.length, 0,
258
+ 'the UPDATE was refused, so nothing was recorded — an attach intent would wire an address this instance does not hold');
259
+ });
260
+
261
+ await ta('any OTHER failure on attach is still the honest 500', async () => {
262
+ reset();
263
+ instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
264
+ updateThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
265
+ const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
266
+ assert.equal(res.status, 500, res.raw);
267
+ assert.equal(res.body.error.code, 'domain_attach_failed');
268
+ });
269
+
270
+ server.close();
271
+ rateLimit.makeAskRateLimiter = realMakeAskRateLimiter;
272
+
273
+ console.log(`\n${passed} passed, ${failed} failed`);
274
+ process.exit(failed ? 1 : 0);