@bongos/core 1.19.60 → 1.19.61

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.60",
6
- "core_contract": "1.19.60",
7
- "source_commit": "ce8873473b894cf991da712833f18dc9e5f3f88c",
5
+ "core_version": "1.19.61",
6
+ "core_contract": "1.19.61",
7
+ "source_commit": "073b10995781fbdfae0a9da943fa8755002e51bf",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T23:51:23.264Z",
9
+ "built_at": "2026-08-12T00:24:52.611Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 291,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1218,
14
+ "functional_verbatim": 1220,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1530,
20
- "tree_sha256": "acefccdf8f346aff9dfc425d717f82bc3d87cc5c680d303bd19ffc334dc9b195",
19
+ "file_count": 1532,
20
+ "tree_sha256": "c4d2ff9c96e88bf23e2c2c1acf1593d174589a1b9beeb7b3b12d61c65bb84113",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -347,12 +347,12 @@
347
347
  {
348
348
  "path": "clients/bongos-client/README.md",
349
349
  "mode": "0000644",
350
- "sha256": "8aeb1aaf87db169788d5e17a671846526a1c89f184ee6ff0425730ecea71fb8a"
350
+ "sha256": "36eb537043097dc8e05307adc06d893a2c5ceb8cceb63f24b64ade001a0a2640"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "724bfb1a3647a316226f32b1b62f56e30dba8df28a2ccfadb1dcbaaff65e7197"
355
+ "sha256": "ad598bf38511e3e7bd9f25a82987126cd71cfe76ac8275ddee2bcd90eb8e6a11"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -362,17 +362,17 @@
362
362
  {
363
363
  "path": "clients/bongos-client/index.cjs",
364
364
  "mode": "0000644",
365
- "sha256": "e4efb5ad8e209a5c27c9a85f42e920915a7d7b91491197b03a52f85091319247"
365
+ "sha256": "43823273ec587b104f3e0296614ae8ea6c4dc5c96261dbbb359ae39c21cf4e79"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "34b9ebfa393ad892c959bf356c1727188e71c61a8ff9ee0d0eca66433a51614e"
370
+ "sha256": "4deeae55ba58483557310d768a63df7cd75a1219137539b7ccbbf4481f09f5d9"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "ff501fe93dd31785d2acb8e55c9b664128065d7acf2862fb91af0800274619de"
375
+ "sha256": "6d441799f948513f840394bd382dba7e601e79a07fb7591bcb0e05aea2edd2a9"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/package.json",
@@ -1307,12 +1307,12 @@
1307
1307
  {
1308
1308
  "path": "docs/api-reference.md",
1309
1309
  "mode": "0000644",
1310
- "sha256": "27e9c0ab46db5386833b9e03bcff0dae31f4606882e845e7e830f9ef6f2550b5"
1310
+ "sha256": "a30007778606ce90b8e31470d926047a372c72b93022f54023937cfd4c5b5fc0"
1311
1311
  },
1312
1312
  {
1313
1313
  "path": "docs/api/openapi.json",
1314
1314
  "mode": "0000644",
1315
- "sha256": "bad0bdd6cdd07d66ed8b3bbc2bb2751ce1d6a0a819bc2d455952f5b0c18cea64"
1315
+ "sha256": "0e7646ee148453ef5d6d51b3434253b76f35628ee6c36681dbf6e7b27dd20c6f"
1316
1316
  },
1317
1317
  {
1318
1318
  "path": "docs/architecture.md",
@@ -2552,7 +2552,7 @@
2552
2552
  {
2553
2553
  "path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
2554
2554
  "mode": "0000644",
2555
- "sha256": "e4efb5ad8e209a5c27c9a85f42e920915a7d7b91491197b03a52f85091319247"
2555
+ "sha256": "43823273ec587b104f3e0296614ae8ea6c4dc5c96261dbbb359ae39c21cf4e79"
2556
2556
  },
2557
2557
  {
2558
2558
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -3719,6 +3719,11 @@
3719
3719
  "mode": "0000644",
3720
3720
  "sha256": "c25101daed13fc22ba3f132b2dac59c782fa564fa4bc22a6a5169e6644109b3a"
3721
3721
  },
3722
+ {
3723
+ "path": "modules/provisioning/migrations/provisioning_005_domain_attach.sql",
3724
+ "mode": "0000644",
3725
+ "sha256": "230e8a9df147495ee5113c1400c582e9e39e3286baab7e01ce7684e1b1c7860c"
3726
+ },
3722
3727
  {
3723
3728
  "path": "modules/provisioning/module.json",
3724
3729
  "mode": "0000644",
@@ -3732,7 +3737,7 @@
3732
3737
  {
3733
3738
  "path": "modules/provisioning/provisioning.js",
3734
3739
  "mode": "0000644",
3735
- "sha256": "655d7e07f8a50cd12420c67e0cb12ff4f94098e53b218b0985f866b9433e409f"
3740
+ "sha256": "48c047c48579a1cb5cc6b36c2148bcab5c1736a16c4c73c0741a8998bfe922cd"
3736
3741
  },
3737
3742
  {
3738
3743
  "path": "modules/provisioning/rate-limit.js",
@@ -3742,7 +3747,7 @@
3742
3747
  {
3743
3748
  "path": "modules/provisioning/routes/provisioning.js",
3744
3749
  "mode": "0000644",
3745
- "sha256": "c0a1a015e20c446513ae435075379e1c46cfe0e492574ff9f64d9ef39b081e4b"
3750
+ "sha256": "a490a4b4b607a302533bac6fedbc39a0153d57e35a086bcc20079fbefc6815e5"
3746
3751
  },
3747
3752
  {
3748
3753
  "path": "modules/provisioning/tests/provisioning.mjs",
@@ -4032,12 +4037,12 @@
4032
4037
  {
4033
4038
  "path": "package-lock.json",
4034
4039
  "mode": "0000644",
4035
- "sha256": "862808e3b9e59ac80baa7bcdda60a08dc8f0f31152750ea99264a5aac24c1140"
4040
+ "sha256": "9b320dfef27bca09c1ab23bb85c04b499724208dad01a608763f18b9c4e1d1b4"
4036
4041
  },
4037
4042
  {
4038
4043
  "path": "package.json",
4039
4044
  "mode": "0000644",
4040
- "sha256": "51b6e2905a863ad6c9d25eecf376a8db1f61ee960e00dd5b12f4d5aefcf36625"
4045
+ "sha256": "d1720c6790a6aa61a091335cf8873dac1e75dfbb1250c7d484395c95fd8825c6"
4041
4046
  },
4042
4047
  {
4043
4048
  "path": "public-docs/index.html",
@@ -5007,7 +5012,7 @@
5007
5012
  {
5008
5013
  "path": "scripts/gds/provision.js",
5009
5014
  "mode": "0000644",
5010
- "sha256": "93a5c4a6d6f76cf1fa8ca4c902b19d6ef94f9147488f1c01d3413f692a196942"
5015
+ "sha256": "e4108117dbe8dd6493ba41ed053fe8648424ca127b1c26505aa6f6702b2dd94c"
5011
5016
  },
5012
5017
  {
5013
5018
  "path": "scripts/gds/publish-manifest.js",
@@ -5657,7 +5662,7 @@
5657
5662
  {
5658
5663
  "path": "src/module-api.js",
5659
5664
  "mode": "0000644",
5660
- "sha256": "dd71e36bfd25965ca6b720f1fdbd94ba47553260915176d693f5246fdaac8d2e"
5665
+ "sha256": "0126fe1e9af95f3daee985c7a9859e19b5937e023d6a54927b6c5dd7055a83ec"
5661
5666
  },
5662
5667
  {
5663
5668
  "path": "src/module-loader/loader.js",
@@ -7137,7 +7142,12 @@
7137
7142
  {
7138
7143
  "path": "tests/provision.mjs",
7139
7144
  "mode": "0000644",
7140
- "sha256": "6f47c2a9c43fbef9b26090c9ca794a67bbca279f5bc79db625f26fa43775d4b7"
7145
+ "sha256": "bd8e111c2d5614f79086c5f8298cec6ffdcb7ab57b94565457520d85df0c24a8"
7146
+ },
7147
+ {
7148
+ "path": "tests/provisioning_domain_attach.mjs",
7149
+ "mode": "0000644",
7150
+ "sha256": "241e1b208abbd54a625af050d541344186d93806846a1e7f1cbb0f93dfbab802"
7141
7151
  },
7142
7152
  {
7143
7153
  "path": "tests/provisioning_github_app_routes.mjs",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 268 operations across 45 resource groups
8
+ - 269 operations across 45 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -523,6 +523,8 @@ function createClient(opts = {}) {
523
523
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
524
524
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
525
525
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
526
+ // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
527
+ postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
526
528
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
527
529
  postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
528
530
  // POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
@@ -522,6 +522,8 @@ function createClient(opts = {}) {
522
522
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
523
523
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
524
524
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
525
+ // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
526
+ postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
525
527
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
526
528
  postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
527
529
  // POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
@@ -246,6 +246,8 @@ export interface PostOverrideRequestsIdDecideRequest { decision: string; note?:
246
246
  export interface PostProjectsInviteRequest { client_id: string; github_login: string }
247
247
  export interface PostProjectsRequest { origin: string; name: string; tagline?: string; art_url?: string }
248
248
  export interface PostProjectsResponse { project: unknown }
249
+ export interface PostProvisioningInstancesIdDomainRequest { domain: string }
250
+ export interface PostProvisioningInstancesIdDomainResponse { ok: boolean; changed: boolean; queued: unknown; domain: unknown; message: unknown }
249
251
  export interface PostProvisioningInstancesIdForceTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
250
252
  export interface PostProvisioningInstancesIdGithubAppRequest { account?: string }
251
253
  export interface PostProvisioningInstancesIdGithubAppResponse { state: unknown; account: unknown; post_url: unknown; personal_url: unknown; org_url: unknown; org_login: unknown; manifest: unknown; redirect_url: unknown; expires_at: unknown }
@@ -756,6 +758,8 @@ export interface BongosClient {
756
758
  postProvisioningInstances(args: RequestArgs & { body: PostProvisioningInstancesRequest }): Promise<ApiResponse>;
757
759
  /** GET /provisioning/instances/{id} — rank: any-builder */
758
760
  getProvisioningInstancesId(args?: RequestArgs): Promise<GetProvisioningInstancesIdResponse>;
761
+ /** POST /provisioning/instances/{id}/domain — rank: any-builder */
762
+ postProvisioningInstancesIdDomain(args: RequestArgs & { body: PostProvisioningInstancesIdDomainRequest }): Promise<PostProvisioningInstancesIdDomainResponse>;
759
763
  /** POST /provisioning/instances/{id}/force-teardown — rank: metic+archon */
760
764
  postProvisioningInstancesIdForceTeardown(args?: RequestArgs): Promise<PostProvisioningInstancesIdForceTeardownResponse>;
761
765
  /** POST /provisioning/instances/{id}/github-app — rank: any-builder */
@@ -519,6 +519,8 @@ export function createClient(opts = {}) {
519
519
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
520
520
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
521
521
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
522
+ // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
523
+ postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
522
524
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
523
525
  postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
524
526
  // POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
@@ -8493,6 +8493,69 @@
8493
8493
  ]
8494
8494
  }
8495
8495
  },
8496
+ "/provisioning/instances/{id}/domain": {
8497
+ "post": {
8498
+ "operationId": "post_provisioning_instances_id_domain",
8499
+ "tags": [
8500
+ "provisioning"
8501
+ ],
8502
+ "summary": "POST /provisioning/instances/:id/domain",
8503
+ "description": "POST /provisioning/instances/:id/domain — give the caller's OWN instance its address (task 1002692, gap 5: no path existed to add a domain after creation). Validates + records the domain; for an already-ACTIVE instance it also enqueues a 'domain-attach' intent the control-plane runner drains (web tier holds no cloud tokens — ADR 0111 §2). A requested/error instance only records it: the (re)provision run wires the domain itself. Re-pointing an existing address is out of scope — refuse truthfully. Body: { domain (required) }.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
8504
+ "x-rank": "any-builder",
8505
+ "x-source": "modules/provisioning/routes/provisioning.js",
8506
+ "parameters": [
8507
+ {
8508
+ "name": "id",
8509
+ "in": "path",
8510
+ "required": true,
8511
+ "schema": {
8512
+ "type": "string"
8513
+ },
8514
+ "description": "Path parameter `id`."
8515
+ }
8516
+ ],
8517
+ "requestBody": {
8518
+ "required": true,
8519
+ "content": {
8520
+ "application/json": {
8521
+ "schema": {
8522
+ "$ref": "#/components/schemas/PostProvisioningInstancesIdDomainRequest"
8523
+ }
8524
+ }
8525
+ },
8526
+ "x-validated": true
8527
+ },
8528
+ "responses": {
8529
+ "200": {
8530
+ "description": "Success.",
8531
+ "content": {
8532
+ "application/json": {
8533
+ "schema": {
8534
+ "$ref": "#/components/schemas/PostProvisioningInstancesIdDomainResponse"
8535
+ }
8536
+ }
8537
+ }
8538
+ },
8539
+ "400": {
8540
+ "$ref": "#/components/responses/ValidationFailed"
8541
+ },
8542
+ "401": {
8543
+ "$ref": "#/components/responses/Unauthorized"
8544
+ },
8545
+ "403": {
8546
+ "$ref": "#/components/responses/Forbidden"
8547
+ },
8548
+ "404": {
8549
+ "$ref": "#/components/responses/NotFound"
8550
+ }
8551
+ },
8552
+ "security": [
8553
+ {
8554
+ "builderSession": []
8555
+ }
8556
+ ]
8557
+ }
8558
+ },
8496
8559
  "/provisioning/instances/{id}/force-teardown": {
8497
8560
  "post": {
8498
8561
  "operationId": "post_provisioning_instances_id_force_teardown",
@@ -15628,6 +15691,40 @@
15628
15691
  "project"
15629
15692
  ]
15630
15693
  },
15694
+ "PostProvisioningInstancesIdDomainRequest": {
15695
+ "type": "object",
15696
+ "properties": {
15697
+ "domain": {
15698
+ "type": "string",
15699
+ "maxLength": 253
15700
+ }
15701
+ },
15702
+ "required": [
15703
+ "domain"
15704
+ ],
15705
+ "additionalProperties": false
15706
+ },
15707
+ "PostProvisioningInstancesIdDomainResponse": {
15708
+ "type": "object",
15709
+ "properties": {
15710
+ "ok": {
15711
+ "type": "boolean"
15712
+ },
15713
+ "changed": {
15714
+ "type": "boolean"
15715
+ },
15716
+ "queued": {},
15717
+ "domain": {},
15718
+ "message": {}
15719
+ },
15720
+ "required": [
15721
+ "ok",
15722
+ "changed",
15723
+ "queued",
15724
+ "domain",
15725
+ "message"
15726
+ ]
15727
+ },
15631
15728
  "PostProvisioningInstancesIdForceTeardownResponse": {
15632
15729
  "type": "object",
15633
15730
  "properties": {
@@ -16875,9 +16972,9 @@
16875
16972
  "description": "A required dependency/feature is not configured or is temporarily down."
16876
16973
  }
16877
16974
  },
16878
- "x-endpoint-count": 268,
16879
- "x-schema-count": 300,
16975
+ "x-endpoint-count": 269,
16976
+ "x-schema-count": 302,
16880
16977
  "x-undocumented-bodies": 8,
16881
- "x-response-schemas": 203,
16978
+ "x-response-schemas": 204,
16882
16979
  "x-generated-by": "scripts/gds/gen-api-docs.js"
16883
16980
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 268 endpoints across 44 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 269 endpoints across 44 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -358,7 +358,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
358
358
  | GET | `/api/bongos/projects/featured` | `public` | — | a curated public showcase; no auth by design. |
359
359
  | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
360
360
 
361
- ## `provisioning` (13)
361
+ ## `provisioning` (14)
362
362
 
363
363
  | Method | Path | Rank | Body | Description |
364
364
  |---|---|---|---|---|
@@ -370,6 +370,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
370
370
  | GET | `/api/bongos/provisioning/instances` | `any-builder` | — | GET /provisioning/instances — the caller's own instances + each open intent. |
371
371
  | POST | `/api/bongos/provisioning/instances` | `any-builder` | `slug`, `target_ref`, `hosting_shape`, `domain`, `tier`, `onboard_mode` | POST /provisioning/instances — request a new instance. |
372
372
  | GET | `/api/bongos/provisioning/instances/:id` | `any-builder` | — | GET /provisioning/instances/:id — read one of the caller's own instances. |
373
+ | POST | `/api/bongos/provisioning/instances/:id/domain` | `any-builder` | `domain` | POST /provisioning/instances/:id/domain — give the caller's OWN instance its address (task 1002692, gap 5: no path existed to add a domai… |
373
374
  | POST | `/api/bongos/provisioning/instances/:id/force-teardown` | `metic+archon` | — | POST /provisioning/instances/:id/force-teardown — Archon tears down ANY instance. |
374
375
  | POST | `/api/bongos/provisioning/instances/:id/github-app` | `any-builder` | `account` | POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding link for the caller's OWN instance (task 2080, ADR 01… |
375
376
  | POST | `/api/bongos/provisioning/instances/:id/teardown` | `any-builder` | — | POST /provisioning/instances/:id/teardown — request teardown of the caller's OWN instance. |
@@ -522,6 +522,8 @@ function createClient(opts = {}) {
522
522
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
523
523
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
524
524
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
525
+ // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
526
+ postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
525
527
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
526
528
  postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
527
529
  // POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
@@ -0,0 +1,20 @@
1
+ -- provisioning_005_domain_attach.sql — the domain-attach intent action
2
+ -- (task 1002692, gap 5 of the creation-flow audit / idea 1000677).
3
+ --
4
+ -- WHY. No path existed to give an EXISTING instance its address: a slug re-request
5
+ -- silently dropped the domain in the body, and a domainless instance stayed
6
+ -- domainless forever — which also blocked GitHub sign-in (the github-app manifest
7
+ -- route requires a domain). The new owner-scoped POST /provisioning/instances/:id/
8
+ -- domain records the domain and, for an already-ACTIVE instance, enqueues a
9
+ -- 'domain-attach' intent; the control-plane runner (the sole token-holder, ADR 0111
10
+ -- §2) re-asserts the domain-dependent standup legs (web.env origins + service
11
+ -- restart, Cloudflare A-record UPSERT, Caddy snippet + reload, healthz). The web
12
+ -- tier still only ENQUEUES — the trust boundary is unchanged.
13
+ --
14
+ -- Widening a CHECK enum is additive (permits MORE values; no row changes) — the
15
+ -- provisioning_003 precedent. Applied only when the provisioning module is enabled.
16
+ -- Safe to re-run. Keep INTENT_ACTIONS in provisioning.js in lockstep.
17
+
18
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
19
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
20
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach'));
@@ -33,7 +33,10 @@ const ONBOARD_MODES = ['greenfield', 'adopt'];
33
33
  // conversion. Today the runner drains it by scanning provisioning_oauth_manifests
34
34
  // (drainOAuthManifests), not the intent queue, but it stays a valid action so the
35
35
  // widened CHECK (migration provisioning_003) and this list never drift.
36
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest'];
36
+ // 'domain-attach' (task 1002692, migration provisioning_005): re-assert the
37
+ // domain-dependent standup legs for an already-ACTIVE instance that was just
38
+ // given its address via POST /provisioning/instances/:id/domain.
39
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach'];
37
40
  // The states from which a teardown is meaningful (there is something to reclaim).
38
41
  // Mirrors dev-box canTransition('deprovision', …): a requested/torn_down/error
39
42
  // instance has nothing running to tear down via the happy path.
@@ -44,6 +47,11 @@ const TEARDOWN_FROM = new Set(['provisioning', 'active', 'tearing_down']);
44
47
  // teardown on the action-blind ux_provisioning_intents_open (task 1002691);
45
48
  // 'tearing_down' is mid-reclaim (any open slot belongs to the teardown).
46
49
  const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 'torn_down']);
50
+ // The states from which attaching a domain is meaningful (task 1002692): 'active'
51
+ // needs a 'domain-attach' intent (the runner re-asserts the domain legs);
52
+ // 'requested'/'error' only record it — the (re)provision run wires the domain
53
+ // itself. Mid-flight or torn-down states refuse truthfully.
54
+ const DOMAIN_ATTACH_FROM = new Set(['requested', 'error', 'active']);
47
55
 
48
56
  // Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
49
57
  // (ADR 0111 §6), never a bill. A small local table so this module needs no
@@ -254,6 +262,29 @@ async function getInstanceBySlug(db, slug) {
254
262
  return rows[0] || null;
255
263
  }
256
264
 
265
+ // The instance currently holding `domain` (case-insensitive), if any — backs the
266
+ // domain-attach route's uniqueness check so one owner's address can never silently
267
+ // shadow another's (task 1002692). Caller normalizes; matching is defensive anyway.
268
+ async function getInstanceByDomain(db, domain) {
269
+ if (!domain) return null;
270
+ const { rows } = await db.query(
271
+ `SELECT * FROM provisioning_instances WHERE lower(domain) = $1 LIMIT 1`,
272
+ [String(domain).toLowerCase()]
273
+ );
274
+ return rows[0] || null;
275
+ }
276
+
277
+ // Record an instance's domain — the ONLY column this touches (task 1002692). Status
278
+ // changes stay setInstanceStatus's job; keeping this narrow means the domain-attach
279
+ // route can never drift an instance's lifecycle as a side effect.
280
+ async function updateInstanceDomain(db, id, domain) {
281
+ const { rows } = await db.query(
282
+ `UPDATE provisioning_instances SET domain = $2, updated_at = now() WHERE id = $1 RETURNING *`,
283
+ [id, domain]
284
+ );
285
+ return rows[0] || null;
286
+ }
287
+
257
288
  // The caller's own instances, newest first.
258
289
  async function listInstancesForOwner(db, ownerBuilderId) {
259
290
  const { rows } = await db.query(
@@ -571,15 +602,15 @@ async function costLedger(db, { limit = 500 } = {}) {
571
602
  module.exports = {
572
603
  // constants
573
604
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, INTENT_ACTIONS, TEARDOWN_FROM,
574
- PROVISION_ENQUEUE_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
605
+ PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
575
606
  // pure
576
607
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
577
608
  // pure — GitHub App Manifest flow (task 2080)
578
609
  githubAppName, buildGithubAppManifest, manifestPostUrl,
579
610
  // data access
580
611
  isProvisionedHostname,
581
- getInstanceById, getInstanceBySlug, listInstancesForOwner, listAllInstances,
582
- createInstance, setInstanceStatus,
612
+ getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
613
+ createInstance, setInstanceStatus, updateInstanceDomain,
583
614
  enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
584
615
  recordEvent, instanceStateForSlug, costLedger,
585
616
  // data access — GitHub App Manifest attempts (task 2080)
@@ -361,11 +361,24 @@ module.exports = function provisioningRoutes() {
361
361
  actor: 'api:self',
362
362
  }).catch(() => {});
363
363
  }
364
+ // A re-request cannot change an existing instance's domain — SAY so instead of
365
+ // silently dropping it (task 1002692); the domain route is the real path.
366
+ let domainIgnored;
367
+ if (!created && body.domain) {
368
+ const asked = provisioning.normalizeHostname(body.domain);
369
+ if (asked && asked !== provisioning.normalizeHostname(instance.domain)) {
370
+ domainIgnored = {
371
+ requested: asked, current: instance.domain || null,
372
+ hint: `a re-request never changes an existing instance's address — POST /provisioning/instances/${instance.id}/domain instead`,
373
+ };
374
+ }
375
+ }
364
376
  res.status(created ? 201 : 200).json({
365
377
  instance: provisioning.publicInstance(instance),
366
378
  open_intent: intent ? { action: intent.action, state: intent.state } : null,
367
379
  queued: intentCreated,
368
380
  created,
381
+ ...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
369
382
  });
370
383
  } catch (err) {
371
384
  console.error('[provisioning] POST /provisioning/instances', err);
@@ -454,6 +467,84 @@ module.exports = function provisioningRoutes() {
454
467
  }
455
468
  });
456
469
 
470
+ // POST /provisioning/instances/:id/domain — give the caller's OWN instance its
471
+ // address (task 1002692, gap 5: no path existed to add a domain after creation).
472
+ // Validates + records the domain; for an already-ACTIVE instance it also enqueues
473
+ // a 'domain-attach' intent the control-plane runner drains (web tier holds no
474
+ // cloud tokens — ADR 0111 §2). A requested/error instance only records it: the
475
+ // (re)provision run wires the domain itself. Re-pointing an existing address is
476
+ // out of scope — refuse truthfully. Body: { domain (required) }.
477
+ router.post('/provisioning/instances/:id/domain', auth.requireBuilder, async (req, res) => {
478
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
479
+ if (!id) return;
480
+ if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
481
+ try {
482
+ const domain = provisioning.normalizeHostname((req.body || {}).domain);
483
+ if (!domain) {
484
+ return res.fail('bad_domain', 400, { reason: 'a real multi-label hostname (e.g. myproject.example.com) — no scheme, path, or bare TLD' });
485
+ }
486
+ const inst = await provisioning.getInstanceById(pool, id);
487
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
488
+ return res.fail('instance_not_found', 404);
489
+ }
490
+ const current = provisioning.normalizeHostname(inst.domain);
491
+ if (current && current === domain) {
492
+ return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
493
+ message: 'This instance already has that address.' });
494
+ }
495
+ if (current) {
496
+ return res.fail('domain_already_set', { status: 409,
497
+ message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
498
+ details: { current: inst.domain } });
499
+ }
500
+ const holder = await provisioning.getInstanceByDomain(pool, domain);
501
+ if (holder && String(holder.id) !== String(inst.id)) {
502
+ return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
503
+ }
504
+ if (!provisioning.DOMAIN_ATTACH_FROM.has(inst.status)) {
505
+ return res.fail('domain_not_attachable', { status: 409,
506
+ message: `Instance is '${inst.status}' — an address can be attached once it settles ('active') or before standup ('requested').`,
507
+ details: { status: inst.status } });
508
+ }
509
+ // Record BEFORE enqueueing: the runner reads the row's domain, so the intent
510
+ // must never race ahead of the column it wires.
511
+ await provisioning.updateInstanceDomain(pool, id, domain);
512
+ let queued = false;
513
+ if (inst.status === 'active') {
514
+ const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
515
+ if (!created && intent.action !== 'domain-attach') {
516
+ // The open slot is held by other work (e.g. a teardown). The address IS
517
+ // recorded — say exactly what happened and what to do.
518
+ return res.fail('intent_conflict', { status: 409,
519
+ message: `The address was recorded, but a '${intent.action}' run is currently ${intent.state} for this instance — re-send this request once it settles to wire the address.`,
520
+ details: { action: intent.action, state: intent.state, recorded: true } });
521
+ }
522
+ queued = created;
523
+ if (!created) {
524
+ // an identical attach is already open — idempotent
525
+ await provisioning.recordEvent(pool, {
526
+ instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
527
+ detail: `domain ${domain} recorded (attach already queued)`, actor: 'api:self',
528
+ }).catch(() => {});
529
+ return res.json({ ok: true, changed: true, queued: false, domain,
530
+ message: 'That address is already being wired for this instance.' });
531
+ }
532
+ }
533
+ await provisioning.recordEvent(pool, {
534
+ instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
535
+ detail: queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`,
536
+ actor: 'api:self',
537
+ }).catch(() => {});
538
+ res.json({ ok: true, changed: true, queued, domain,
539
+ message: queued
540
+ ? 'Your instance is being wired to its new address — it will answer there shortly.'
541
+ : 'Address recorded — it will be wired when the instance is provisioned.' });
542
+ } catch (err) {
543
+ console.error('[provisioning] POST /provisioning/instances/:id/domain', err);
544
+ res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
545
+ }
546
+ });
547
+
457
548
  // POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding
458
549
  // link for the caller's OWN instance (task 2080, ADR 0133). rank: any authenticated
459
550
  // builder (own resource). Returns the pre-filled manifest + the GitHub form-POST target
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.60",
3
+ "version": "1.19.61",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.60",
9
+ "version": "1.19.61",
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.60",
3
+ "version": "1.19.61",
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",
@@ -1510,6 +1510,90 @@ runcmd:
1510
1510
  `;
1511
1511
  }
1512
1512
 
1513
+ // ---------------------------------------------------------------------------
1514
+ // Attach a late-arriving domain to an ALREADY-ACTIVE instance (task 1002692):
1515
+ // re-assert exactly the domain-dependent runbook legs — web.env origins (+ a
1516
+ // service RESTART so the process re-reads them), the Cloudflare A-record UPSERT
1517
+ // (step 5), the Caddy site snippet (step 6) — then healthz. Expected failures
1518
+ // RETURN { ok:false } instead of throwing: the drain loop's catch flips the
1519
+ // instance to 'error', which must never happen to a healthy ACTIVE instance
1520
+ // over a domain hiccup.
1521
+ // ---------------------------------------------------------------------------
1522
+ async function domainAttachInstance(inst, deps) {
1523
+ const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
1524
+ const controlExec = deps.controlExec || exec;
1525
+ // Same box routing as provisionInstance: a standalone runs control-plane-local
1526
+ // (task 2074), so its env/caddy/service live HERE, not on the co-hosting box.
1527
+ const standalone = inst.hosting_shape === 'standalone';
1528
+ const boxExec = standalone ? controlExec : exec;
1529
+ const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
1530
+ const sudoP = privileged ? 'sudo ' : '';
1531
+ log(`domain-attach ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}, domain=${inst.domain || '-'})`);
1532
+
1533
+ if (!inst.domain) return { ok: false, error: 'no domain recorded on the instance — nothing to wire' };
1534
+ // Pre-active states are the provision run's job; post-active states have nothing
1535
+ // to address. A clean no-op (not an error) so a raced teardown/re-request never
1536
+ // burns the intent's retry budget.
1537
+ if (inst.status !== 'active') {
1538
+ log(` instance is '${inst.status}' — nothing to wire now (provision owns pre-active domains)`);
1539
+ return { ok: true, noop: true };
1540
+ }
1541
+ if (inst.hosting_shape === 'dedicated') {
1542
+ return { ok: false, error: `domain-attach for a dedicated instance is not automated yet — point an A record for ${inst.domain} at the droplet's IP and reload its Caddy` };
1543
+ }
1544
+
1545
+ // DNS preflight (the task-1980 stance): verify token reach BEFORE touching the box.
1546
+ if (CONFIG.dnsHook && apply) {
1547
+ const scope = await (deps.checkDnsTokenScope || checkDnsTokenScope)(inst.domain);
1548
+ if (!scope.reachable) {
1549
+ return { ok: false, error: `DNS preflight failed for ${inst.domain}: ${scope.reason}. ${scope.guidance}` };
1550
+ }
1551
+ log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
1552
+ }
1553
+
1554
+ // The A-record target — the same derivation as provisionInstance step 1.
1555
+ const recordIp = standalone
1556
+ ? (CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp))
1557
+ : CONFIG.publicIp;
1558
+
1559
+ log(' [config] re-write per-instance web.env (origins now carry the domain)');
1560
+ boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation: null }), { mode: 0o600, sudo: true });
1561
+ log(' [service] restart to pick up the new origins');
1562
+ boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1563
+
1564
+ // DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
1565
+ // the hook needs the Cloudflare token, which never leaves the control plane).
1566
+ if (CONFIG.dnsHook) {
1567
+ if (!recordIp) {
1568
+ const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
1569
+ if (apply) return { ok: false, error: `cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env` };
1570
+ log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
1571
+ } else {
1572
+ const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
1573
+ log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
1574
+ controlExec(CONFIG.dnsHook, { env: dnsEnv });
1575
+ }
1576
+ } else {
1577
+ log(' [dns] skipped (BOX_DNS_HOOK unset) — point the A record manually');
1578
+ }
1579
+
1580
+ log(' [caddy] write site snippet + reload');
1581
+ boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
1582
+ boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
1583
+
1584
+ log(' [healthz] verify the instance answers');
1585
+ boxExec(healthzCmd(inst), { allowFail: true });
1586
+
1587
+ if (apply) {
1588
+ await provisioning.recordEvent(db, {
1589
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
1590
+ detail: `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`, actor: 'runner',
1591
+ }).catch(() => {});
1592
+ }
1593
+ log(` ✓ ${inst.slug} addressed as ${inst.domain}`);
1594
+ return { ok: true };
1595
+ }
1596
+
1513
1597
  // ---------------------------------------------------------------------------
1514
1598
  // Teardown one instance (reverse the runbook; idempotent).
1515
1599
  // ---------------------------------------------------------------------------
@@ -1735,9 +1819,13 @@ async function cmdRunIntents(deps) {
1735
1819
  try {
1736
1820
  const inst = await provisioning.getInstanceById(db, intent.instance_id);
1737
1821
  if (!inst) { await provisioning.resolveIntent(db, intent.id, 'error', 'instance row vanished'); errors++; continue; }
1738
- const result = intent.action === 'provision'
1739
- ? await provisionInstance(inst, deps)
1740
- : await teardownInstance(inst, deps);
1822
+ // Dispatch EXPLICITLY per action — an unrecognized action must surface as an
1823
+ // error, never fall through to the destructive teardown leg (task 1002692).
1824
+ let result;
1825
+ if (intent.action === 'provision') result = await provisionInstance(inst, deps);
1826
+ else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
1827
+ else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1828
+ else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
1741
1829
  if (result && result.ok === false) {
1742
1830
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
1743
1831
  else await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
@@ -1907,7 +1995,7 @@ module.exports = {
1907
1995
  caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
1908
1996
  // executors + orchestration (injectable deps for tests)
1909
1997
  makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
1910
- cmdRunIntents, cmdReconcile, cmdStatus,
1998
+ domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
1911
1999
  // GitHub App Manifest flow (task 2080, ADR 0133)
1912
2000
  convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1913
2001
  // cross-site identity federation (ADR 0141 §4, task #1002109)
package/src/module-api.js CHANGED
@@ -547,8 +547,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
547
547
  // landed since 1.19.58 with no explicit bump. run 31547103265. (task 1002620)
548
548
  // 1.19.60 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
549
549
  // landed since 1.19.59 with no explicit bump. run 31548021746. (task 1002620)
550
+ // 1.19.61 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
551
+ // landed since 1.19.60 with no explicit bump. run 31550061440. (task 1002620)
550
552
  // ---------------------------------------------------------------------------
551
- const CORE_VERSION = '1.19.60'; // CI auto-patch carrier (ADR 0161); see changelog above
553
+ const CORE_VERSION = '1.19.61'; // CI auto-patch carrier (ADR 0161); see changelog above
552
554
 
553
555
  // A namespaced logger so a module's log lines are attributable + consistent.
554
556
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -1421,5 +1421,139 @@ await ta('provisionInstance(standalone, hub-configured): still NOT federated —
1421
1421
  });
1422
1422
  });
1423
1423
 
1424
+ console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its late-arriving address):');
1425
+
1426
+ // Recorder for the attach leg: captures commands (with env), file writes (with
1427
+ // bodies), and recorded events. No status writes to capture — domain-attach must
1428
+ // never touch the instance lifecycle.
1429
+ function attachRecorder({ apply = false } = {}) {
1430
+ const cmds = [], envs = [], writes = [], events = [];
1431
+ const deps = {
1432
+ apply, log: () => {},
1433
+ db: { query: async () => ({ rows: [] }) },
1434
+ provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
1435
+ exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
1436
+ writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
1437
+ doApi: null,
1438
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
1439
+ };
1440
+ return { deps, cmds, envs, writes, events };
1441
+ }
1442
+
1443
+ const activeStandalone = {
1444
+ id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
1445
+ port: 3010, db_name: 'spike', domain: 'spike.cloudbongos.com', target_ref: 'jaxri/spike-harbor',
1446
+ };
1447
+
1448
+ await ta('domainAttachInstance(active standalone): env re-write → restart → DNS upsert → caddy → healthz', async () => {
1449
+ const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
1450
+ P.CONFIG.publicIp = '203.0.113.5';
1451
+ P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
1452
+ try {
1453
+ const { deps, cmds, envs, writes } = attachRecorder();
1454
+ const r = await P.domainAttachInstance(activeStandalone, deps);
1455
+ assert.equal(r.ok, true);
1456
+ const env = writes.find((w) => /\/spike\/web\.env$/.test(w.path));
1457
+ assert.ok(env, 're-writes web.env');
1458
+ assert.match(env.body, /https:\/\/spike\.cloudbongos\.com/, 'the new address reaches the origins');
1459
+ const joined = cmds.join('\n');
1460
+ assert.match(joined, /systemctl restart spike/, 'RESTART (not reload) so the process re-reads its env');
1461
+ const di = cmds.indexOf('/opt/infra/box-dns-cloudflare.sh');
1462
+ assert.ok(di >= 0, 'DNS hook invoked by path');
1463
+ assert.equal(envs[di].BOX_HOSTNAME, 'spike.cloudbongos.com');
1464
+ assert.equal(envs[di].BOX_PROXIED, '1', 'standalone rides the CF proxy (task 2077)');
1465
+ assert.ok(writes.some((w) => /\/spike\.caddy$/.test(w.path)), 'writes the Caddy site snippet');
1466
+ assert.match(joined, /reload caddy/);
1467
+ assert.match(joined, /healthz/);
1468
+ const idx = (re) => cmds.findIndex((c) => re.test(c));
1469
+ assert.ok(idx(/restart spike/) < idx(/reload caddy/), 'service restart before caddy reload');
1470
+ } finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
1471
+ });
1472
+
1473
+ await ta('domainAttachInstance: no domain recorded → ok:false, nothing runs', async () => {
1474
+ const { deps, cmds } = attachRecorder();
1475
+ const r = await P.domainAttachInstance({ ...activeStandalone, domain: null }, deps);
1476
+ assert.equal(r.ok, false);
1477
+ assert.match(r.error, /no domain/i);
1478
+ assert.equal(cmds.length, 0);
1479
+ });
1480
+
1481
+ await ta('domainAttachInstance: instance no longer active → clean no-op (provision owns pre-active wiring; never burns the retry budget)', async () => {
1482
+ for (const status of ['requested', 'provisioning', 'tearing_down', 'torn_down', 'error']) {
1483
+ const { deps, cmds } = attachRecorder();
1484
+ const r = await P.domainAttachInstance({ ...activeStandalone, status }, deps);
1485
+ assert.equal(r.ok, true, status);
1486
+ assert.equal(r.noop, true, status);
1487
+ assert.equal(cmds.length, 0, `${status}: nothing ran`);
1488
+ }
1489
+ });
1490
+
1491
+ await ta('domainAttachInstance: dedicated is not automated — ok:false with guidance, box untouched', async () => {
1492
+ const { deps, cmds } = attachRecorder();
1493
+ const r = await P.domainAttachInstance({ ...activeStandalone, hosting_shape: 'dedicated' }, deps);
1494
+ assert.equal(r.ok, false);
1495
+ assert.match(r.error, /dedicated/);
1496
+ assert.equal(cmds.length, 0);
1497
+ });
1498
+
1499
+ await ta('domainAttachInstance(--apply): a DNS preflight failure returns ok:false — never THROWS (a throw would flip the ACTIVE instance to error in the drain loop)', async () => {
1500
+ const savedHook = P.CONFIG.dnsHook; P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
1501
+ try {
1502
+ const { deps, cmds } = attachRecorder({ apply: true });
1503
+ deps.checkDnsTokenScope = async () => ({ reachable: false, zone: 'z', reason: 'not in scope', guidance: 'widen the token' });
1504
+ const r = await P.domainAttachInstance(activeStandalone, deps);
1505
+ assert.equal(r.ok, false);
1506
+ assert.match(r.error, /preflight.*not in scope/);
1507
+ assert.ok(!cmds.some((c) => /restart/.test(c)), 'stops before touching the service');
1508
+ } finally { P.CONFIG.dnsHook = savedHook; }
1509
+ });
1510
+
1511
+ await ta('cmdRunIntents (--apply) routes a domain-attach intent to the attach leg and resolves it done', async () => {
1512
+ let claims = 0; const resolved = [];
1513
+ const deps = {
1514
+ apply: true, log: () => {},
1515
+ db: { query: async () => ({ rows: [] }) },
1516
+ provisioning: {
1517
+ claimNextIntent: async () => (claims++ === 0 ? { id: 6, instance_id: 18, action: 'domain-attach', attempts: 1 } : null),
1518
+ getInstanceById: async () => ({ ...activeStandalone }),
1519
+ resolveIntent: async (_db, id, state) => { resolved.push({ id, state }); },
1520
+ setInstanceStatus: async () => {},
1521
+ recordEvent: async () => {},
1522
+ claimNextOAuthManifest: async () => null,
1523
+ resolveOAuthManifest: async () => {},
1524
+ },
1525
+ exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
1526
+ checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
1527
+ };
1528
+ const r = await P.cmdRunIntents(deps);
1529
+ assert.equal(r.ran, 1);
1530
+ assert.equal(r.ok, 1);
1531
+ assert.deepEqual(resolved, [{ id: 6, state: 'done' }]);
1532
+ });
1533
+
1534
+ await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through to teardown', async () => {
1535
+ let claims = 0; const resolved = []; const cmds = [];
1536
+ const deps = {
1537
+ apply: true, log: () => {},
1538
+ db: { query: async () => ({ rows: [] }) },
1539
+ provisioning: {
1540
+ claimNextIntent: async () => (claims++ === 0 ? { id: 7, instance_id: 18, action: 'oauth-manifest', attempts: P.MAX_INTENT_ATTEMPTS } : null),
1541
+ getInstanceById: async () => ({ ...activeStandalone }),
1542
+ resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
1543
+ setInstanceStatus: async () => {},
1544
+ recordEvent: async () => {},
1545
+ claimNextOAuthManifest: async () => null,
1546
+ resolveOAuthManifest: async () => {},
1547
+ },
1548
+ exec: (c) => { cmds.push(c); return { ok: true }; },
1549
+ writeFile: () => ({ ok: true }), doApi: null,
1550
+ };
1551
+ const r = await P.cmdRunIntents(deps);
1552
+ assert.equal(r.errors, 1, 'surfaced as an error, not silently executed');
1553
+ assert.equal(resolved.length, 1);
1554
+ assert.equal(resolved[0].state, 'error');
1555
+ assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
1556
+ });
1557
+
1424
1558
  console.log(`\nprovision: ${passed} passed, ${failed} failed`);
1425
1559
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,289 @@
1
+ // tests/provisioning_domain_attach.mjs
2
+ //
3
+ // The domain-attach path (task 1002692, gap 5 of the creation-flow audit / idea
4
+ // 1000677): before this, NO route could give an EXISTING instance its address — a
5
+ // slug re-request silently dropped the domain in the body, and a domainless
6
+ // instance (e.g. Spike Harbor, instance 18) was stuck domainless forever, which
7
+ // also blocked GitHub sign-in (the github-app manifest route 409s without a domain).
8
+ //
9
+ // Covers:
10
+ // • updateInstanceDomain / getInstanceByDomain — the service slice (recording fakes)
11
+ // • INTENT_ACTIONS lockstep — 'domain-attach' rides the intent queue (migration 005)
12
+ // • POST /provisioning/instances/:id/domain — validate + record + enqueue, with
13
+ // truthful answers for every refusal (bad domain, taken domain, wrong state,
14
+ // open-intent conflict) — driven over the real mounted router
15
+ // • POST /provisioning/instances — a slug re-request carrying a domain now SAYS
16
+ // the domain was ignored instead of silently dropping it
17
+ // • POST /provisioning/instances/:id/github-app — accepts the newly-addressed
18
+ // instance (the domain_required 409 clears once a domain is recorded)
19
+ //
20
+ // DB-free: data-access fns are stubbed on the cached module object (the
21
+ // provisioning_onboard_mode.mjs pattern). No network.
22
+ //
23
+ // Run: node tests/provisioning_domain_attach.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const provisioning = require('../modules/provisioning/provisioning.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ function t(name, fn) {
34
+ try { fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+ async function ta(name, fn) {
38
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
39
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
40
+ }
41
+
42
+ console.log('service slice — updateInstanceDomain / getInstanceByDomain:');
43
+
44
+ await ta('updateInstanceDomain: parameterized UPDATE of only the domain (+updated_at), returns the row', async () => {
45
+ const calls = [];
46
+ const db = { query: async (sql, params) => {
47
+ calls.push({ sql, params });
48
+ return { rows: [{ id: 18, domain: params[1] }] };
49
+ } };
50
+ const row = await provisioning.updateInstanceDomain(db, 18, 'spike.cloudbongos.com');
51
+ assert.equal(row.domain, 'spike.cloudbongos.com');
52
+ assert.match(calls[0].sql, /UPDATE provisioning_instances/);
53
+ assert.match(calls[0].sql, /SET domain = \$2, updated_at = now\(\)/, 'touches ONLY the domain column');
54
+ assert.deepEqual(calls[0].params, [18, 'spike.cloudbongos.com']);
55
+ });
56
+
57
+ await ta('getInstanceByDomain: case-insensitive exact match, parameterized', async () => {
58
+ const calls = [];
59
+ const db = { query: async (sql, params) => {
60
+ calls.push({ sql, params });
61
+ return { rows: params[0] === 'spike.cloudbongos.com' ? [{ id: 18 }] : [] };
62
+ } };
63
+ const hit = await provisioning.getInstanceByDomain(db, 'SPIKE.cloudbongos.com');
64
+ assert.equal(hit.id, 18, 'matches regardless of stored/asked case');
65
+ assert.match(calls[0].sql, /lower\(domain\) = \$1/);
66
+ assert.equal(await provisioning.getInstanceByDomain(db, 'other.example.com'), null);
67
+ });
68
+
69
+ t("INTENT_ACTIONS includes 'domain-attach' (lockstep with the provisioning_005 CHECK)", () => {
70
+ assert.ok(provisioning.INTENT_ACTIONS.includes('domain-attach'));
71
+ });
72
+
73
+ // ── the routes, over a real mounted router (stubbed data access) ──────────────────
74
+ let instance = null; // what getInstanceById returns
75
+ let domainHolder = null; // what getInstanceByDomain returns
76
+ let updateCalls = [];
77
+ let enqueueCalls = [];
78
+ let enqueueResult = null; // what enqueueIntent reports
79
+ let createResult = null; // what createInstance reports (re-request tests)
80
+ let manifestRows = [];
81
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
82
+ provisioning.getInstanceByDomain = async (_db, d) => domainHolder;
83
+ provisioning.updateInstanceDomain = async (_db, id, domain) => {
84
+ updateCalls.push({ id, domain });
85
+ instance = { ...instance, domain };
86
+ return { ...instance, id };
87
+ };
88
+ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
89
+ enqueueCalls.push({ id, action, requestedBy });
90
+ return enqueueResult || { intent: { id: 9, action, state: 'pending' }, created: true };
91
+ };
92
+ provisioning.getOpenIntent = async () => null;
93
+ provisioning.createInstance = async () => createResult;
94
+ provisioning.recordEvent = async () => {};
95
+ provisioning.createOAuthManifest = async (_db, row) => { manifestRows.push(row); return { id: 1, ...row }; };
96
+ provisioning.getLatestOAuthManifestForInstance = async () => null;
97
+
98
+ const api = require('../src/module-api.js');
99
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
100
+ api.requirePermission = () => (_req, _res, next) => next();
101
+ api.branding = () => ({
102
+ domains: { publicOrigin: 'https://cb.test' },
103
+ identity: { productName: 'Cloud Bongos' },
104
+ });
105
+
106
+ const express = require('express');
107
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
108
+ const app = express();
109
+ app.use(express.json());
110
+ app.use((_req, res, next) => {
111
+ res.fail = (code, opts, details) => {
112
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
113
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
114
+ };
115
+ next();
116
+ });
117
+ app.use('/api/bongos', provisioningRoutes());
118
+
119
+ const server = app.listen(0);
120
+ await new Promise((r) => server.once('listening', r));
121
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
122
+
123
+ function post(path, body = {}) {
124
+ return new Promise((resolve, reject) => {
125
+ const payload = JSON.stringify(body);
126
+ const req = http.request(`${base}${path}`, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
129
+ }, (res) => {
130
+ let d = '';
131
+ res.on('data', (c) => { d += c; });
132
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
133
+ });
134
+ req.on('error', reject);
135
+ req.end(payload);
136
+ });
137
+ }
138
+
139
+ function reset({ inst } = {}) {
140
+ instance = inst || null;
141
+ domainHolder = null;
142
+ updateCalls = [];
143
+ enqueueCalls = [];
144
+ enqueueResult = null;
145
+ createResult = null;
146
+ manifestRows = [];
147
+ }
148
+
149
+ console.log('\nPOST /provisioning/instances/:id/domain — give an existing instance its address:');
150
+
151
+ await ta('ACTIVE domainless instance: domain is validated, recorded, and a domain-attach intent queues', async () => {
152
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null } });
153
+ const res = await post('/provisioning/instances/18/domain', { domain: 'Spike.CloudBongos.com.' });
154
+ assert.equal(res.status, 200, res.raw);
155
+ assert.equal(res.body.ok, true);
156
+ assert.equal(res.body.domain, 'spike.cloudbongos.com', 'normalized before storing');
157
+ assert.deepEqual(updateCalls, [{ id: 18, domain: 'spike.cloudbongos.com' }]);
158
+ assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
159
+ assert.equal(res.body.queued, true);
160
+ });
161
+
162
+ await ta('a malformed domain is refused 400 before anything is written', async () => {
163
+ for (const bad of ['not a host', 'com', '', '-x.example.com']) {
164
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
165
+ const res = await post('/provisioning/instances/18/domain', { domain: bad });
166
+ assert.equal(res.status, 400, `${JSON.stringify(bad)}: ${res.raw}`);
167
+ assert.equal(res.body.error.code, 'bad_domain');
168
+ assert.equal(updateCalls.length, 0, 'nothing recorded');
169
+ assert.equal(enqueueCalls.length, 0, 'nothing queued');
170
+ }
171
+ });
172
+
173
+ await ta("another instance already holding the domain is a truthful 409 domain_taken (never a silent shadow)", async () => {
174
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
175
+ domainHolder = { id: 99, slug: 'other' };
176
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
177
+ assert.equal(res.status, 409, res.raw);
178
+ assert.equal(res.body.error.code, 'domain_taken');
179
+ assert.equal(updateCalls.length, 0);
180
+ });
181
+
182
+ await ta('same domain again is idempotent — 200, changed:false, nothing re-queued', async () => {
183
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' } });
184
+ const res = await post('/provisioning/instances/18/domain', { domain: 'SPIKE.cloudbongos.com' });
185
+ assert.equal(res.status, 200, res.raw);
186
+ assert.equal(res.body.changed, false);
187
+ assert.equal(updateCalls.length, 0);
188
+ assert.equal(enqueueCalls.length, 0);
189
+ });
190
+
191
+ await ta('a DIFFERENT domain already set is a truthful 409 domain_already_set (re-pointing is not this route)', async () => {
192
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'old.cloudbongos.com' } });
193
+ const res = await post('/provisioning/instances/18/domain', { domain: 'new.cloudbongos.com' });
194
+ assert.equal(res.status, 409, res.raw);
195
+ assert.equal(res.body.error.code, 'domain_already_set');
196
+ assert.equal(res.body.error.details.current, 'old.cloudbongos.com', 'names the current address');
197
+ assert.equal(updateCalls.length, 0);
198
+ });
199
+
200
+ await ta('REQUESTED instance: domain records with NO intent — the provision run wires it', async () => {
201
+ reset({ inst: { owner_builder_id: 7, slug: 'fresh', status: 'requested', domain: null } });
202
+ const res = await post('/provisioning/instances/18/domain', { domain: 'fresh.cloudbongos.com' });
203
+ assert.equal(res.status, 200, res.raw);
204
+ assert.equal(res.body.queued, false);
205
+ assert.deepEqual(updateCalls, [{ id: 18, domain: 'fresh.cloudbongos.com' }]);
206
+ assert.equal(enqueueCalls.length, 0, 'provision will wire it — no separate intent');
207
+ });
208
+
209
+ await ta('an instance mid-flight (provisioning / tearing_down / torn_down) is a truthful 409', async () => {
210
+ for (const status of ['provisioning', 'tearing_down', 'torn_down']) {
211
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status, domain: null } });
212
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
213
+ assert.equal(res.status, 409, `${status}: ${res.raw}`);
214
+ assert.equal(res.body.error.code, 'domain_not_attachable');
215
+ assert.equal(res.body.error.details.status, status, 'names the real state');
216
+ assert.equal(updateCalls.length, 0, `${status}: nothing recorded`);
217
+ }
218
+ });
219
+
220
+ await ta('an open non-domain intent (e.g. a teardown) is a truthful 409 intent_conflict, not a false success', async () => {
221
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
222
+ enqueueResult = { intent: { id: 5, action: 'teardown', state: 'pending' }, created: false };
223
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
224
+ assert.equal(res.status, 409, res.raw);
225
+ assert.equal(res.body.error.code, 'intent_conflict');
226
+ assert.equal(res.body.error.details.action, 'teardown', 'names the real blocker');
227
+ });
228
+
229
+ await ta('an already-open domain-attach intent is idempotent — 200 queued:false "already being wired"', async () => {
230
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
231
+ enqueueResult = { intent: { id: 5, action: 'domain-attach', state: 'pending' }, created: false };
232
+ const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
233
+ assert.equal(res.status, 200, res.raw);
234
+ assert.equal(res.body.queued, false);
235
+ assert.match(res.body.message, /already/i);
236
+ });
237
+
238
+ await ta('not the owner → 404 (own-scoped; existence not leaked)', async () => {
239
+ reset({ inst: { owner_builder_id: 42, slug: 'theirs', status: 'active', domain: null } });
240
+ const res = await post('/provisioning/instances/18/domain', { domain: 'x.cloudbongos.com' });
241
+ assert.equal(res.status, 404, res.raw);
242
+ });
243
+
244
+ console.log('\nPOST /provisioning/instances — slug re-requests are truthful about the domain:');
245
+
246
+ await ta('re-request with a domain on an existing instance SAYS the domain was ignored (and where to send it)', async () => {
247
+ reset();
248
+ createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: null }, created: false };
249
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
250
+ assert.equal(res.status, 200, res.raw);
251
+ assert.ok(res.body.domain_ignored, 'the drop is surfaced, not silent');
252
+ assert.equal(res.body.domain_ignored.requested, 'spike.cloudbongos.com');
253
+ assert.match(res.body.domain_ignored.hint, /\/domain/, 'points at the domain-attach route');
254
+ });
255
+
256
+ await ta('re-request with the SAME domain the instance already has → no ignored flag (nothing was dropped)', async () => {
257
+ reset();
258
+ createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' }, created: false };
259
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
260
+ assert.equal(res.status, 200, res.raw);
261
+ assert.equal(res.body.domain_ignored, undefined);
262
+ });
263
+
264
+ await ta('a fresh create with a domain is untouched (the domain is stored at creation — nothing ignored)', async () => {
265
+ reset();
266
+ createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'new', status: 'requested', domain: 'new.cloudbongos.com' }, created: true };
267
+ const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'standalone', domain: 'new.cloudbongos.com' });
268
+ assert.equal(res.status, 201, res.raw);
269
+ assert.equal(res.body.domain_ignored, undefined);
270
+ });
271
+
272
+ console.log('\nPOST /provisioning/instances/:id/github-app — accepts the newly-addressed instance:');
273
+
274
+ await ta('domainless instance still 409s domain_required; once addressed, the manifest mints', async () => {
275
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
276
+ const before = await post('/provisioning/instances/18/github-app', {});
277
+ assert.equal(before.status, 409, before.raw);
278
+ assert.equal(before.body.error.code, 'domain_required');
279
+
280
+ instance = { ...instance, domain: 'spike.cloudbongos.com' };
281
+ const after = await post('/provisioning/instances/18/github-app', {});
282
+ assert.equal(after.status, 200, after.raw);
283
+ assert.equal(manifestRows.length, 1, 'a manifest attempt row was recorded');
284
+ assert.match(after.body.manifest.url, /spike\.cloudbongos\.com/, 'the manifest points at the new address');
285
+ });
286
+
287
+ server.close();
288
+ console.log(`\nprovisioning_domain_attach: ${passed} passed, ${failed} failed`);
289
+ process.exit(failed ? 1 : 0);