@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 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.142",
6
- "core_contract": "1.19.142",
7
- "source_commit": "34ee06f88922e1b277d1d8780b0da9c9fa7edd2a",
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:39:26.241Z",
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": 1250,
14
+ "functional_verbatim": 1251,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1574,
20
- "tree_sha256": "c544fb023102eaf104fa988b33f2cb9c70a2d8817edf0549e3e5608ba47be6e4",
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": "36eb537043097dc8e05307adc06d893a2c5ceb8cceb63f24b64ade001a0a2640"
350
+ "sha256": "0942508db8fb6f31b9e095d197f8b4cdd4efdc4e754c46629ea299ff10d895a1"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "ad598bf38511e3e7bd9f25a82987126cd71cfe76ac8275ddee2bcd90eb8e6a11"
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": "43823273ec587b104f3e0296614ae8ea6c4dc5c96261dbbb359ae39c21cf4e79"
365
+ "sha256": "2c199a1ad61c8ca8a015b0918368da4c3bf4a8e6196edfd46b93048dda8e9eb5"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "eeb9b7bdf6054c92e3c605201c846d67ea20dd927355d2dc72479477c073fc7c"
370
+ "sha256": "e1359174cf948616e95a03232a8fe3f7a56e66aa94d578c786b6e7a258ce0221"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "6d441799f948513f840394bd382dba7e601e79a07fb7591bcb0e05aea2edd2a9"
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": "b9af2341b7017c6e21b53a611a824b8d3a59653f967e6f83218c9f1d25d0ff58"
1340
+ "sha256": "e88714b1178d43ff8aea451807a0a6095a3e90d4a592babc4ffb5c1baccac4ba"
1341
1341
  },
1342
1342
  {
1343
1343
  "path": "docs/api/openapi.json",
1344
1344
  "mode": "0000644",
1345
- "sha256": "35762381d39e1943558131e97d3541e28d47161a56ec0a3d664942c90fb9fa9b"
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": "43823273ec587b104f3e0296614ae8ea6c4dc5c96261dbbb359ae39c21cf4e79"
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": "7741c4ba99f687fd02e33682a541320064aecb4fb7a7dc6b492583acc20db9a3"
3880
+ "sha256": "27046325beffad5d1edfcc91c331048038900ef7d32f1d38796755c7990ba202"
3881
3881
  },
3882
3882
  {
3883
3883
  "path": "modules/provisioning/routes/provisioning.js",
3884
3884
  "mode": "0000644",
3885
- "sha256": "903a3324337f8c1f123f81ca176550df0afbdeeea1f18254d41489dc3c4fed5d"
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": "9c082e0279aa72767e3167df6277a3f34247a7d600bc184f00a4ca9380b97da5"
4180
+ "sha256": "5f68e2c2d9e7f46d2884dce06d8bc7a5ed71f7716800c1815d550e43bd896176"
4181
4181
  },
4182
4182
  {
4183
4183
  "path": "package.json",
4184
4184
  "mode": "0000644",
4185
- "sha256": "b3d152773025a6478d933888ade55e094a55016f8260989361ef426838700d03"
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": "0e023e2e98babfd7d748b2f7542af13c41be96cbcc84f2853110410a57fe7816"
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": "eb36261b21198d9217d96408de5ee898083aaf0d5f62368f1be3b4af9a908dcb"
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": "6eaa713a395bf793d4e07c0d68ae86a29473e410d393b859dcfd1d52bacb2811"
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": "15ebf10ea627a32e943d139f1a713ff46cfda14fa1a39a12d87bcc78934516c0"
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": "d7998f770254f2c0179a6269d408f571c8757a97defd2543462b8496cb5f3e23"
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
- - 269 operations across 45 resource groups
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
  },
@@ -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": 269,
16987
- "x-schema-count": 302,
17039
+ "x-endpoint-count": 270,
17040
+ "x-schema-count": 303,
16988
17041
  "x-undocumented-bodies": 8,
16989
- "x-response-schemas": 204,
17042
+ "x-response-schemas": 205,
16990
17043
  "x-generated-by": "scripts/gds/gen-api-docs.js"
16991
17044
  }
@@ -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. 269 endpoints across 44 route files.
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` (14)
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 makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now } = {}) {
18
- const buckets = new Map(); // ip -> array of recent hit timestamps within the window
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 = req.ip || (req.socket && req.socket.remoteAddress) || 'unknown';
22
- // Prune this IP's bucket to the window, then decide.
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.142",
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.142",
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.142",
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
- .proj.gone{opacity:0.78;}
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
- .filters .n{font-family:var(--font-mono);font-weight:500;font-size:11.5px;opacity:.75;}
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; opt-out lives in the disclosure).
717
- The domainless default was a proven dead-end (QA walk, task 1002702). -->
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">&nbsp;</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 (a folded disclosure made the
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
- if (step === 4) renderAddrPanel();
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 = '&nbsp;'; 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: resolvedDomain() || undefined,
2597
- /* The server now defaults an omitted domain to the assigned address
2598
- (task 1002704), so the opt-out must travel explicitly — an absent
2599
- domain no longer means "none". */
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: resolvedDomain(),
2623
- noaddr: !!state.noAddress,
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.142'; // CI auto-patch carrier (ADR 0161); see changelog above
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 create flow's
420
- // known text containers may not carry opacity at all — this also catches
421
- // ancestor dims (.createSplit) that net 1 can't see.
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');