@bongos/core 1.19.142 → 1.19.143
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 +29 -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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +56 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/rate-limit.js +15 -5
- package/modules/provisioning/routes/provisioning.js +71 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +18 -2
- package/public-landing/projects.html +128 -16
- package/src/module-api.js +3 -1
- package/tests/cosmos_token_contrast.mjs +16 -4
- package/tests/provisioning_slug_available.mjs +243 -0
- package/tests/public_landing_projects.mjs +118 -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.143",
|
|
6
|
+
"core_contract": "1.19.143",
|
|
7
|
+
"source_commit": "31df926208597154ce684504f0aa15d6b1dde113",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-15T15:
|
|
9
|
+
"built_at": "2026-08-15T15:52:12.410Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 303,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1251,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1575,
|
|
20
|
+
"tree_sha256": "a9a3320aae0cbec4eb5ec01e7b50675d69a50814017d7f4658efa4e52493deb4",
|
|
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": "0942508db8fb6f31b9e095d197f8b4cdd4efdc4e754c46629ea299ff10d895a1"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "5a4a95386b5a2ec9bc59334a35b09b5701b2ca5eabc99baf2305c7b82d920342"
|
|
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": "2c199a1ad61c8ca8a015b0918368da4c3bf4a8e6196edfd46b93048dda8e9eb5"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "e1359174cf948616e95a03232a8fe3f7a56e66aa94d578c786b6e7a258ce0221"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "e0e5672aafc56bfec2b6d0b3d90a73c984a5f3cf6b745025f5d9a37d8f09d7e6"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/package.json",
|
|
@@ -1337,12 +1337,12 @@
|
|
|
1337
1337
|
{
|
|
1338
1338
|
"path": "docs/api-reference.md",
|
|
1339
1339
|
"mode": "0000644",
|
|
1340
|
-
"sha256": "
|
|
1340
|
+
"sha256": "e88714b1178d43ff8aea451807a0a6095a3e90d4a592babc4ffb5c1baccac4ba"
|
|
1341
1341
|
},
|
|
1342
1342
|
{
|
|
1343
1343
|
"path": "docs/api/openapi.json",
|
|
1344
1344
|
"mode": "0000644",
|
|
1345
|
-
"sha256": "
|
|
1345
|
+
"sha256": "0a3bf7cc757082f1764b926f72e24a14e8f22e07f0e0a5085ab6ff9d64e74ba8"
|
|
1346
1346
|
},
|
|
1347
1347
|
{
|
|
1348
1348
|
"path": "docs/architecture.md",
|
|
@@ -2652,7 +2652,7 @@
|
|
|
2652
2652
|
{
|
|
2653
2653
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2654
2654
|
"mode": "0000644",
|
|
2655
|
-
"sha256": "
|
|
2655
|
+
"sha256": "2c199a1ad61c8ca8a015b0918368da4c3bf4a8e6196edfd46b93048dda8e9eb5"
|
|
2656
2656
|
},
|
|
2657
2657
|
{
|
|
2658
2658
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -3877,12 +3877,12 @@
|
|
|
3877
3877
|
{
|
|
3878
3878
|
"path": "modules/provisioning/rate-limit.js",
|
|
3879
3879
|
"mode": "0000644",
|
|
3880
|
-
"sha256": "
|
|
3880
|
+
"sha256": "27046325beffad5d1edfcc91c331048038900ef7d32f1d38796755c7990ba202"
|
|
3881
3881
|
},
|
|
3882
3882
|
{
|
|
3883
3883
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
3884
3884
|
"mode": "0000644",
|
|
3885
|
-
"sha256": "
|
|
3885
|
+
"sha256": "17f6adb4edd7dee2ab339f5d302b61ecfbcf45db2ba4d0315eeeffbad87a9dce"
|
|
3886
3886
|
},
|
|
3887
3887
|
{
|
|
3888
3888
|
"path": "modules/provisioning/tests/liveness.mjs",
|
|
@@ -4177,12 +4177,12 @@
|
|
|
4177
4177
|
{
|
|
4178
4178
|
"path": "package-lock.json",
|
|
4179
4179
|
"mode": "0000644",
|
|
4180
|
-
"sha256": "
|
|
4180
|
+
"sha256": "5f68e2c2d9e7f46d2884dce06d8bc7a5ed71f7716800c1815d550e43bd896176"
|
|
4181
4181
|
},
|
|
4182
4182
|
{
|
|
4183
4183
|
"path": "package.json",
|
|
4184
4184
|
"mode": "0000644",
|
|
4185
|
-
"sha256": "
|
|
4185
|
+
"sha256": "51affa4342af5f89f0cfda20e74bb4120338ef58c2d0309c706a7dbdb25c4d8f"
|
|
4186
4186
|
},
|
|
4187
4187
|
{
|
|
4188
4188
|
"path": "public-docs/index.html",
|
|
@@ -4422,7 +4422,7 @@
|
|
|
4422
4422
|
{
|
|
4423
4423
|
"path": "public-landing/assets/cosmos.css",
|
|
4424
4424
|
"mode": "0000644",
|
|
4425
|
-
"sha256": "
|
|
4425
|
+
"sha256": "06ed45b09cb35cc8b2e20816416c45a07dd6ca47f418ac9c239ab8c949796f8b"
|
|
4426
4426
|
},
|
|
4427
4427
|
{
|
|
4428
4428
|
"path": "public-landing/buddha.png",
|
|
@@ -4437,7 +4437,7 @@
|
|
|
4437
4437
|
{
|
|
4438
4438
|
"path": "public-landing/projects.html",
|
|
4439
4439
|
"mode": "0000644",
|
|
4440
|
-
"sha256": "
|
|
4440
|
+
"sha256": "aa37e8e5b14f169f593a55eb71a0c21117c818a07d34d23a35c45219c646a5dd"
|
|
4441
4441
|
},
|
|
4442
4442
|
{
|
|
4443
4443
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -5767,7 +5767,7 @@
|
|
|
5767
5767
|
{
|
|
5768
5768
|
"path": "src/module-api.js",
|
|
5769
5769
|
"mode": "0000644",
|
|
5770
|
-
"sha256": "
|
|
5770
|
+
"sha256": "7ac4130655324a65c52dfd04a7b57c72371904149e8a126a16d341f82d3d1625"
|
|
5771
5771
|
},
|
|
5772
5772
|
{
|
|
5773
5773
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6277,7 +6277,7 @@
|
|
|
6277
6277
|
{
|
|
6278
6278
|
"path": "tests/cosmos_token_contrast.mjs",
|
|
6279
6279
|
"mode": "0000644",
|
|
6280
|
-
"sha256": "
|
|
6280
|
+
"sha256": "6343c10b95c8ed1da86ae32367882a9970f19266bccedb26b3e15b73c5bdd37a"
|
|
6281
6281
|
},
|
|
6282
6282
|
{
|
|
6283
6283
|
"path": "tests/cost.mjs",
|
|
@@ -7339,6 +7339,11 @@
|
|
|
7339
7339
|
"mode": "0000644",
|
|
7340
7340
|
"sha256": "7749df454ac3cca7c7e5ea1acc94ead15d1c9f87b38fe2028a1c93c8adf31bcf"
|
|
7341
7341
|
},
|
|
7342
|
+
{
|
|
7343
|
+
"path": "tests/provisioning_slug_available.mjs",
|
|
7344
|
+
"mode": "0000644",
|
|
7345
|
+
"sha256": "1623d2dff20d7b5843f9fd39dd91c35e4a8ebe10b60cf798e01a7fbc4b0202cf"
|
|
7346
|
+
},
|
|
7342
7347
|
{
|
|
7343
7348
|
"path": "tests/provisioning_teardown_intent.mjs",
|
|
7344
7349
|
"mode": "0000644",
|
|
@@ -7352,7 +7357,7 @@
|
|
|
7352
7357
|
{
|
|
7353
7358
|
"path": "tests/public_landing_projects.mjs",
|
|
7354
7359
|
"mode": "0000644",
|
|
7355
|
-
"sha256": "
|
|
7360
|
+
"sha256": "ea3fc3b311115c765e7734e11cc33750a5ffde9f492d4635460b04cfc2108ad3"
|
|
7356
7361
|
},
|
|
7357
7362
|
{
|
|
7358
7363
|
"path": "tests/public_mirror_export.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
|
+
- 270 operations across 45 resource groups
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -533,6 +533,8 @@ function createClient(opts = {}) {
|
|
|
533
533
|
postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
|
|
534
534
|
// GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
|
|
535
535
|
getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
|
|
536
|
+
// GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
|
|
537
|
+
getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
|
|
536
538
|
// GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
|
|
537
539
|
getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
|
|
538
540
|
},
|
|
@@ -532,6 +532,8 @@ function createClient(opts = {}) {
|
|
|
532
532
|
postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
|
|
533
533
|
// GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
|
|
534
534
|
getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
|
|
535
|
+
// GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
|
|
536
|
+
getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
|
|
535
537
|
// GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
|
|
536
538
|
getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
|
|
537
539
|
},
|
|
@@ -75,6 +75,7 @@ export interface GetProvisioningHealthResponse { module: unknown; ok: boolean; c
|
|
|
75
75
|
export interface GetProvisioningInstancesIdResponse { instance: unknown; open_intent: unknown }
|
|
76
76
|
export interface GetProvisioningInstancesResponse { instances: unknown }
|
|
77
77
|
export interface GetProvisioningOnboardPlanResponse { mode: unknown; steps: unknown }
|
|
78
|
+
export interface GetProvisioningSlugAvailableResponse { slug: unknown; available: unknown; reason: unknown; address: unknown }
|
|
78
79
|
export interface GetPublicBlockersIdResponse { blocker: unknown }
|
|
79
80
|
export interface GetPublicBountyTableResponse { table: unknown; currency: unknown }
|
|
80
81
|
export interface GetPublicGradesResponse { window_days: unknown; rolling: unknown; trend: unknown; attempts: unknown; threshold: unknown; grader_version: unknown }
|
|
@@ -768,6 +769,8 @@ export interface BongosClient {
|
|
|
768
769
|
postProvisioningInstancesIdTeardown(args?: RequestArgs): Promise<PostProvisioningInstancesIdTeardownResponse>;
|
|
769
770
|
/** GET /provisioning/onboard-plan — rank: public */
|
|
770
771
|
getProvisioningOnboardPlan(args?: RequestArgs): Promise<GetProvisioningOnboardPlanResponse>;
|
|
772
|
+
/** GET /provisioning/slug-available — rank: any-builder */
|
|
773
|
+
getProvisioningSlugAvailable(args?: RequestArgs): Promise<GetProvisioningSlugAvailableResponse>;
|
|
771
774
|
/** GET /provisioning/tls-check — rank: public */
|
|
772
775
|
getProvisioningTlsCheck(args?: RequestArgs): Promise<ApiResponse>;
|
|
773
776
|
};
|
|
@@ -529,6 +529,8 @@ export function createClient(opts = {}) {
|
|
|
529
529
|
postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
|
|
530
530
|
// GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
|
|
531
531
|
getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
|
|
532
|
+
// GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
|
|
533
|
+
getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
|
|
532
534
|
// GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
|
|
533
535
|
getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
|
|
534
536
|
},
|
package/docs/api/openapi.json
CHANGED
|
@@ -8754,6 +8754,44 @@
|
|
|
8754
8754
|
"security": []
|
|
8755
8755
|
}
|
|
8756
8756
|
},
|
|
8757
|
+
"/provisioning/slug-available": {
|
|
8758
|
+
"get": {
|
|
8759
|
+
"operationId": "get_provisioning_slug_available",
|
|
8760
|
+
"tags": [
|
|
8761
|
+
"provisioning"
|
|
8762
|
+
],
|
|
8763
|
+
"summary": "GET /provisioning/slug-available",
|
|
8764
|
+
"description": "GET /provisioning/slug-available?slug=<handle> — is this handle free to create under? rank: any authenticated builder (same gate as the create route it previews; deliberately NOT public — the answer is a yes/no about every project slug on the instance, so it is an enumeration oracle and carries its own per-IP limiter). It ADVISORY-previews the create route and must agree with it exactly, so the normalization and the bar below are lifted from POST /provisioning/instances: trim+lowercase → isValidSlug → getInstanceBySlug (the byte-exact `slug text UNIQUE` in provisioning_001_tables.sql, which is what the 23505 catch in createInstance reflects — so no lower(slug) here, or we would over-report taken). The three refusals mirror the three ways create actually answers: 'taken' — another builder holds it → create 409s slug_taken 'yours' — the CALLER holds it → create is an idempotent re-request (200, created:false), not a new project, so it is not free to create under 'reserved' — the row is free but <slug>.<apex> is a platform-reserved address → create 400s domain_reserved on the DEFAULT (assigned-address) path. A no_address create would still succeed; the wizard only asks about the default path, and reporting it distinctly keeps that recoverable. NARROWS the TOCTOU window, never closes it: another builder can take the slug between this answer and the create. The 409 at POST /provisioning/instances remains the ONLY authority — this exists so a collision is seen while the owner is still typing instead of at the final Create click.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
8765
|
+
"x-rank": "any-builder",
|
|
8766
|
+
"x-source": "modules/provisioning/routes/provisioning.js",
|
|
8767
|
+
"responses": {
|
|
8768
|
+
"200": {
|
|
8769
|
+
"description": "Success.",
|
|
8770
|
+
"content": {
|
|
8771
|
+
"application/json": {
|
|
8772
|
+
"schema": {
|
|
8773
|
+
"$ref": "#/components/schemas/GetProvisioningSlugAvailableResponse"
|
|
8774
|
+
}
|
|
8775
|
+
}
|
|
8776
|
+
}
|
|
8777
|
+
},
|
|
8778
|
+
"400": {
|
|
8779
|
+
"$ref": "#/components/responses/BadRequest"
|
|
8780
|
+
},
|
|
8781
|
+
"401": {
|
|
8782
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
8783
|
+
},
|
|
8784
|
+
"403": {
|
|
8785
|
+
"$ref": "#/components/responses/Forbidden"
|
|
8786
|
+
}
|
|
8787
|
+
},
|
|
8788
|
+
"security": [
|
|
8789
|
+
{
|
|
8790
|
+
"builderSession": []
|
|
8791
|
+
}
|
|
8792
|
+
]
|
|
8793
|
+
}
|
|
8794
|
+
},
|
|
8757
8795
|
"/provisioning/tls-check": {
|
|
8758
8796
|
"get": {
|
|
8759
8797
|
"operationId": "get_provisioning_tls_check",
|
|
@@ -13109,6 +13147,21 @@
|
|
|
13109
13147
|
"steps"
|
|
13110
13148
|
]
|
|
13111
13149
|
},
|
|
13150
|
+
"GetProvisioningSlugAvailableResponse": {
|
|
13151
|
+
"type": "object",
|
|
13152
|
+
"properties": {
|
|
13153
|
+
"slug": {},
|
|
13154
|
+
"available": {},
|
|
13155
|
+
"reason": {},
|
|
13156
|
+
"address": {}
|
|
13157
|
+
},
|
|
13158
|
+
"required": [
|
|
13159
|
+
"slug",
|
|
13160
|
+
"available",
|
|
13161
|
+
"reason",
|
|
13162
|
+
"address"
|
|
13163
|
+
]
|
|
13164
|
+
},
|
|
13112
13165
|
"GetPublicBlockersIdResponse": {
|
|
13113
13166
|
"type": "object",
|
|
13114
13167
|
"properties": {
|
|
@@ -16983,9 +17036,9 @@
|
|
|
16983
17036
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
16984
17037
|
}
|
|
16985
17038
|
},
|
|
16986
|
-
"x-endpoint-count":
|
|
16987
|
-
"x-schema-count":
|
|
17039
|
+
"x-endpoint-count": 270,
|
|
17040
|
+
"x-schema-count": 303,
|
|
16988
17041
|
"x-undocumented-bodies": 8,
|
|
16989
|
-
"x-response-schemas":
|
|
17042
|
+
"x-response-schemas": 205,
|
|
16990
17043
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
16991
17044
|
}
|
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. 270 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` (15)
|
|
362
362
|
|
|
363
363
|
| Method | Path | Rank | Body | Description |
|
|
364
364
|
|---|---|---|---|---|
|
|
@@ -375,6 +375,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
375
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… |
|
|
376
376
|
| POST | `/api/bongos/provisioning/instances/:id/teardown` | `any-builder` | — | POST /provisioning/instances/:id/teardown — request teardown of the caller's OWN instance. |
|
|
377
377
|
| GET | `/api/bongos/provisioning/onboard-plan` | `public` | — | GET /provisioning/onboard-plan?mode=greenfield\|adopt&domain=<host> — the CANONICAL onboarding step sequence (task 2085; goal 35 / criteri… |
|
|
378
|
+
| GET | `/api/bongos/provisioning/slug-available` | `any-builder` | — | GET /provisioning/slug-available?slug=<handle> — is this handle free to create under? rank: any authenticated builder (same gate as the c… |
|
|
378
379
|
| GET | `/api/bongos/provisioning/tls-check` | `public` | — | GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST (ADR 0111 §4, task P4 task 1945). |
|
|
379
380
|
|
|
380
381
|
## `public` (18)
|
|
@@ -532,6 +532,8 @@ function createClient(opts = {}) {
|
|
|
532
532
|
postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
|
|
533
533
|
// GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
|
|
534
534
|
getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
|
|
535
|
+
// GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
|
|
536
|
+
getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
|
|
535
537
|
// GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
|
|
536
538
|
getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
|
|
537
539
|
},
|
|
@@ -12,14 +12,24 @@
|
|
|
12
12
|
//
|
|
13
13
|
// In-memory + per-process (the core limiter's model too). `now` is injectable so the
|
|
14
14
|
// window logic is unit-testable without a real clock.
|
|
15
|
+
//
|
|
16
|
+
// `key` picks the bucket, and defaults to the caller's IP — the only identity a PUBLIC
|
|
17
|
+
// endpoint has. An AUTHENTICATED route should key on the builder instead (task 1002898):
|
|
18
|
+
// per-IP starves co-located builders behind one office/CGNAT address for each other,
|
|
19
|
+
// and it is the weaker key anyway once a session is required, since an attacker rotates
|
|
20
|
+
// IPs far more easily than accounts. Keying on a bounded id also bounds the Map.
|
|
15
21
|
'use strict';
|
|
16
22
|
|
|
17
|
-
function
|
|
18
|
-
|
|
23
|
+
function clientIp(req) {
|
|
24
|
+
return req.ip || (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now, key = clientIp } = {}) {
|
|
28
|
+
const buckets = new Map(); // key -> array of recent hit timestamps within the window
|
|
19
29
|
return function askRateLimit(req, res, next) {
|
|
20
30
|
const t = now();
|
|
21
|
-
const ip =
|
|
22
|
-
// Prune this
|
|
31
|
+
const ip = key(req) || 'unknown';
|
|
32
|
+
// Prune this caller's bucket to the window, then decide.
|
|
23
33
|
const hits = (buckets.get(ip) || []).filter((ts) => ts > t - windowMs);
|
|
24
34
|
if (hits.length >= max) {
|
|
25
35
|
const retryAfter = Math.max(1, Math.ceil((hits[0] + windowMs - t) / 1000));
|
|
@@ -32,4 +42,4 @@ function makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now } = {}
|
|
|
32
42
|
};
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
module.exports = { makeAskRateLimiter };
|
|
45
|
+
module.exports = { makeAskRateLimiter, clientIp };
|
|
@@ -33,7 +33,7 @@ const auth = api;
|
|
|
33
33
|
const { pool, validateOrRespond, parseId } = api;
|
|
34
34
|
const provisioning = require('../provisioning');
|
|
35
35
|
const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
36
|
-
const { makeAskRateLimiter } = require('../rate-limit');
|
|
36
|
+
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
37
37
|
|
|
38
38
|
// How long a GitHub-App-Manifest `state` token is valid — the owner must click the
|
|
39
39
|
// pre-filled link + approve on GitHub within this window (task 2080). Comfortably
|
|
@@ -242,6 +242,18 @@ module.exports = function provisioningRoutes() {
|
|
|
242
242
|
// higher than tls-check's one-shot ask. 120/min/IP gives comfortable headroom while
|
|
243
243
|
// still shedding a flood; in-memory, own bucket (does not share tls-check's budget).
|
|
244
244
|
const statusRateLimit = makeAskRateLimiter({ max: 120 });
|
|
245
|
+
// Limiter for the slug-availability check (task 1002898 N10). Signed-in-only, but a
|
|
246
|
+
// session is cheap and the route answers a yes/no about EVERY project slug on the
|
|
247
|
+
// instance — an enumeration oracle if left unthrottled. A wizard debouncing one handle
|
|
248
|
+
// field fires a handful per minute, so 30 is roomy for a human and tight for a crawler.
|
|
249
|
+
// Keyed on the BUILDER, not the IP (unlike the public limiters above): the route is
|
|
250
|
+
// authenticated, so per-IP would starve co-located builders behind one office address
|
|
251
|
+
// for each other while an attacker just rotates IPs. Own bucket either way — it must
|
|
252
|
+
// not spend, or be starved by, tls-check's budget.
|
|
253
|
+
const slugCheckRateLimit = makeAskRateLimiter({
|
|
254
|
+
max: 30,
|
|
255
|
+
key: (req) => (req.builder ? `b:${req.builder.id}` : clientIp(req)),
|
|
256
|
+
});
|
|
245
257
|
|
|
246
258
|
// Route ORDER is deliberate (the box.js precedent): the Archon-gated routes are
|
|
247
259
|
// declared FIRST, then the any-builder routes. The route-rank auditor
|
|
@@ -358,6 +370,16 @@ module.exports = function provisioningRoutes() {
|
|
|
358
370
|
reason: 'adopt layers onto an existing repo, so target_ref (owner/repo) is required',
|
|
359
371
|
});
|
|
360
372
|
}
|
|
373
|
+
// KNOWN GAP — the credential preflight belongs HERE, and is deliberately absent.
|
|
374
|
+
// This route validates that target_ref is PRESENT but never that the owner holds a
|
|
375
|
+
// usable repo credential for it, so an API caller can queue a standup that dies
|
|
376
|
+
// ~2 minutes later in raw git stderr (the task-1002697 walk did exactly that; the
|
|
377
|
+
// web wizard is only incidentally protected because it 403s earlier). Carved out of
|
|
378
|
+
// task 1002898 as N3 and tracked by task 1002909, which is BLOCKED on task 1002735
|
|
379
|
+
// deciding what credential a private-repo standup actually needs: written against
|
|
380
|
+
// the wrong definition, a preflight would refuse valid creates or keep admitting
|
|
381
|
+
// doomed ones. Greenfield must stay exempt when it lands — the platform creates
|
|
382
|
+
// that repo itself, so there is no pre-existing repo to hold a credential for.
|
|
361
383
|
try {
|
|
362
384
|
// A re-request must not re-litigate the address: createInstance never updates
|
|
363
385
|
// an existing row's domain, so for one the guards below could only misfire —
|
|
@@ -479,6 +501,54 @@ module.exports = function provisioningRoutes() {
|
|
|
479
501
|
}
|
|
480
502
|
});
|
|
481
503
|
|
|
504
|
+
// GET /provisioning/slug-available?slug=<handle> — is this handle free to create
|
|
505
|
+
// under? rank: any authenticated builder (same gate as the create route it previews;
|
|
506
|
+
// deliberately NOT public — the answer is a yes/no about every project slug on the
|
|
507
|
+
// instance, so it is an enumeration oracle and carries its own per-IP limiter).
|
|
508
|
+
//
|
|
509
|
+
// It ADVISORY-previews the create route and must agree with it exactly, so the
|
|
510
|
+
// normalization and the bar below are lifted from POST /provisioning/instances:
|
|
511
|
+
// trim+lowercase → isValidSlug → getInstanceBySlug (the byte-exact `slug text
|
|
512
|
+
// UNIQUE` in provisioning_001_tables.sql, which is what the 23505 catch in
|
|
513
|
+
// createInstance reflects — so no lower(slug) here, or we would over-report taken).
|
|
514
|
+
// The three refusals mirror the three ways create actually answers:
|
|
515
|
+
// 'taken' — another builder holds it → create 409s slug_taken
|
|
516
|
+
// 'yours' — the CALLER holds it → create is an idempotent re-request (200,
|
|
517
|
+
// created:false), not a new project, so it is not free to create under
|
|
518
|
+
// 'reserved' — the row is free but <slug>.<apex> is a platform-reserved address →
|
|
519
|
+
// create 400s domain_reserved on the DEFAULT (assigned-address) path.
|
|
520
|
+
// A no_address create would still succeed; the wizard only asks about
|
|
521
|
+
// the default path, and reporting it distinctly keeps that recoverable.
|
|
522
|
+
//
|
|
523
|
+
// NARROWS the TOCTOU window, never closes it: another builder can take the slug
|
|
524
|
+
// between this answer and the create. The 409 at POST /provisioning/instances
|
|
525
|
+
// remains the ONLY authority — this exists so a collision is seen while the owner
|
|
526
|
+
// is still typing instead of at the final Create click.
|
|
527
|
+
router.get('/provisioning/slug-available', auth.requireBuilder, slugCheckRateLimit, async (req, res) => {
|
|
528
|
+
const slug = String((req.query && req.query.slug) || '').trim().toLowerCase();
|
|
529
|
+
if (!provisioning.isValidSlug(slug)) {
|
|
530
|
+
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const apex = instanceApex();
|
|
534
|
+
const address = provisioning.defaultDomainForSlug(slug, apex);
|
|
535
|
+
const holder = await provisioning.getInstanceBySlug(pool, slug);
|
|
536
|
+
let reason = null;
|
|
537
|
+
if (holder) {
|
|
538
|
+
reason = String(holder.owner_builder_id) === String(req.builder.id) ? 'yours' : 'taken';
|
|
539
|
+
} else if (address && provisioning.isReservedHostname(address, apex)) {
|
|
540
|
+
reason = 'reserved';
|
|
541
|
+
}
|
|
542
|
+
// Only the verdict travels — never the holder's row. A signed-in stranger
|
|
543
|
+
// learning WHO owns a handle (or anything about their instance) would be a
|
|
544
|
+
// disclosure this route has no reason to make.
|
|
545
|
+
res.json({ slug, available: reason === null, reason, address: address || null });
|
|
546
|
+
} catch (err) {
|
|
547
|
+
console.error('[provisioning] GET /provisioning/slug-available', err);
|
|
548
|
+
res.fail('check_failed', { status: 500, message: 'internal error' });
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
482
552
|
// GET /provisioning/instances — the caller's own instances + each open intent.
|
|
483
553
|
// rank: any authenticated builder (own resource read).
|
|
484
554
|
router.get('/provisioning/instances', auth.requireBuilder, async (req, res) => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.143",
|
|
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.143",
|
|
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.143",
|
|
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",
|
|
@@ -273,7 +273,16 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
|
|
|
273
273
|
/* ── project cards ── */
|
|
274
274
|
.projects{list-style:none;display:flex;flex-direction:column;gap:18px;}
|
|
275
275
|
.proj{border:2px solid var(--line);border-radius:18px;padding:18px 20px;background:var(--bg-card);display:flex;gap:16px;align-items:flex-start;}
|
|
276
|
-
.
|
|
276
|
+
/* torn down = muted TOKENS, not a subtree dim. opacity multiplies AFTER every
|
|
277
|
+
token resolves, so .78 dimmed the WHOLE card — meta to 3.72:1 light / 4.16:1
|
|
278
|
+
dark, address to 4.35:1 light, and the chips and buttons with them (task
|
|
279
|
+
1002823, the .humanSteps>li.slocked pattern from 1002751). Only the name needs
|
|
280
|
+
an override: .projMeta is already --ink-faint and measures once the multiply
|
|
281
|
+
is gone. The chip, the "Torn down" line and the actions keep full strength —
|
|
282
|
+
a dead project's last known state and its Bring-it-back button are what the
|
|
283
|
+
card is there for — and the ghosted .orb stays the graphic cue. */
|
|
284
|
+
.proj.gone{border-color:color-mix(in srgb,var(--line) 55%,var(--bg-card));}
|
|
285
|
+
.proj.gone .projName{color:var(--ink-soft);}
|
|
277
286
|
/* click-to-manage landing glow: the sky's owned orb hands focus to this card */
|
|
278
287
|
.proj.anchored{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 30%,transparent);}
|
|
279
288
|
.projBody{flex:1;min-width:0;}
|
|
@@ -662,7 +671,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
662
671
|
border:2px solid var(--line);border-radius:999px;padding:7px 14px;white-space:nowrap;}
|
|
663
672
|
.filters button:hover{border-color:var(--accent);color:var(--accent-deep);}
|
|
664
673
|
.filters button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff;}
|
|
665
|
-
|
|
674
|
+
/* muted by TOKEN, not opacity — .75 multiplied after the ink resolved and took
|
|
675
|
+
the count to 4.05:1 on a resting pill and 3.23:1 on the pressed one (task
|
|
676
|
+
1002823). The pressed pill is excluded on purpose: its ground is the saturated
|
|
677
|
+
--accent fill, where no ink token clears 4.5:1 (--ink is the best at 3.70
|
|
678
|
+
light / 4.05 dark) — only the button's own #fff does, at 4.61, so the count
|
|
679
|
+
inherits it there. Size, weight and the mono face already say "secondary". */
|
|
680
|
+
.filters .n{font-family:var(--font-mono);font-weight:500;font-size:11.5px;}
|
|
681
|
+
.filters button:not([aria-pressed="true"]) .n{color:var(--ink-faint);}
|
|
666
682
|
.mineEmpty{font-size:14.5px;font-weight:600;color:var(--ink-soft);padding:10px 0 22px;}
|
|
667
683
|
|
|
668
684
|
/* ── the create rail ── */
|
|
@@ -255,6 +255,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
255
255
|
.addrConfirm.optedOut{border-color:var(--line);background:var(--bg);}
|
|
256
256
|
.addrConfirm.optedOut .addrBig{color:var(--ink-soft);}
|
|
257
257
|
.addrConfirm.optedOut>p{color:var(--ink-faint);}
|
|
258
|
+
/* the handle is not free (task 1002898 N10) — a refusal must not be painted into the
|
|
259
|
+
green confirmation box that means "yours" */
|
|
260
|
+
.addrConfirm.unavailable{border-color:color-mix(in srgb,var(--err) 45%,var(--bg-card));background:var(--bg);}
|
|
261
|
+
.addrConfirm.unavailable .addrBig{color:var(--err);}
|
|
258
262
|
.addrBig{font-family:var(--font-display);font-weight:700;font-size:19px;letter-spacing:-0.3px;line-height:1.4;}
|
|
259
263
|
.addrBig code{font-family:var(--font-mono);font-size:15px;background:var(--bg-deep);border-radius:8px;padding:2px 9px;color:var(--accent-deep);word-break:break-all;}
|
|
260
264
|
.addrConfirm>p{font-size:13.5px;font-weight:500;color:var(--ink-soft);line-height:1.5;margin-top:6px;}
|
|
@@ -713,8 +717,14 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
713
717
|
</section>
|
|
714
718
|
|
|
715
719
|
<!-- step 4 · address — a CONFIRMATION, not a form (task 1002704: every project
|
|
716
|
-
gets its slug subdomain assigned up front
|
|
717
|
-
|
|
720
|
+
gets its slug subdomain assigned up front). The domainless default was a
|
|
721
|
+
proven dead-end (QA walk, task 1002702).
|
|
722
|
+
Three choices, all legible without opening anything (task 1002898 N8):
|
|
723
|
+
the assigned address is stated, and BOTH alternatives sit under it — the
|
|
724
|
+
bring-your-own domain behind its disclosure, the no-address opt-out in the
|
|
725
|
+
open. The opt-out used to live INSIDE the "domain I own" fold, i.e. the
|
|
726
|
+
choice for NO address was hidden under a fold labelled the opposite
|
|
727
|
+
choice; the owner read the step as "weird" and never found it. -->
|
|
718
728
|
<section class="panel" id="panel-address" aria-labelledby="addr-h">
|
|
719
729
|
<h1 id="addr-h">Its address in the sky</h1>
|
|
720
730
|
<p class="lede">The handle names your project on the platform — and its address comes with it. Sign-in, sharing, everything just works from the moment it stands up.</p>
|
|
@@ -733,11 +743,11 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
733
743
|
<input type="text" id="f-domain" placeholder="mercury.example.com" autocomplete="off" maxlength="253" spellcheck="false">
|
|
734
744
|
<div class="hint" id="h-domain"> </div>
|
|
735
745
|
</label>
|
|
736
|
-
<label class="addrOpt"><input type="checkbox" id="f-noaddr">
|
|
737
|
-
<span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
|
|
738
|
-
</label>
|
|
739
746
|
</div>
|
|
740
747
|
</details>
|
|
748
|
+
<label class="addrOpt"><input type="checkbox" id="f-noaddr">
|
|
749
|
+
<span>Start without an address — possible, but sign-in on your project can’t be set up until it has one.</span>
|
|
750
|
+
</label>
|
|
741
751
|
</div>
|
|
742
752
|
<div class="nav">
|
|
743
753
|
<button type="button" class="btn ghost" data-back="3">← Back</button>
|
|
@@ -2113,30 +2123,101 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2113
2123
|
if (state.noAddress) return '';
|
|
2114
2124
|
return assignedDomain() || '';
|
|
2115
2125
|
}
|
|
2126
|
+
/* What the create POST may CARRY, as opposed to what the panel displays: only a
|
|
2127
|
+
bring-your-own domain. The assigned <slug>.<apex> default is the server's to
|
|
2128
|
+
apply (task 1002704) and re-asserting it here is what task 1002898 N2 closes —
|
|
2129
|
+
apexBase() falls back to guessing from location.hostname, and a guess that
|
|
2130
|
+
disagrees with the server's apex (a subdomain hub like get.<apex>) is refused
|
|
2131
|
+
as an under-apex squat. Omit it and the server assigns the right one. */
|
|
2132
|
+
function byoDomain() {
|
|
2133
|
+
return state.domain && looksLikeDomain(state.domain) ? state.domain : '';
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/* ── slug availability (task 1002898 N10) ──
|
|
2137
|
+
The panel promised '<slug>.<apex> — yours from the moment it stands up' on FORMAT
|
|
2138
|
+
validation alone, so a collision surfaced only as a 409 at the final Create click.
|
|
2139
|
+
GET /provisioning/slug-available answers the same question the create route will,
|
|
2140
|
+
and the promise is now made only once the server has said the handle is free.
|
|
2141
|
+
This NARROWS the race, it does not close it — the 409 at create is still the only
|
|
2142
|
+
authority — so an unanswered check degrades to naming the address WITHOUT the
|
|
2143
|
+
ownership promise, never to guessing in either direction. */
|
|
2144
|
+
var slugAvail = { slug: '', state: 'unknown' }; /* unknown | checking | free | taken | yours | reserved */
|
|
2145
|
+
var slugCheckT = 0;
|
|
2146
|
+
var slugCheckGen = 0;
|
|
2147
|
+
/* Only trust a verdict that belongs to the handle currently in the field. */
|
|
2148
|
+
function slugAvailState() {
|
|
2149
|
+
return slugAvail.slug === state.slug ? slugAvail.state : 'unknown';
|
|
2150
|
+
}
|
|
2151
|
+
/* Debounced, and generation-guarded because a cleared timer cannot cancel a chain
|
|
2152
|
+
already mid-fetch — each chain carries the generation it was born under, so an
|
|
2153
|
+
orphaned answer goes quiet instead of overwriting a newer one (the manifest-poll
|
|
2154
|
+
idiom below). The caller re-renders; this only sets state. */
|
|
2155
|
+
function checkSlugAvailable() {
|
|
2156
|
+
clearTimeout(slugCheckT);
|
|
2157
|
+
slugCheckGen += 1;
|
|
2158
|
+
var slug = state.slug;
|
|
2159
|
+
if (!validSlug(slug)) { slugAvail = { slug: slug, state: 'unknown' }; return; }
|
|
2160
|
+
slugAvail = { slug: slug, state: 'checking' };
|
|
2161
|
+
var myGen = slugCheckGen;
|
|
2162
|
+
slugCheckT = setTimeout(function () {
|
|
2163
|
+
fetch(API + '/provisioning/slug-available?slug=' + encodeURIComponent(slug), { credentials: 'same-origin' })
|
|
2164
|
+
.then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
|
|
2165
|
+
.then(function (d) {
|
|
2166
|
+
if (myGen !== slugCheckGen) return;
|
|
2167
|
+
slugAvail = { slug: slug, state: d.available ? 'free' : (d.reason || 'taken') };
|
|
2168
|
+
renderAddrPanel();
|
|
2169
|
+
})
|
|
2170
|
+
.catch(function () {
|
|
2171
|
+
if (myGen !== slugCheckGen) return;
|
|
2172
|
+
/* no answer is NOT a free answer — back to the format-only wording */
|
|
2173
|
+
slugAvail = { slug: slug, state: 'unknown' };
|
|
2174
|
+
renderAddrPanel();
|
|
2175
|
+
});
|
|
2176
|
+
}, 400);
|
|
2177
|
+
}
|
|
2116
2178
|
function renderAddrPanel() {
|
|
2117
2179
|
var box = $('addrConfirm');
|
|
2118
2180
|
if (!box) return;
|
|
2119
2181
|
var assigned = assignedDomain();
|
|
2120
2182
|
var custom = state.domain && looksLikeDomain(state.domain) ? state.domain : '';
|
|
2121
|
-
box.classList.toggle('optedOut', state.noAddress && !custom);
|
|
2122
2183
|
var big = $('addrAssigned');
|
|
2123
2184
|
var note = $('addrAssignedNote');
|
|
2185
|
+
/* the verdict only bears on the ASSIGNED address — a chosen domain or the
|
|
2186
|
+
opt-out settles the step without it */
|
|
2187
|
+
var avail = !custom && !state.noAddress && assigned ? slugAvailState() : 'unknown';
|
|
2188
|
+
box.classList.toggle('optedOut', state.noAddress && !custom);
|
|
2189
|
+
box.classList.toggle('unavailable', avail === 'taken' || avail === 'yours' || avail === 'reserved');
|
|
2124
2190
|
if (custom) {
|
|
2125
2191
|
big.innerHTML = '<code>' + esc(custom) + '</code> — your own domain, wired up for you.';
|
|
2126
2192
|
note.textContent = 'DNS pointing happens after the standup; the card on your projects page walks you through it.';
|
|
2127
2193
|
} else if (state.noAddress) {
|
|
2128
2194
|
big.textContent = 'Starting without an address.';
|
|
2129
2195
|
note.textContent = 'Sign-in on your project can’t be set up until it has one — add it any time from your projects page.';
|
|
2130
|
-
} else if (assigned) {
|
|
2196
|
+
} else if (assigned && avail === 'free') {
|
|
2131
2197
|
big.innerHTML = '<code>' + esc(assigned) + '</code> — yours from the moment it stands up.';
|
|
2132
2198
|
note.textContent = 'Sign-in, sharing, everything just works. You can point a domain you own at it any time.';
|
|
2199
|
+
} else if (assigned && avail === 'taken') {
|
|
2200
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — already taken.';
|
|
2201
|
+
note.textContent = 'Another project on the platform holds that handle. Pick a different one.';
|
|
2202
|
+
} else if (assigned && avail === 'yours') {
|
|
2203
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — you already have that one.';
|
|
2204
|
+
note.textContent = 'That handle belongs to one of your own projects. Pick a different one to start something new.';
|
|
2205
|
+
} else if (assigned && avail === 'reserved') {
|
|
2206
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — reserved by the platform.';
|
|
2207
|
+
note.textContent = 'That address is the platform’s own. Pick a different handle, or bring a domain you own.';
|
|
2208
|
+
} else if (assigned && avail === 'checking') {
|
|
2209
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — checking that it’s free…';
|
|
2210
|
+
note.textContent = 'One moment.';
|
|
2211
|
+
} else if (assigned) {
|
|
2212
|
+
/* the check didn’t answer — name the address, promise nothing about it */
|
|
2213
|
+
big.innerHTML = '<code>' + esc(assigned) + '</code> — the address it would take.';
|
|
2214
|
+
note.textContent = 'We couldn’t check whether that handle is free just now — you’ll know for certain when you create it.';
|
|
2133
2215
|
} else if (!validSlug(state.slug)) {
|
|
2134
2216
|
big.textContent = 'Pick a handle above and the address comes with it.';
|
|
2135
2217
|
note.textContent = '';
|
|
2136
2218
|
} else {
|
|
2137
2219
|
/* dotless host (local dev): nothing sane to auto-assign — the domain
|
|
2138
|
-
field IS the step here, so unfold it
|
|
2139
|
-
opt-out checkbox unreachable-by-default; owner walk, 2026-08-12) */
|
|
2220
|
+
field IS the step here, so unfold it */
|
|
2140
2221
|
big.textContent = 'No address is assigned on this host.';
|
|
2141
2222
|
note.textContent = 'Add a domain you own below, or start without one.';
|
|
2142
2223
|
$('addrCustom').open = true;
|
|
@@ -2202,7 +2283,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2202
2283
|
(a pre-auth probe 401s for strangers and, worse, could seed the
|
|
2203
2284
|
draft's owner with myLogin()'s placeholder) */
|
|
2204
2285
|
if (step === 3) { syncHome(); if (authKnown) ensureRepoAccess(); }
|
|
2205
|
-
|
|
2286
|
+
/* re-check on every entry, not just on typing: a resumed draft arrives here with
|
|
2287
|
+
a handle nobody has asked the server about, and a handle free ten minutes ago
|
|
2288
|
+
may not be now (task 1002898 N10) */
|
|
2289
|
+
if (step === 4) { checkSlugAvailable(); renderAddrPanel(); }
|
|
2206
2290
|
if (step === 5) renderReview();
|
|
2207
2291
|
}
|
|
2208
2292
|
|
|
@@ -2427,6 +2511,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2427
2511
|
if (!state.slug) { h.innerHTML = ' '; h.className = 'hint'; this.classList.remove('bad'); }
|
|
2428
2512
|
else if (validSlug(state.slug)) { h.textContent = 'Nice — that works.'; h.className = 'hint ok'; this.classList.remove('bad'); }
|
|
2429
2513
|
else { h.textContent = '2–40 lowercase letters, numbers, or dashes; no leading/trailing/double dash.'; h.className = 'hint err'; this.classList.add('bad'); }
|
|
2514
|
+
checkSlugAvailable();
|
|
2430
2515
|
renderAddrPanel();
|
|
2431
2516
|
saveDraft();
|
|
2432
2517
|
});
|
|
@@ -2481,6 +2566,23 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2481
2566
|
return false;
|
|
2482
2567
|
}
|
|
2483
2568
|
if (state.domain && !looksLikeDomain(state.domain)) return false;
|
|
2569
|
+
/* A handle the server has already said is not free would only 409 at Create —
|
|
2570
|
+
stop here instead (task 1002898 N10). ONLY a definite negative blocks:
|
|
2571
|
+
'unknown' and 'checking' must never gate the step, because the check is
|
|
2572
|
+
advisory and the 409 remains the authority. A chosen domain or the opt-out
|
|
2573
|
+
settles the address another way, so the block does not apply there. */
|
|
2574
|
+
if (!state.domain && !state.noAddress) {
|
|
2575
|
+
var av = slugAvailState();
|
|
2576
|
+
if (av === 'taken' || av === 'yours' || av === 'reserved') {
|
|
2577
|
+
$('h-slug').textContent = av === 'taken'
|
|
2578
|
+
? 'That handle is already taken — pick a different one.'
|
|
2579
|
+
: av === 'yours'
|
|
2580
|
+
? 'You already have a project with that handle — pick a different one.'
|
|
2581
|
+
: 'That handle is reserved by the platform — pick a different one.';
|
|
2582
|
+
$('h-slug').className = 'hint err'; $('f-slug').classList.add('bad');
|
|
2583
|
+
return false;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2484
2586
|
/* no auto-assigned address (dotless host): going domainless must be an
|
|
2485
2587
|
EXPLICIT choice — a domain, or the opt-out box. Never silent. */
|
|
2486
2588
|
if (!resolvedDomain() && !state.noAddress) {
|
|
@@ -2593,10 +2695,15 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2593
2695
|
slug: state.slug,
|
|
2594
2696
|
type: state.projType || undefined,
|
|
2595
2697
|
hosting_shape: 'standalone',
|
|
2596
|
-
domain
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2698
|
+
/* ONLY a bring-your-own domain travels (task 1002898 N2). The server owns
|
|
2699
|
+
the assigned <slug>.<apex> default (task 1002704) and applies it to an
|
|
2700
|
+
omitted domain, so re-sending our own derived copy could only disagree
|
|
2701
|
+
with it — apexBase() may be guessing from location.hostname, and a guess
|
|
2702
|
+
that lands under the apex but isn't the server's assignment is refused
|
|
2703
|
+
as a squat. Omitting it is how the default is now requested. */
|
|
2704
|
+
domain: byoDomain() || undefined,
|
|
2705
|
+
/* An absent domain means "assign me the default", so the opt-out must
|
|
2706
|
+
travel explicitly — it is the only thing that means "none". */
|
|
2600
2707
|
no_address: state.noAddress ? true : undefined,
|
|
2601
2708
|
target_ref: state.owner + '/' + state.repo,
|
|
2602
2709
|
/* LOAD-BEARING for the control-plane runner, not just for the checklist we
|
|
@@ -2618,9 +2725,14 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2618
2725
|
});
|
|
2619
2726
|
}).then(function (data) {
|
|
2620
2727
|
try {
|
|
2728
|
+
/* The address the instance ACTUALLY holds, read off the create response —
|
|
2729
|
+
not our preview of it (task 1002898 N2). The server owns the assigned
|
|
2730
|
+
default, so its answer is the only one the done panel may promise; a
|
|
2731
|
+
client-derived copy could name an address that was never assigned. */
|
|
2732
|
+
var madeDomain = (data.instance && data.instance.domain) || '';
|
|
2621
2733
|
sessionStorage.setItem(CREATED_KEY, JSON.stringify({
|
|
2622
|
-
id: data.instance && data.instance.id, slug: state.slug, domain:
|
|
2623
|
-
noaddr:
|
|
2734
|
+
id: data.instance && data.instance.id, slug: state.slug, domain: madeDomain,
|
|
2735
|
+
noaddr: !madeDomain,
|
|
2624
2736
|
mode: state.mode, name: state.name, at: Date.now()
|
|
2625
2737
|
}));
|
|
2626
2738
|
sessionStorage.removeItem(DRAFT_KEY);
|
package/src/module-api.js
CHANGED
|
@@ -711,8 +711,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
711
711
|
// landed since 1.19.140 with no explicit bump. run 31892484952. (task 1002620)
|
|
712
712
|
// 1.19.142 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
713
713
|
// landed since 1.19.141 with no explicit bump. run 31893298770. (task 1002620)
|
|
714
|
+
// 1.19.143 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
715
|
+
// landed since 1.19.142 with no explicit bump. run 31893905320. (task 1002620)
|
|
714
716
|
// ---------------------------------------------------------------------------
|
|
715
|
-
const CORE_VERSION = '1.19.
|
|
717
|
+
const CORE_VERSION = '1.19.143'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
716
718
|
|
|
717
719
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
718
720
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -207,6 +207,15 @@ const PAIRS = [
|
|
|
207
207
|
// must still measure (19px/700 headline = large text, 3.0)
|
|
208
208
|
['optedOut .addrBig (large) on --bg', 'var(--ink-soft)', 'var(--bg)', 3.0],
|
|
209
209
|
['addrOpt warning on --bg', 'var(--warn)', 'var(--bg)', 4.5],
|
|
210
|
+
// ?view=mine (task 1002823) — both surfaces traded a subtree opacity for muted
|
|
211
|
+
// tokens, so the tokens themselves now carry the de-emphasis and must measure.
|
|
212
|
+
// The two pill rows are the pair that proves the split: one mute cannot serve
|
|
213
|
+
// both grounds, because on the pressed --accent fill no ink token clears 4.5
|
|
214
|
+
// (--ink is the best at 3.70 light / 4.05 dark) and only the button's #fff does.
|
|
215
|
+
['.filters .n count, resting pill', 'var(--ink-faint)', CARD, 4.5],
|
|
216
|
+
['.filters .n count, pressed pill', '#ffffff', 'var(--accent)', 4.5],
|
|
217
|
+
['.proj.gone name (muted)', 'var(--ink-soft)', CARD, 4.5],
|
|
218
|
+
['.proj.gone meta (dim removed)', 'var(--ink-faint)', CARD, 4.5],
|
|
210
219
|
// lifecycle strip: 12px/700
|
|
211
220
|
['.strip .slbl idle', 'var(--ink-faint)', CARD, 4.5],
|
|
212
221
|
['.strip .slbl now/done', 'color-mix(in srgb,var(--sea) 40%,var(--ink))', CARD, 4.5],
|
|
@@ -416,9 +425,12 @@ const dimsBelowFull = (body) => {
|
|
|
416
425
|
|
|
417
426
|
test('text is never dimmed by subtree opacity', () => {
|
|
418
427
|
// Two nets. (1) Anywhere in either sheet: a rule that declares BOTH a color
|
|
419
|
-
// and a sub-1 opacity is dimmed text by construction. (2) The
|
|
420
|
-
//
|
|
421
|
-
//
|
|
428
|
+
// and a sub-1 opacity is dimmed text by construction. (2) The known text
|
|
429
|
+
// containers may not carry opacity at all — this also catches ancestor dims
|
|
430
|
+
// (.createSplit, .proj.gone) and blocks that declare NO color of their own,
|
|
431
|
+
// which is exactly how .filters .n and .proj.gone survived net 1 into task
|
|
432
|
+
// 1002823. Graphics are deliberately absent from the list: .orb.ghost and
|
|
433
|
+
// .worldOrb.dim carry no text and their opacity IS the state cue.
|
|
422
434
|
const offenders = [];
|
|
423
435
|
for (const [where, text] of [['cosmos.css', cssSrc], ['projects.html', pageSrc]]) {
|
|
424
436
|
for (const m of text.matchAll(/([^{}@]+)\{([^{}]*)\}/g)) {
|
|
@@ -427,7 +439,7 @@ test('text is never dimmed by subtree opacity', () => {
|
|
|
427
439
|
}
|
|
428
440
|
}
|
|
429
441
|
}
|
|
430
|
-
for (const sel of ['\\.humanSteps', '\\.addrConfirm', '\\.prog\\b', '\\.choice', '\\.review', '\\.createSplit', '\\.chip \\.rec']) {
|
|
442
|
+
for (const sel of ['\\.humanSteps', '\\.addrConfirm', '\\.prog\\b', '\\.choice', '\\.review', '\\.createSplit', '\\.chip \\.rec', '\\.filters', '\\.proj\\b']) {
|
|
431
443
|
for (const text of [cssSrc, pageSrc]) {
|
|
432
444
|
for (const m of text.matchAll(new RegExp(`${sel}[^{}]*\\{([^}]*)\\}`, 'g'))) {
|
|
433
445
|
if (dimsBelowFull(m[1])) offenders.push(`text container dimmed: ${m[0].slice(0, 90)}`);
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// tests/provisioning_slug_available.mjs
|
|
2
|
+
//
|
|
3
|
+
// GET /provisioning/slug-available (task 1002898 N10). The create wizard declared
|
|
4
|
+
// '<slug>.<apex> — yours from the moment it stands up' on FORMAT validation alone;
|
|
5
|
+
// no availability route existed anywhere, so a collision surfaced only as a 409 at
|
|
6
|
+
// the final Create click. This route answers the same question POST
|
|
7
|
+
// /provisioning/instances will, and the panel now waits for it before promising.
|
|
8
|
+
//
|
|
9
|
+
// The contract under test is AGREEMENT with the create route — the route is only
|
|
10
|
+
// worth having if its three refusals match the three ways create actually answers:
|
|
11
|
+
// taken → another builder holds the slug → create 409s slug_taken
|
|
12
|
+
// yours → the CALLER holds it → create is an idempotent re-request
|
|
13
|
+
// reserved → row free, <slug>.<apex> reserved → create 400s domain_reserved
|
|
14
|
+
// plus the same trim+lowercase normalization and the same isValidSlug bar, and the
|
|
15
|
+
// same byte-exact `slug text UNIQUE` lookup (no lower(slug), which would over-report).
|
|
16
|
+
//
|
|
17
|
+
// Also pinned: it requires a session (NOT public — the answer is a yes/no about every
|
|
18
|
+
// project slug on the instance) and it is rate-limited (same reason).
|
|
19
|
+
//
|
|
20
|
+
// DB-free: data-access fns are stubbed on the cached module object (the
|
|
21
|
+
// provisioning_default_domain.mjs harness). No network.
|
|
22
|
+
//
|
|
23
|
+
// Run: node tests/provisioning_slug_available.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
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── harness ─────────────────────────────────────────────────────────────────
|
|
39
|
+
let slugHolder = null; // what getInstanceBySlug returns
|
|
40
|
+
let lookedUp = []; // the exact string the route handed the lookup
|
|
41
|
+
provisioning.getInstanceBySlug = async (_db, s) => { lookedUp.push(s); return slugHolder; };
|
|
42
|
+
|
|
43
|
+
const api = require('../src/module-api.js');
|
|
44
|
+
let authOk = true; // flipped to prove the route is gated, not public
|
|
45
|
+
api.requireBuilder = (req, res, next) => {
|
|
46
|
+
if (!authOk) return res.status(401).json({ error: { code: 'unauthenticated' } });
|
|
47
|
+
req.builder = { id: 7, github_login: 'owner' };
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
51
|
+
let brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
|
|
52
|
+
api.branding = () => ({ domains: brandingDomains, identity: { productName: 'Cloud Bongos' } });
|
|
53
|
+
|
|
54
|
+
const express = require('express');
|
|
55
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
56
|
+
|
|
57
|
+
// Each app gets its OWN router — and therefore its own fresh rate-limit bucket, so
|
|
58
|
+
// the limiter test cannot be starved (or starve) the correctness tests.
|
|
59
|
+
function mount() {
|
|
60
|
+
const app = express();
|
|
61
|
+
app.use(express.json());
|
|
62
|
+
app.use((_req, res, next) => {
|
|
63
|
+
res.fail = (code, opts, details) => {
|
|
64
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
65
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
66
|
+
};
|
|
67
|
+
next();
|
|
68
|
+
});
|
|
69
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
70
|
+
return app;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const server = mount().listen(0);
|
|
74
|
+
await new Promise((r) => server.once('listening', r));
|
|
75
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
76
|
+
|
|
77
|
+
function get(path, origin = base) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const req = http.request(`${origin}${path}`, { method: 'GET' }, (res) => {
|
|
80
|
+
let d = '';
|
|
81
|
+
res.on('data', (c) => { d += c; });
|
|
82
|
+
res.on('end', () => {
|
|
83
|
+
let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ }
|
|
84
|
+
resolve({ status: res.statusCode, body: j, raw: d, headers: res.headers });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
req.on('error', reject);
|
|
88
|
+
req.end();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const heldBy = (ownerId) => ({ id: 41, owner_builder_id: ownerId, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' });
|
|
93
|
+
|
|
94
|
+
function reset() {
|
|
95
|
+
slugHolder = null;
|
|
96
|
+
lookedUp = [];
|
|
97
|
+
authOk = true;
|
|
98
|
+
brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── the answer, and its agreement with the create route ─────────────────────
|
|
102
|
+
console.log('GET /provisioning/slug-available — the verdict:');
|
|
103
|
+
|
|
104
|
+
await ta('a free handle → available, with the address create would assign', async () => {
|
|
105
|
+
reset();
|
|
106
|
+
const res = await get('/provisioning/slug-available?slug=spike');
|
|
107
|
+
assert.equal(res.status, 200, res.raw);
|
|
108
|
+
assert.deepEqual(res.body, { slug: 'spike', available: true, reason: null, address: 'spike.cloudbongos.com' });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await ta("held by ANOTHER builder → taken (the create route's 409 slug_taken)", async () => {
|
|
112
|
+
reset();
|
|
113
|
+
slugHolder = heldBy(99);
|
|
114
|
+
const res = await get('/provisioning/slug-available?slug=spike');
|
|
115
|
+
assert.equal(res.status, 200, res.raw);
|
|
116
|
+
assert.equal(res.body.available, false);
|
|
117
|
+
assert.equal(res.body.reason, 'taken');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await ta("held by the CALLER → 'yours', never 'taken' — create re-requests it, it is not a new project", async () => {
|
|
121
|
+
reset();
|
|
122
|
+
slugHolder = heldBy(7);
|
|
123
|
+
const res = await get('/provisioning/slug-available?slug=spike');
|
|
124
|
+
assert.equal(res.status, 200, res.raw);
|
|
125
|
+
assert.equal(res.body.available, false);
|
|
126
|
+
assert.equal(res.body.reason, 'yours', 'the caller must not be told a stranger holds it');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await ta('the holder row NEVER travels — only the verdict', async () => {
|
|
130
|
+
reset();
|
|
131
|
+
slugHolder = heldBy(99);
|
|
132
|
+
const res = await get('/provisioning/slug-available?slug=spike');
|
|
133
|
+
assert.deepEqual(Object.keys(res.body).sort(), ['address', 'available', 'reason', 'slug']);
|
|
134
|
+
assert.doesNotMatch(res.raw, /owner_builder_id|status|"id"/, 'no instance shape may leak');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await ta("a free row whose <slug>.<apex> is platform-reserved → 'reserved' (create 400s domain_reserved)", async () => {
|
|
138
|
+
reset();
|
|
139
|
+
const res = await get('/provisioning/slug-available?slug=www');
|
|
140
|
+
assert.equal(res.status, 200, res.raw);
|
|
141
|
+
assert.equal(res.body.available, false);
|
|
142
|
+
assert.equal(res.body.reason, 'reserved');
|
|
143
|
+
// agrees with the helper the create route itself calls
|
|
144
|
+
assert.equal(provisioning.isReservedHostname('www.cloudbongos.com', 'cloudbongos.com'), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await ta('a taken row wins over reserved — the row is the uniqueness rule', async () => {
|
|
148
|
+
reset();
|
|
149
|
+
slugHolder = heldBy(99);
|
|
150
|
+
const res = await get('/provisioning/slug-available?slug=www');
|
|
151
|
+
assert.equal(res.body.reason, 'taken');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── normalization: the same sequence the create route runs ──────────────────
|
|
155
|
+
console.log('\nnormalization — must match the create route byte for byte:');
|
|
156
|
+
|
|
157
|
+
await ta('trim + lowercase before the lookup, exactly as create does', async () => {
|
|
158
|
+
reset();
|
|
159
|
+
const res = await get('/provisioning/slug-available?slug=%20%20SPIKE%20%20');
|
|
160
|
+
assert.equal(res.status, 200, res.raw);
|
|
161
|
+
assert.equal(res.body.slug, 'spike', 'the normalized handle is echoed back');
|
|
162
|
+
assert.deepEqual(lookedUp, ['spike'], 'the UNIQUE index is byte-exact — the lookup must be too');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ta('an invalid handle → 400 bad_slug, the same bar and reason string as create', async () => {
|
|
166
|
+
for (const bad of ['a', '-bad', 'bad-', 'do--uble', 'Nope!', '', 'x'.repeat(41)]) {
|
|
167
|
+
reset();
|
|
168
|
+
const res = await get(`/provisioning/slug-available?slug=${encodeURIComponent(bad)}`);
|
|
169
|
+
assert.equal(res.status, 400, `${JSON.stringify(bad)} → ${res.raw}`);
|
|
170
|
+
assert.equal(res.body.error.code, 'bad_slug');
|
|
171
|
+
assert.match(res.body.error.reason, /lowercase, 2–40 chars/);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await ta('a missing slug param → 400 bad_slug, never a lookup', async () => {
|
|
176
|
+
reset();
|
|
177
|
+
const res = await get('/provisioning/slug-available');
|
|
178
|
+
assert.equal(res.status, 400, res.raw);
|
|
179
|
+
assert.equal(res.body.error.code, 'bad_slug');
|
|
180
|
+
assert.deepEqual(lookedUp, [], 'nothing may reach the database');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await ta('a blank apex (self-host) still answers availability, with no address to promise', async () => {
|
|
184
|
+
reset();
|
|
185
|
+
brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: '' };
|
|
186
|
+
const res = await get('/provisioning/slug-available?slug=www');
|
|
187
|
+
assert.equal(res.status, 200, res.raw);
|
|
188
|
+
assert.equal(res.body.address, null, 'nothing to assign → nothing to name');
|
|
189
|
+
assert.equal(res.body.available, true, 'with no apex there is no reserved address to collide with');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── the gate + the throttle ─────────────────────────────────────────────────
|
|
193
|
+
console.log('\nthe gate and the throttle — it is an enumeration oracle:');
|
|
194
|
+
|
|
195
|
+
await ta('requires a session — this route is NOT public', async () => {
|
|
196
|
+
reset();
|
|
197
|
+
authOk = false;
|
|
198
|
+
const res = await get('/provisioning/slug-available?slug=spike');
|
|
199
|
+
assert.equal(res.status, 401, res.raw);
|
|
200
|
+
assert.deepEqual(lookedUp, [], 'an unauthenticated caller learns nothing');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await ta('the limiter keys on the BUILDER, not the IP — one office address cannot starve itself', async () => {
|
|
204
|
+
reset();
|
|
205
|
+
const RL = require('../modules/provisioning/rate-limit.js');
|
|
206
|
+
// default key is still the IP (the public tls-check/status limiters depend on it)
|
|
207
|
+
assert.equal(RL.clientIp({ ip: '203.0.113.7' }), '203.0.113.7');
|
|
208
|
+
const seen = [];
|
|
209
|
+
const limiter = RL.makeAskRateLimiter({ max: 1, key: (req) => `b:${req.builder.id}` });
|
|
210
|
+
const runFor = (id) => new Promise((resolve) => {
|
|
211
|
+
const res = { set() {}, status(c) { this._c = c; return this; }, json(b) { resolve({ status: this._c, body: b }); } };
|
|
212
|
+
limiter({ ip: '203.0.113.7', builder: { id } }, res, () => { seen.push(id); resolve({ status: 200 }); });
|
|
213
|
+
});
|
|
214
|
+
assert.equal((await runFor(7)).status, 200, 'builder 7 gets its first call');
|
|
215
|
+
assert.equal((await runFor(7)).status, 429, 'and is throttled on its second');
|
|
216
|
+
assert.equal((await runFor(8)).status, 200,
|
|
217
|
+
'a DIFFERENT builder on the same IP still gets through — the bucket is per-builder');
|
|
218
|
+
assert.deepEqual(seen, [7, 8]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await ta('rate-limited — a crawler is shed with 429 + Retry-After', async () => {
|
|
222
|
+
reset();
|
|
223
|
+
const rlServer = mount().listen(0);
|
|
224
|
+
await new Promise((r) => rlServer.once('listening', r));
|
|
225
|
+
const rlBase = `http://127.0.0.1:${rlServer.address().port}/api/bongos`;
|
|
226
|
+
try {
|
|
227
|
+
let limited = null;
|
|
228
|
+
// the limiter's cap is 30/min/IP; walk past it and the next answer must be 429
|
|
229
|
+
for (let i = 0; i < 40 && !limited; i++) {
|
|
230
|
+
const res = await get(`/provisioning/slug-available?slug=spike${i}`, rlBase);
|
|
231
|
+
if (res.status === 429) limited = res;
|
|
232
|
+
}
|
|
233
|
+
assert.ok(limited, 'the route must stop answering under a flood');
|
|
234
|
+
assert.equal(limited.body.error, 'rate_limited');
|
|
235
|
+
assert.ok(Number(limited.headers['retry-after']) > 0, 'Retry-After must tell the caller when');
|
|
236
|
+
} finally {
|
|
237
|
+
rlServer.close();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
server.close();
|
|
242
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
243
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -251,6 +251,124 @@ test('the create-repo response keeps the html_url it already returned', async ()
|
|
|
251
251
|
assert.match(fn, /saveDraft\(\)/, 'the draft is persisted after the URL lands on state');
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
/* ── The address step's three choices (task 1002898 N2 / N8 / N10) ───────────
|
|
255
|
+
One root: the create path asserted things it had not verified. The step offered
|
|
256
|
+
an address it had not checked was free, hid the no-address opt-out under a fold
|
|
257
|
+
labelled the OPPOSITE choice, and re-sent a client-derived default the server
|
|
258
|
+
already owns. ── */
|
|
259
|
+
|
|
260
|
+
test('the no-address opt-out is visible at the step, not folded under "a domain I own" (N8)', async () => {
|
|
261
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
262
|
+
const panel = (body.match(/<div class="addrConfirm" id="addrConfirm">([\s\S]*?)\n {14}<\/div>/) || [, ''])[1];
|
|
263
|
+
assert.ok(panel, 'the address confirmation box must be extractable');
|
|
264
|
+
const fold = (panel.match(/<details id="addrCustom">[\s\S]*?<\/details>/) || [''])[0];
|
|
265
|
+
assert.ok(fold, 'the bring-your-own-domain disclosure still exists');
|
|
266
|
+
assert.match(fold, /id="f-domain"/, 'the domain field is what the fold is FOR');
|
|
267
|
+
assert.doesNotMatch(fold, /f-noaddr/,
|
|
268
|
+
'the opt-out for NO address may not live under a fold labelled "use a domain I own"');
|
|
269
|
+
assert.match(panel, /<\/details>[\s\S]*id="f-noaddr"/,
|
|
270
|
+
'the opt-out sits after the disclosure, in the open');
|
|
271
|
+
// all three choices legible at once: assigned address, own domain, no address
|
|
272
|
+
assert.match(panel, /id="addrAssigned"/);
|
|
273
|
+
assert.match(fold, /Use a domain I own instead/);
|
|
274
|
+
assert.match(panel, /Start without an address/);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('the ownership promise is gated on the availability answer, never on format (N10)', async () => {
|
|
278
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
279
|
+
const fn = (body.match(/function renderAddrPanel\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
280
|
+
assert.ok(fn, 'renderAddrPanel() must be extractable');
|
|
281
|
+
// the promise now rides a branch that requires the server's verdict
|
|
282
|
+
assert.match(fn, /avail === 'free'[\s\S]{0,200}yours from the moment it stands up/,
|
|
283
|
+
"'yours from the moment it stands up' must only render on a free verdict");
|
|
284
|
+
// and the honest alternatives exist rather than a single optimistic string
|
|
285
|
+
assert.match(fn, /already taken/, 'a taken handle is said, not confirmed');
|
|
286
|
+
assert.match(fn, /you already have that one/i, "the caller's own handle reads as theirs, not as a stranger's");
|
|
287
|
+
assert.match(fn, /reserved by the platform/, 'a platform-reserved address is said');
|
|
288
|
+
assert.match(fn, /checking that it’s free/, 'the in-flight state has its own calm rung');
|
|
289
|
+
// the degrade: an unanswered check names the address WITHOUT promising it
|
|
290
|
+
assert.match(fn, /the address it would take/, 'no answer degrades to naming, not promising');
|
|
291
|
+
assert.match(fn, /couldn’t check whether that handle is free/, 'and says so honestly');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('the availability check is debounced, generation-guarded, and fails to "unknown" (N10)', async () => {
|
|
295
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
296
|
+
const fn = (body.match(/function checkSlugAvailable\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
297
|
+
assert.ok(fn, 'checkSlugAvailable() must be extractable');
|
|
298
|
+
assert.match(fn, /\/provisioning\/slug-available\?slug=/, 'it asks the real route');
|
|
299
|
+
assert.match(fn, /encodeURIComponent\(slug\)/, 'the handle is encoded into the query');
|
|
300
|
+
assert.match(fn, /clearTimeout\(slugCheckT\)[\s\S]*setTimeout\(/, 'debounced on a timer');
|
|
301
|
+
assert.match(fn, /myGen !== slugCheckGen/, 'a stale in-flight answer cannot overwrite a newer one');
|
|
302
|
+
assert.match(fn, /catch[\s\S]*state: 'unknown'/, 'a failed check is never read as free');
|
|
303
|
+
// a verdict only counts for the handle it was asked about
|
|
304
|
+
const guard = (body.match(/function slugAvailState\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
305
|
+
assert.match(guard, /slugAvail\.slug === state\.slug/, 'a verdict for another handle is not trusted');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('only a DEFINITE negative blocks the step — unknown and checking never gate it (N10)', async () => {
|
|
309
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
310
|
+
const fn = (body.match(/function validateStep\(step\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
311
|
+
assert.ok(fn, 'validateStep() must be extractable');
|
|
312
|
+
const gate = (fn.match(/if \(!state\.domain && !state\.noAddress\) \{[\s\S]*?\n {8}\}/) || [''])[0];
|
|
313
|
+
assert.ok(gate, 'the availability gate must be extractable');
|
|
314
|
+
assert.match(gate, /av === 'taken' \|\| av === 'yours' \|\| av === 'reserved'/,
|
|
315
|
+
'exactly the three definite negatives block');
|
|
316
|
+
assert.doesNotMatch(gate, /'unknown'|'checking'/,
|
|
317
|
+
'an unanswered or in-flight check must never trap the owner on the step');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("the wizard's validSlug() agrees with the server's isValidSlug(), handle for handle (N10)", async () => {
|
|
321
|
+
// The whole availability contract is "answer what create would answer" — but the
|
|
322
|
+
// page gates on its OWN hand-copied validSlug() before it even asks. If the two
|
|
323
|
+
// drift, the wizard promises (or refuses) an address the server would not, which is
|
|
324
|
+
// the exact class of dishonesty N10 exists to close. Run both over the same corpus.
|
|
325
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
326
|
+
const src = (body.match(/(function validSlug\(s\)\s*\{[\s\S]*?\n \})/) || [, ''])[1];
|
|
327
|
+
assert.ok(src, 'validSlug() must be extractable');
|
|
328
|
+
const clientValid = new Function('s', `${src}\nreturn validSlug(s);`);
|
|
329
|
+
const { createRequire } = await import('node:module');
|
|
330
|
+
const require = createRequire(import.meta.url);
|
|
331
|
+
const serverValid = require('../modules/provisioning/provisioning.js').isValidSlug;
|
|
332
|
+
|
|
333
|
+
const corpus = [
|
|
334
|
+
'mercury', 'a1', 'x'.repeat(40), 'has-dash', 'a-b-c', '0start', 'end0',
|
|
335
|
+
'a', '', 'x'.repeat(41), '-lead', 'trail-', 'do--uble', 'UPPER', 'sp ace',
|
|
336
|
+
'dot.ted', 'under_score', 'bang!', 'uni✦code', '--', '-', 'a--b',
|
|
337
|
+
];
|
|
338
|
+
for (const s of corpus) {
|
|
339
|
+
assert.equal(clientValid(s), serverValid(s),
|
|
340
|
+
`validSlug(${JSON.stringify(s)}) disagrees: page=${clientValid(s)} server=${serverValid(s)}`);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('the create POST lets the SERVER assign the default address (N2)', async () => {
|
|
345
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
346
|
+
// the wizard may only carry a bring-your-own domain; the <slug>.<apex> default is
|
|
347
|
+
// the server's (task 1002704), and re-asserting a client-derived copy could only
|
|
348
|
+
// disagree with it
|
|
349
|
+
assert.match(body, /domain: byoDomain\(\) \|\| undefined/,
|
|
350
|
+
'only a bring-your-own domain travels in the create body');
|
|
351
|
+
assert.doesNotMatch(body, /domain: resolvedDomain\(\) \|\| undefined/,
|
|
352
|
+
'the client-derived assigned default must no longer be re-sent');
|
|
353
|
+
const fn = (body.match(/function byoDomain\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
354
|
+
assert.ok(fn, 'byoDomain() must be extractable');
|
|
355
|
+
assert.match(fn, /looksLikeDomain\(state\.domain\)/, 'and only a domain that parses');
|
|
356
|
+
// the opt-out is still the only thing that means "no address"
|
|
357
|
+
assert.match(body, /no_address: state\.noAddress \? true : undefined/,
|
|
358
|
+
'the explicit opt-out shape must survive — an absent domain now means "assign one"');
|
|
359
|
+
// resolvedDomain() still drives what the page DISPLAYS; it just stops driving the wire
|
|
360
|
+
assert.match(body, /resolvedDomain\(\)/, 'the display path keeps its own resolver');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('the done panel promises the address the SERVER assigned, not our preview (N2)', async () => {
|
|
364
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
365
|
+
assert.match(body, /var madeDomain = \(data\.instance && data\.instance\.domain\) \|\| '';/,
|
|
366
|
+
'the created-instance stash reads the address off the create response');
|
|
367
|
+
assert.match(body, /domain: madeDomain/, 'and stashes that, not resolvedDomain()');
|
|
368
|
+
assert.match(body, /noaddr: !madeDomain/,
|
|
369
|
+
'no address on the row IS the no-address state — derived, not re-asserted');
|
|
370
|
+
});
|
|
371
|
+
|
|
254
372
|
test('the address row surfaces the manual-DNS note the runner recorded (F16)', async () => {
|
|
255
373
|
const { body } = await request('/projects', 'game.test.local');
|
|
256
374
|
assert.match(body, /inst\.dns_note/, 'the card reads the dns_note the runner writes');
|