@bongos/core 1.19.141 → 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.141",
6
- "core_contract": "1.19.141",
7
- "source_commit": "b945e540f7d2e847172a7d529a393ef7ed3cd181",
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:22:10.867Z",
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": "ebfb642cad8b628d4485648012f4b179c33923d35f37dd4f4be28e87245f99b9",
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": "e2dbef1b10a02d42af480300fa0be08f86312d8be984a64e1f0522f5f70b1152"
4180
+ "sha256": "5f68e2c2d9e7f46d2884dce06d8bc7a5ed71f7716800c1815d550e43bd896176"
4181
4181
  },
4182
4182
  {
4183
4183
  "path": "package.json",
4184
4184
  "mode": "0000644",
4185
- "sha256": "624eb72a1cc5d8f63b33038e823818c93159c0f92351f011c88202618c2aff45"
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": "a55b4dd516485060edb4f1cd0be5164bd2026d77186d9b19c1f728a64eda9c7b"
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": "1823d08908672433c4ef47c9320598fd1e6c949836daf8f874b5b802fc43f9c5"
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": "851121766a8f979a937d436e38efa09f77dae45687c464d7a5a8185373a0fcbe"
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": "3689ce2b23d0baedb5a277676e939af712cea8d0cb3c07857e33f36eb12e1233"
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.141",
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.141",
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.141",
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",
@@ -98,6 +98,7 @@
98
98
  --ink:#fdeedd; --ink-soft:#dcb693; --ink-faint:#b89272;
99
99
  --rule:#5e3521; --rule-soft:#3f2317;
100
100
  --accent-deep:color-mix(in srgb, var(--accent) 45%, #ffffff);
101
+ --link-ink:color-mix(in srgb, var(--accent) 51%, #ffffff);
101
102
  --sea-ink:color-mix(in srgb, var(--sea) 65%, #ffffff);
102
103
  --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
103
104
  --on-ok:#0b2e18;
@@ -125,6 +126,7 @@
125
126
  --ink:#fdeedd; --ink-soft:#dcb693; --ink-faint:#b89272;
126
127
  --rule:#5e3521; --rule-soft:#3f2317;
127
128
  --accent-deep:color-mix(in srgb, var(--accent) 45%, #ffffff);
129
+ --link-ink:color-mix(in srgb, var(--accent) 51%, #ffffff);
128
130
  --sea-ink:color-mix(in srgb, var(--sea) 65%, #ffffff);
129
131
  --ok:#4ade80; --warn:#fbbf24; --err:#f87171;
130
132
  --on-ok:#0b2e18;
@@ -160,6 +162,15 @@
160
162
  --sea as-is; dark derives brighter (the --accent-deep pattern) so a pack's
161
163
  light-tuned sea clears WCAG 1.4.11's 3:1 on the near-black card too */
162
164
  --sea-ink:var(--sea);
165
+ /* TEXT LINKS ONLY (task 1002822). Light is --accent-deep unchanged; dark needs
166
+ its own mix because --accent-deep and --ink-soft land 2 lightness points
167
+ apart there (#e9ad8f vs #dcb693, ΔE76 9.7) — the same colour, and dark links
168
+ carry no underline until hover, so weight was the only cue left. 51% is the
169
+ ceiling, not a preference: --accent-deep can't simply be pushed (it also
170
+ feeds --chrome-accent/--glass-ink/--hero-b/--foot-code), and past 51% a dark
171
+ navy pack accent falls under 4.5:1 on --bg-deep. Chrome keeps --accent-deep;
172
+ only rules that paint a link inside prose take this. */
173
+ --link-ink:var(--accent-deep);
163
174
  --neutral-soft:color-mix(in srgb, var(--ink) 8%, var(--bg-card));
164
175
  /* ink-derived so the shadow is a WARM brown on the marigold ground instead of
165
176
  the cold navy it used to cast. The dark blocks override it with a plain black
@@ -211,7 +222,7 @@ body{font-family:var(--font-body);color:var(--ink);background:var(--page-bg);min
211
222
  .card{background:var(--card);border-radius:26px;box-shadow:var(--shadow-card);padding:clamp(22px,3.6vw,40px);}
212
223
  h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32px);letter-spacing:-0.5px;line-height:1.15;margin-bottom:8px;}
213
224
  .lede{font-size:15.5px;color:var(--ink-soft);font-weight:500;line-height:1.55;margin-bottom:24px;max-width:62ch;}
214
- .lede a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
225
+ .lede a{color:var(--link-ink);font-weight:700;text-decoration:none;}
215
226
  .lede a:hover{text-decoration:underline;}
216
227
 
217
228
  /* ── buttons ── */
@@ -225,7 +236,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
225
236
  .btn.danger{background:transparent;color:var(--err);box-shadow:none;border:2px solid color-mix(in srgb,var(--err) 35%,transparent);}
226
237
  .btn.danger:hover{background:var(--err-soft);border-color:var(--err);}
227
238
  .btn .gh{width:17px;height:17px;fill:currentColor;}
228
- .linklike{background:none;border:none;cursor:pointer;font-family:var(--font-body);font-weight:700;font-size:13.5px;color:var(--accent-deep);text-decoration:underline;padding:6px 4px;}
239
+ .linklike{background:none;border:none;cursor:pointer;font-family:var(--font-body);font-weight:700;font-size:13.5px;color:var(--link-ink);text-decoration:underline;padding:6px 4px;}
229
240
 
230
241
  /* ── chips: the one status vocabulary (direction §4). Recency rides inside
231
242
  (.rec) — "live" always says when it was last verified. ── */
@@ -262,7 +273,16 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
262
273
  /* ── project cards ── */
263
274
  .projects{list-style:none;display:flex;flex-direction:column;gap:18px;}
264
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;}
265
- .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);}
266
286
  /* click-to-manage landing glow: the sky's owned orb hands focus to this card */
267
287
  .proj.anchored{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 30%,transparent);}
268
288
  .projBody{flex:1;min-width:0;}
@@ -271,7 +291,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
271
291
  .projMeta{font-size:13px;font-weight:600;color:var(--ink-faint);margin-top:6px;display:flex;gap:14px;flex-wrap:wrap;}
272
292
  .projMeta code{font-family:var(--font-mono);font-size:12.5px;background:var(--bg-deep);border-radius:6px;padding:1px 6px;color:var(--accent-deep);}
273
293
  .addr{margin-top:12px;font-size:14.5px;font-weight:600;color:var(--ink-soft);}
274
- .addr a{color:var(--accent-deep);font-weight:700;text-decoration:none;font-family:var(--font-mono);font-size:13.5px;}
294
+ .addr a{color:var(--link-ink);font-weight:700;text-decoration:none;font-family:var(--font-mono);font-size:13.5px;}
275
295
  .addr a:hover{text-decoration:underline;}
276
296
  .addr .none{color:var(--ink-faint);font-style:italic;}
277
297
  .addr code{font-family:var(--font-mono);font-size:13px;background:var(--bg-deep);border-radius:6px;padding:1px 7px;color:var(--accent-deep);}
@@ -282,7 +302,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
282
302
  .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
283
303
  .projMsg.ok{color:var(--ok);}
284
304
  .projMsg.err{color:var(--err);}
285
- .projMsg a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
305
+ .projMsg a{color:var(--link-ink);font-weight:700;text-decoration:none;}
286
306
  .projMsg a:hover{text-decoration:underline;}
287
307
  .projMsg details{margin-top:8px;}
288
308
  .projMsg summary{cursor:pointer;font-size:12.5px;font-weight:700;color:var(--ink-faint);}
@@ -293,7 +313,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
293
313
  .finePrint{font-size:12.5px;color:var(--ink-faint);font-weight:600;margin-top:16px;line-height:1.5;}
294
314
  /* a link in fine print still has to look like one — without this it fell back to
295
315
  the browser's default blue, the one colour in the page that is not ours */
296
- .finePrint a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
316
+ .finePrint a{color:var(--link-ink);font-weight:700;text-decoration:none;}
297
317
  .finePrint a:hover{text-decoration:underline;}
298
318
 
299
319
  /* ── progress strips: one visual, per-flow vocabularies (direction §4).
@@ -315,7 +335,7 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
315
335
  .strip .slbl{font-size:12px;font-weight:700;color:var(--ink-faint);}
316
336
  .strip .stages li.now .slbl,.strip .stages li.done .slbl{color:color-mix(in srgb,var(--sea) 40%,var(--ink));}
317
337
  .strip .pnote{text-align:center;font-size:13px;font-weight:600;color:var(--ink-soft);min-height:18px;line-height:1.5;}
318
- .strip .pnote a{color:var(--accent-deep);font-weight:700;text-decoration:none;}
338
+ .strip .pnote a{color:var(--link-ink);font-weight:700;text-decoration:none;}
319
339
  .strip.isLive{border-color:var(--ok-soft);background:linear-gradient(180deg,color-mix(in srgb,var(--ok) 8%,var(--bg-card)) 0%,var(--bg-card) 100%);}
320
340
  .strip.isLive .stages li.done:not(:first-child)::before{background:var(--ok);}
321
341
  .strip.isLive .stages li.done .sdot{border-color:var(--ok);background:var(--ok);}
@@ -651,7 +671,14 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
651
671
  border:2px solid var(--line);border-radius:999px;padding:7px 14px;white-space:nowrap;}
652
672
  .filters button:hover{border-color:var(--accent);color:var(--accent-deep);}
653
673
  .filters button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff;}
654
- .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);}
655
682
  .mineEmpty{font-size:14.5px;font-weight:600;color:var(--ink-soft);padding:10px 0 22px;}
656
683
 
657
684
  /* ── the create rail ── */