@bongos/core 1.19.60 → 1.19.62
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 +34 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +100 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/migrations/provisioning_005_domain_attach.sql +20 -0
- package/modules/provisioning/provisioning.js +35 -4
- package/modules/provisioning/routes/provisioning.js +91 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision.js +92 -4
- package/src/bongos/module-scope-map.js +16 -0
- package/src/module-api.js +5 -1
- package/tests/module-scope-map.mjs +13 -0
- package/tests/provision.mjs +134 -0
- package/tests/provisioning_domain_attach.mjs +289 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.62",
|
|
6
|
+
"core_contract": "1.19.62",
|
|
7
|
+
"source_commit": "81537ad6976770dfa5d0e9ff4aae44c6cc00be9f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-12T01:12:59.066Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 291,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1220,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1532,
|
|
20
|
+
"tree_sha256": "d7adb6b51e99575f2dbed8932206114b763bc19d3d4b880f41b091710892920b",
|
|
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": "
|
|
350
|
+
"sha256": "36eb537043097dc8e05307adc06d893a2c5ceb8cceb63f24b64ade001a0a2640"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
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": "
|
|
365
|
+
"sha256": "43823273ec587b104f3e0296614ae8ea6c4dc5c96261dbbb359ae39c21cf4e79"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "4deeae55ba58483557310d768a63df7cd75a1219137539b7ccbbf4481f09f5d9"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
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": "
|
|
1310
|
+
"sha256": "a30007778606ce90b8e31470d926047a372c72b93022f54023937cfd4c5b5fc0"
|
|
1311
1311
|
},
|
|
1312
1312
|
{
|
|
1313
1313
|
"path": "docs/api/openapi.json",
|
|
1314
1314
|
"mode": "0000644",
|
|
1315
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
4040
|
+
"sha256": "66f2a964b8eb493c69abb582b5f9be8dec20d49f31919abf3b55b8c11ffee2ed"
|
|
4036
4041
|
},
|
|
4037
4042
|
{
|
|
4038
4043
|
"path": "package.json",
|
|
4039
4044
|
"mode": "0000644",
|
|
4040
|
-
"sha256": "
|
|
4045
|
+
"sha256": "c49444a432fa927ad7487c52180f23cda72e18055cfbfbf22565d4cdca2825de"
|
|
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": "
|
|
5015
|
+
"sha256": "e4108117dbe8dd6493ba41ed053fe8648424ca127b1c26505aa6f6702b2dd94c"
|
|
5011
5016
|
},
|
|
5012
5017
|
{
|
|
5013
5018
|
"path": "scripts/gds/publish-manifest.js",
|
|
@@ -5492,7 +5497,7 @@
|
|
|
5492
5497
|
{
|
|
5493
5498
|
"path": "src/bongos/module-scope-map.js",
|
|
5494
5499
|
"mode": "0000644",
|
|
5495
|
-
"sha256": "
|
|
5500
|
+
"sha256": "a5c939de500ba39d12f09e053e3b9079510716cb4af18cad0e13482952b02904"
|
|
5496
5501
|
},
|
|
5497
5502
|
{
|
|
5498
5503
|
"path": "src/bongos/module-submissions.js",
|
|
@@ -5657,7 +5662,7 @@
|
|
|
5657
5662
|
{
|
|
5658
5663
|
"path": "src/module-api.js",
|
|
5659
5664
|
"mode": "0000644",
|
|
5660
|
-
"sha256": "
|
|
5665
|
+
"sha256": "f2df366234508aa746d344fd4c28b3e09f4354c3510f585283518a0fde03b89c"
|
|
5661
5666
|
},
|
|
5662
5667
|
{
|
|
5663
5668
|
"path": "src/module-loader/loader.js",
|
|
@@ -6922,7 +6927,7 @@
|
|
|
6922
6927
|
{
|
|
6923
6928
|
"path": "tests/module-scope-map.mjs",
|
|
6924
6929
|
"mode": "0000644",
|
|
6925
|
-
"sha256": "
|
|
6930
|
+
"sha256": "a1059f1995ba2b98436df0c4965a87b20e7553b6b72e80bb724c0b861e347ecf"
|
|
6926
6931
|
},
|
|
6927
6932
|
{
|
|
6928
6933
|
"path": "tests/module_api.mjs",
|
|
@@ -7137,7 +7142,12 @@
|
|
|
7137
7142
|
{
|
|
7138
7143
|
"path": "tests/provision.mjs",
|
|
7139
7144
|
"mode": "0000644",
|
|
7140
|
-
"sha256": "
|
|
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
|
-
-
|
|
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
|
package/docs/api/openapi.json
CHANGED
|
@@ -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":
|
|
16879
|
-
"x-schema-count":
|
|
16975
|
+
"x-endpoint-count": 269,
|
|
16976
|
+
"x-schema-count": 302,
|
|
16880
16977
|
"x-undocumented-bodies": 8,
|
|
16881
|
-
"x-response-schemas":
|
|
16978
|
+
"x-response-schemas": 204,
|
|
16882
16979
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
16883
16980
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -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.
|
|
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` (
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.19.62",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.62",
|
|
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.
|
|
3
|
+
"version": "1.19.62",
|
|
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/scripts/gds/provision.js
CHANGED
|
@@ -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
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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)
|
|
@@ -192,6 +192,13 @@ const MODULE_GLOBS = {
|
|
|
192
192
|
// (moduleWebSurfaces → serve-internal). Its public read endpoints stay SHARED
|
|
193
193
|
// core in routes/public.js (the hall + the off-box mirror consume them too).
|
|
194
194
|
'status-ui': ['modules/status-ui/'],
|
|
195
|
+
// The instance's public landing surface (index/world/gate/create), served by
|
|
196
|
+
// platform-server.js from the repo-root public-landing/ dir — not yet a carved
|
|
197
|
+
// module, so the glob lists where the code lives TODAY (the auto-tighten
|
|
198
|
+
// contract above). Its own key, not provisioning's: the front door is broader
|
|
199
|
+
// than instance hosting, and the web-surface precedent (hall-ui/status-ui) is
|
|
200
|
+
// one key per surface (task 1002690, goal 1000059).
|
|
201
|
+
'public-landing': ['public-landing/'],
|
|
195
202
|
// --- feature modules ---
|
|
196
203
|
// BV1.R49: game graduated to modules/game/ — src/rooms/, src/world/, src/seats.js,
|
|
197
204
|
// src/db.js, migrations/game/, and public/ all moved there. src/preview-server.js
|
|
@@ -200,6 +207,15 @@ const MODULE_GLOBS = {
|
|
|
200
207
|
'modules/game/', 'src/preview-server.js',
|
|
201
208
|
],
|
|
202
209
|
'art-pipeline': ['modules/art-pipeline/'],
|
|
210
|
+
// --- instance provisioning: the web-tier REQUEST + STATE surface (module home)
|
|
211
|
+
// plus the control-plane runner CLI — a command travels with its domain (the
|
|
212
|
+
// lifecycle/dev-box precedent). Covers the projects-surface work (goal
|
|
213
|
+
// 1000059); previously NO key resolved these paths, so no goal could wall
|
|
214
|
+
// them (task 1002690). ---
|
|
215
|
+
provisioning: [
|
|
216
|
+
'modules/provisioning/',
|
|
217
|
+
'scripts/gds/provision.js',
|
|
218
|
+
],
|
|
203
219
|
discord: [
|
|
204
220
|
// The bot, broadcast, link/role seams, channels, and bug-attachments graduated
|
|
205
221
|
// into modules/discord/ (BV1.R47); the outbound webhook lib + post CLI stay
|
package/src/module-api.js
CHANGED
|
@@ -547,8 +547,12 @@ 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)
|
|
552
|
+
// 1.19.62 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
553
|
+
// landed since 1.19.61 with no explicit bump. run 31552833331. (task 1002620)
|
|
550
554
|
// ---------------------------------------------------------------------------
|
|
551
|
-
const CORE_VERSION = '1.19.
|
|
555
|
+
const CORE_VERSION = '1.19.62'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
552
556
|
|
|
553
557
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
554
558
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -86,3 +86,16 @@ test('the exported roster is frozen (immutable scope wall)', () => {
|
|
|
86
86
|
assert.ok(Object.isFrozen(scope.scopeForModule('kernel')));
|
|
87
87
|
assert.ok(Object.isFrozen(scope.scopeForModule('kernel').globs));
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
test('provisioning + public-landing joined the roster (task 1002690) — territory resolvable, non-protected', () => {
|
|
91
|
+
// Before this, modules/provisioning/ and public-landing/ were covered by NO
|
|
92
|
+
// key, so no goal could wall the projects-surface work (goal 1000059).
|
|
93
|
+
for (const k of ['provisioning', 'public-landing']) {
|
|
94
|
+
assert.ok(scope.isModuleKey(k), `roster missing module "${k}"`);
|
|
95
|
+
assert.equal(scope.isProtectedModule(k), false, `"${k}" must derive non-protected (no PROTECTED_GLOBS overlap)`);
|
|
96
|
+
}
|
|
97
|
+
const prov = scope.globsForModules(['provisioning']);
|
|
98
|
+
assert.ok(prov.includes('modules/provisioning/'), 'provisioning owns its module directory');
|
|
99
|
+
assert.ok(prov.includes('scripts/gds/provision.js'), 'the control-plane runner travels with its domain');
|
|
100
|
+
assert.ok(scope.globsForModules(['public-landing']).includes('public-landing/'), 'public-landing owns its surface directory');
|
|
101
|
+
});
|
package/tests/provision.mjs
CHANGED
|
@@ -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);
|