@bongos/core 1.19.142 → 1.19.144

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.144",
6
+ "core_contract": "1.19.144",
7
+ "source_commit": "36beb6c51bf7da7c8677cd6cf29d3b3739b1fc86",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-15T15:39:26.241Z",
9
+ "built_at": "2026-08-15T16:06:10.087Z",
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": "5c594013e8ee432b685ee793f28efedacd67b463904cda4eb9942dd7dd6b62d3",
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": "a315183a94657af4ee558bc70eb2860782fa07576b9c55d0a028295f4287e2fc"
4181
4181
  },
4182
4182
  {
4183
4183
  "path": "package.json",
4184
4184
  "mode": "0000644",
4185
- "sha256": "b3d152773025a6478d933888ade55e094a55016f8260989361ef426838700d03"
4185
+ "sha256": "385cb6e8816fd2bfefa4a04d5a8bc4a9dac9e4d540903d9f736883b8d8abbc5c"
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": "4641a5c0c0222c0948ecdf57db9bb3cd88b5500089bbbbe735597abd4d44d4da"
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": "deae8db82f897ee77633ffb2b7f5144960d9961ba4cdb9e052346c6cb127b0c9"
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": "8b8d833fa63b772e134faf867ad771f33293f81d8af3dd695221489217dd7c89"
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": "dfb0f0b083a118c2bf8f424232f1e36e96f48aa962921649c487c93d5ce4eda2"
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.144",
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.144",
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.144",
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",
@@ -171,6 +171,18 @@
171
171
  navy pack accent falls under 4.5:1 on --bg-deep. Chrome keeps --accent-deep;
172
172
  only rules that paint a link inside prose take this. */
173
173
  --link-ink:var(--accent-deep);
174
+ /* CONTROL BOUNDARIES ONLY (task 1002824) — the edge that tells you a text
175
+ field IS a field. --line is a CARD hairline and measures 1.16:1 light /
176
+ 1.09:1 dark, so a resting input drawn with it was invisible until focus
177
+ (its fill is 1.02:1 on the card too, so the border is the ONLY boundary
178
+ there is); WCAG 1.4.11 asks 3:1. --line itself must not move — it also
179
+ draws .review, .humanSteps>li, .kv, .ghlist, the unselected .tpill and the
180
+ .choice cards, and darkening it globally would restyle every card in both
181
+ modes. Ink-derived on purpose, never accent-derived: a boundary that
182
+ changed hue with a re-theme would read as a state. 55% clears 3:1 with
183
+ headroom in the binding (light) mode — 3.75:1 on --card, 3.82:1 on the
184
+ input's own --bg-card fill; dark lands at 5.41/5.35. */
185
+ --ctl-line:color-mix(in srgb, var(--ink) 55%, var(--bg-card));
174
186
  --neutral-soft:color-mix(in srgb, var(--ink) 8%, var(--bg-card));
175
187
  /* ink-derived so the shadow is a WARM brown on the marigold ground instead of
176
188
  the cold navy it used to cast. The dark blocks override it with a plain black
@@ -273,7 +285,16 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
273
285
  /* ── project cards ── */
274
286
  .projects{list-style:none;display:flex;flex-direction:column;gap:18px;}
275
287
  .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;}
288
+ /* torn down = muted TOKENS, not a subtree dim. opacity multiplies AFTER every
289
+ token resolves, so .78 dimmed the WHOLE card — meta to 3.72:1 light / 4.16:1
290
+ dark, address to 4.35:1 light, and the chips and buttons with them (task
291
+ 1002823, the .humanSteps>li.slocked pattern from 1002751). Only the name needs
292
+ an override: .projMeta is already --ink-faint and measures once the multiply
293
+ is gone. The chip, the "Torn down" line and the actions keep full strength —
294
+ a dead project's last known state and its Bring-it-back button are what the
295
+ card is there for — and the ghosted .orb stays the graphic cue. */
296
+ .proj.gone{border-color:color-mix(in srgb,var(--line) 55%,var(--bg-card));}
297
+ .proj.gone .projName{color:var(--ink-soft);}
277
298
  /* click-to-manage landing glow: the sky's owned orb hands focus to this card */
278
299
  .proj.anchored{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 30%,transparent);}
279
300
  .projBody{flex:1;min-width:0;}
@@ -287,7 +308,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
287
308
  .addr .none{color:var(--ink-faint);font-style:italic;}
288
309
  .addr code{font-family:var(--font-mono);font-size:13px;background:var(--bg-deep);border-radius:6px;padding:1px 7px;color:var(--accent-deep);}
289
310
  .attach{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap;}
290
- .attach input[type=text]{flex:1;min-width:220px;font-family:var(--font-body);font-size:15px;font-weight:600;color:var(--ink);background:var(--bg-card);border:2px solid var(--line);border-radius:12px;padding:9px 14px;outline:none;transition:border-color .15s ease, box-shadow .15s ease;}
311
+ .attach input[type=text]{flex:1;min-width:220px;font-family:var(--font-body);font-size:15px;font-weight:600;color:var(--ink);background:var(--bg-card);border:2px solid var(--ctl-line);border-radius:12px;padding:9px 14px;outline:none;transition:border-color .15s ease, box-shadow .15s ease;}
291
312
  .attach input[type=text]::placeholder{color:var(--ink-faint);font-weight:500;}
292
313
  .attach input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 13%,transparent);}
293
314
  .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
@@ -651,7 +672,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
651
672
  the chip outcomes; it never relabels them — "Live" holds both `live` and
652
673
  `live?` because neither is known-down, and each card still says which. ── */
653
674
  .mineBar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:20px;}
654
- .mineBar .search{background:var(--bg-card);border-color:var(--line);}
675
+ .mineBar .search{background:var(--bg-card);border-color:var(--ctl-line);}
655
676
  .mineBar .search input{color:var(--ink);}
656
677
  .mineBar .search input::placeholder{color:var(--ink-faint);}
657
678
  .mineBar .search svg{stroke:var(--ink-faint);}
@@ -662,7 +683,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
662
683
  border:2px solid var(--line);border-radius:999px;padding:7px 14px;white-space:nowrap;}
663
684
  .filters button:hover{border-color:var(--accent);color:var(--accent-deep);}
664
685
  .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;}
686
+ /* muted by TOKEN, not opacity — .75 multiplied after the ink resolved and took
687
+ the count to 4.05:1 on a resting pill and 3.23:1 on the pressed one (task
688
+ 1002823). The pressed pill is excluded on purpose: its ground is the saturated
689
+ --accent fill, where no ink token clears 4.5:1 (--ink is the best at 3.70
690
+ light / 4.05 dark) — only the button's own #fff does, at 4.61, so the count
691
+ inherits it there. Size, weight and the mono face already say "secondary". */
692
+ .filters .n{font-family:var(--font-mono);font-weight:500;font-size:11.5px;}
693
+ .filters button:not([aria-pressed="true"]) .n{color:var(--ink-faint);}
666
694
  .mineEmpty{font-size:14.5px;font-weight:600;color:var(--ink-soft);padding:10px 0 22px;}
667
695
 
668
696
  /* ── the create rail ── */