@bongos/core 1.19.419 → 1.19.421

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.
Files changed (61) hide show
  1. package/.bongos-core.json +108 -58
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +282 -269
  13. package/docs/copy-registry.json +487 -368
  14. package/docs/design/mocks/hub/mine.html +377 -0
  15. package/docs/design/projects-hub-direction-v2.md +25 -1
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  19. package/modules/hall-ui/public/settings.html +3 -2
  20. package/modules/hall-ui/public/settings.js +10 -6
  21. package/modules/onboarding/routes/access-requests.js +16 -3
  22. package/modules/platform-identity/account-visibility.js +94 -3
  23. package/modules/platform-identity/platform-identity.js +50 -55
  24. package/modules/platform-identity/routes/connections.js +3 -3
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
  32. package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
  33. package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
  34. package/modules/ui-design/kit/lib.js +6 -2
  35. package/modules/ui-design/kit/probe.js +1 -1
  36. package/modules/ui-design/kit/render.js +1 -1
  37. package/modules/ui-design/kit/serve.js +63 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/public-landing/assets/cosmos.css +224 -18
  41. package/public-landing/projects-empty.states.json +49 -0
  42. package/public-landing/projects-floor.states.json +63 -0
  43. package/public-landing/projects.html +273 -93
  44. package/public-landing/projects.probes.json +32 -7
  45. package/public-landing/projects.states.json +10 -6
  46. package/scripts/gds/fitness.js +9 -0
  47. package/scripts/gds/onboard.js +41 -1
  48. package/scripts/gds/provision-net.js +7 -3
  49. package/src/bongos/middleware/rate-limit.js +68 -2
  50. package/src/bongos/routes/auth.js +19 -12
  51. package/src/module-api.js +13 -1
  52. package/tests/account_existence_rate_limit.mjs +375 -0
  53. package/tests/account_privacy_flags.mjs +17 -10
  54. package/tests/effective_visibility_predicate.mjs +891 -0
  55. package/tests/module_api.mjs +1 -0
  56. package/tests/onboard.mjs +56 -0
  57. package/tests/profile_route.mjs +22 -11
  58. package/tests/projects_hub.mjs +102 -5
  59. package/tests/projects_hub_pre_uat.mjs +45 -11
  60. package/tests/provision.mjs +20 -0
  61. package/tests/ui_design_kit.mjs +32 -2
@@ -166,6 +166,8 @@ export interface PatchMeSkillPrefsResponse { prefs: unknown; effective: unknown;
166
166
  export interface PatchMeSoundPrefsResponse { prefs: unknown; effective: unknown; effective_map: unknown; volume: unknown; defaults: unknown; known_sounds: unknown }
167
167
  export interface PatchProjectsIdFeaturedRequest { featured: boolean }
168
168
  export interface PatchProjectsIdFeaturedResponse { project: unknown }
169
+ export interface PatchProvisioningInstancesIdDetailRequest { description?: string; detail?: Record<string, unknown> }
170
+ export interface PatchProvisioningInstancesIdDetailResponse { ok: boolean; description: unknown; detail: unknown; options: unknown }
169
171
  export interface PatchProvisioningInstancesIdSettingsRequest { platform_visibility?: string; joinability?: string }
170
172
  export interface PatchProvisioningInstancesIdSettingsResponse { ok: boolean; saved: unknown; push: unknown }
171
173
  export interface PatchTaskRecommendationsIdRequest { status: "declined" | "withdrawn" }
@@ -317,7 +319,7 @@ export interface PostProvisioningInstancesIdGithubAppRequest { account?: string
317
319
  export interface PostProvisioningInstancesIdGithubAppResponse { state: unknown; account: unknown; post_url: unknown; personal_url: unknown; org_url: unknown; org_login: unknown; manifest: unknown; redirect_url: unknown; expires_at: unknown }
318
320
  export interface PostProvisioningInstancesIdRestartResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; message: unknown }
319
321
  export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
320
- export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; no_address?: boolean; tier?: string; onboard_mode?: string; type?: string }
322
+ export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; no_address?: boolean; tier?: string; onboard_mode?: string; type?: string; description?: string; detail?: Record<string, unknown> }
321
323
  export interface PostSearchMarkRequest { chunk_id?: StringifiableId; useful?: unknown }
322
324
  export interface PostSearchRequest { expand?: unknown; q?: string; source_kind?: string; limit?: unknown }
323
325
  export interface PostSearchResponse { expand?: unknown; chunk?: unknown; query?: unknown; count?: unknown; total_full_tokens?: unknown; results?: unknown; corpus?: unknown }
@@ -946,6 +948,8 @@ export interface BongosClient {
946
948
  postProvisioningInstances(args: RequestArgs & { body: PostProvisioningInstancesRequest }): Promise<ApiResponse>;
947
949
  /** GET /provisioning/instances/{id} — rank: any-builder */
948
950
  getProvisioningInstancesId(args?: RequestArgs): Promise<GetProvisioningInstancesIdResponse>;
951
+ /** PATCH /provisioning/instances/{id}/detail — rank: any-builder */
952
+ patchProvisioningInstancesIdDetail(args?: RequestArgs & { body?: PatchProvisioningInstancesIdDetailRequest }): Promise<PatchProvisioningInstancesIdDetailResponse>;
949
953
  /** POST /provisioning/instances/{id}/domain — rank: any-builder */
950
954
  postProvisioningInstancesIdDomain(args: RequestArgs & { body: PostProvisioningInstancesIdDomainRequest }): Promise<PostProvisioningInstancesIdDomainResponse>;
951
955
  /** POST /provisioning/instances/{id}/force-teardown — rank: metic+archon */
@@ -637,6 +637,8 @@ export function createClient(opts = {}) {
637
637
  postProvisioningInstances: (args) => request("POST", "/provisioning/instances", { hasBody: true }, args),
638
638
  // GET /provisioning/instances/{id} — rank: any-builder — GET /provisioning/instances/:id
639
639
  getProvisioningInstancesId: (args) => request("GET", "/provisioning/instances/{id}", { hasBody: false }, args),
640
+ // PATCH /provisioning/instances/{id}/detail — rank: any-builder — PATCH /provisioning/instances/:id/detail
641
+ patchProvisioningInstancesIdDetail: (args) => request("PATCH", "/provisioning/instances/{id}/detail", { hasBody: true }, args),
640
642
  // POST /provisioning/instances/{id}/domain — rank: any-builder — POST /provisioning/instances/:id/domain
641
643
  postProvisioningInstancesIdDomain: (args) => request("POST", "/provisioning/instances/{id}/domain", { hasBody: true }, args),
642
644
  // POST /provisioning/instances/{id}/force-teardown — rank: metic+archon — POST /provisioning/instances/:id/force-teardown
@@ -0,0 +1,79 @@
1
+ # 0209 — Two routes answering one question spend one budget; whether the answer itself collapses is the owner's call
2
+
3
+ - **Status:** Accepted (the ceiling). The response-collapse half is **Deferred — owner decision**, filed as a blocker against the task.
4
+ - **Date:** 2026-08-29
5
+ - **Deciders:** Claude, building the throttle half of the account-existence task. The task body named the collapse question as an owner/Archon decision and it is left as one.
6
+ - **Task:** task 1003339 (goal 1000045 — public builder profiles + account security, criterion `i-control-my-own-access-and-exposure`)
7
+ - **Interacts with:** [ADR 0016](<redacted>.md) (the trust boundary — these two routes are deliberately outside it), [ADR 0083](<redacted>.md) (the published doorway a module reaches core through), [ADR 0091](<redacted>.md) §1 (what belongs in the bounded kernel), [ADR 0116](<redacted>.md) (the 429's envelope), [ADR 0194](<redacted>.md) (the membership door these routes report on), [ADR 0206](<redacted>.md) (the sibling account-security decision in this goal)
8
+
9
+ ## Context
10
+
11
+ Two PUBLIC routes answer the same question — *does this GitHub login have an account here?*
12
+
13
+ - `GET /access-requests/status?github_login=X` (`modules/onboarding/routes/access-requests.js`) returns `member | invited | pending | dismissed | none` plus `admitted`.
14
+ - `GET /auth/web/admission-status?login=X` (`src/bongos/routes/auth.js`) returns a bare `{ admitted }`. Strictly weaker; strictly derivable from the first.
15
+
16
+ Neither was throttled, and the reason is structural rather than an oversight anyone could see locally: `rateLimitMiddleware` returns early for every non-write method, so the global limiter meters writes and nothing else. The one read limiter that exists (`publicReadRateLimitMiddleware`) is mounted only under `/public/*`. Neither of these routes is under that prefix, so both were reachable at line rate by anyone.
17
+
18
+ Scripted against a list of GitHub usernames, either route becomes a login→membership roster: who builds here, and confirmation that a specific named person is present. That is targeting information, and it is exactly what you would want before a phishing attempt aimed at a named builder. The `auth.js` comment had already stated the exposure plainly ("It is also NOT throttled") and deferred the remedy, correctly, on the grounds that hardening one route while its richer twin stayed open would accomplish nothing.
19
+
20
+ Two separable questions sit inside that, and conflating them is what kept the task open:
21
+
22
+ 1. **Is unauthenticated probing throttled?** A ceiling is right under every possible answer to (2) — an enumerator is unwelcome whether or not the reply is detailed.
23
+ 2. **Should the reply itself collapse**, so a caller who is not the subject cannot tell `pending` / `dismissed` / `none` apart from a bare "not admitted"? That changes what a real applicant sees during onboarding.
24
+
25
+ ## Decision
26
+
27
+ **Ship (1) now, as ONE budget. Do not decide (2) here — file it.**
28
+
29
+ ### The ceiling is one budget, not two identical ones
30
+
31
+ `accountExistenceReadRateLimit` joins `src/bongos/middleware/rate-limit.js` as limiter #4: 120 reads / 60s per IP over a single bucket map, keyed on `req.ip` (the same real-client-IP resolution the write limiter uses). It is exported as **one middleware instance, not a factory**, and the same instance is mounted on both routes.
32
+
33
+ That is the load-bearing part of this ADR. Two limiters configured `120/60s` each are indistinguishable from one shared limiter in a diff, in a code review, and in any test that only counts to the limit on each route separately — and they hand an enumerator 240/min, because alternating between the twins costs nothing. The routes are one oracle read two ways, so they get one budget. `tests/account_existence_rate_limit.mjs` proves the sharing behaviourally in both directions *and* by object identity, and the mutation that replaces the shared instance with per-route copies leaves the counting tests green while the sharing tests go red — which is precisely the regression worth a test.
34
+
35
+ 120/min is the `/public/*` posture, and it clears the honest callers with wide margin: `renderAccessPendingPage` polls the boolean probe every 3s (20/min) and `bongos login` polls the rich one every 5s (12/min).
36
+
37
+ ### The module reaches it through the doorway, and rate-limit.js becomes kernel
38
+
39
+ The onboarding module may require exactly one core file — the published doorway (ADR 0083). So `src/module-api.js` exports the shared instance, and `src/bongos/middleware/rate-limit.js` joins `KERNEL_FILES` in `fitness.js`. The argument is `logger.js`'s, verbatim: the abuse ceiling is a trust primitive in the same sense the authz gates are — **authz decides who may ask, this decides how often**, and an unauthenticated read route has nothing else holding it. The file requires nothing at all, so joining the roster costs no boundary and buys the import-direction guarantee on a file two surfaces now trust.
40
+
41
+ The alternative — the module vendoring its own copy, as `modules/platform-identity/routes/public-profile.js` had to before there was a doorway export — is the failure this export exists to prevent. It is also how you get two budgets by accident.
42
+
43
+ ### The response collapse is deferred, with the evidence gathered
44
+
45
+ Not deferred vaguely. The consumer sweep was run first, and it is short enough to state in full:
46
+
47
+ - **Route 1 has exactly one consumer in the repo**: `scripts/gds/login.js`, the `bongos login` CLI. Its `pollDecision()` maps `member`/`invited` → admitted, `dismissed` → a hard stop, and **everything else → wait**. So `pending` and `none` are *already* indistinguishable to the only thing that reads them.
48
+ - **Route 2's only consumer reads `admitted` and nothing else** — the inline poller in `renderAccessPendingPage`. The pending-vs-stranger distinction that page needs comes from `webAdmissionStatus`'s `expectedSoon`, computed server-side from a direct query, never from route 1.
49
+ - **The generated typed clients define wrappers for both routes with zero call sites.**
50
+ - Therefore collapsing costs **exactly one thing**: `login.js` is the only place in the entire codebase that tells a declined applicant they were declined. Collapse the answer and that branch is unreachable — a dismissed applicant's CLI prints dots for thirty minutes and then emits an actively wrong instruction ("re-run `bongos login` once an Archon has approved you") for an approval that is never coming.
51
+
52
+ That is a real, if narrow, product regression, and it lands on a person at their worst moment with this project. It is a judgement about what an applicant is owed, not an engineering detail, so it goes to the owner rather than being settled by whoever happened to hold the task. The blocker carries this evidence and a recommendation.
53
+
54
+ One thing the collapse would **not** buy, and the blocker says so: it does not close the oracle. `admitted: true` still means "member or invited" on both routes. Collapsing `pending`/`dismissed`/`none` narrows what leaks about *applicants*; only removing the routes or putting them behind an authenticated identity would close membership enumeration itself, and neither is possible while a stranger with no account has to poll them.
55
+
56
+ ### Rejected
57
+
58
+ - **A separate limiter per route, both 120/60s.** Looks identical in review, hands an enumerator double the rate, and is the exact regression the sharing tests exist to catch.
59
+ - **A `createReadRateLimit({ scope, limit, windowMs })` factory on the doorway.** More general and more wrong: two call sites with matching config get two buckets. The generality is what loses the property.
60
+ - **Vendoring the `public-profile.js` sliding window into the onboarding module.** A fourth copy, and copies drift. That file's own comment records that it vendored only because the core limiter was not on the doorway — this ADR removes that reason.
61
+ - **Hardening only `admission-status`** (the smaller diff). Accomplishes nothing while its richer twin stays open; the `auth.js` comment said so before this task existed.
62
+ - **Refactoring `publicReadRateLimitMiddleware` onto the new shared gate.** A live limiter that is not broken, on a surface this task does not own.
63
+ - **Collapsing the response on my own judgement because "nothing much" reads it.** "Nothing much" is not "nothing" — `dismissed` has one real consumer with real user-visible copy, and the task named this an owner decision.
64
+ - **Deciding it the other way — annotating both routes "acceptable for this community model" and closing.** Equally the owner's call, and equally not mine.
65
+
66
+ ## Consequences
67
+
68
+ - Both account-existence probes now refuse past 120/min from one address, with the ADR 0116 envelope, `Retry-After`, and `X-RateLimit-Scope: account-existence` — the scope name is deliberately the *question*, not the route, so a caller can see the two share a budget.
69
+ - The limiter runs ahead of the lookups, so a refused probe issues no query. The oracle is not also a free amplifier.
70
+ - The doorway gains one export (a MINOR addition; `tests/module_api.mjs` records it). `src/bongos/middleware/rate-limit.js` is now held to the kernel import rule — it currently imports nothing, and must keep importing nothing but the kernel.
71
+ - Behind a shared NAT, ~6 simultaneous sign-ins polling at 3s would reach the ceiling. The refusal is a 429 with `Retry-After` and both pollers already retry, so this degrades to a slower poll rather than a failed sign-in — but it is the number to raise first if a real instance reports slow admissions.
72
+ - A leaked or exhausted budget is per-IP and in-memory: a process restart forgets it, and one abusive address cannot close the door on anybody else. Both are asserted.
73
+
74
+ ## When to revisit
75
+
76
+ - **The owner answers the collapse question** (the blocker). If the answer is "collapse it", `login.js`'s declined branch needs a replacement — the honest option is a timeout message that stops promising an approval is coming.
77
+ - **A real instance reports admission polling hitting the ceiling** behind a shared address. Raise the limit; do not split the budget.
78
+ - **A third surface starts answering the same question.** It mounts this same instance. A new bucket for it would re-open exactly the hole this closed.
79
+ - **The routes stop needing to be public.** If a would-be builder ever arrives already holding an identity, the whole oracle can go behind `requireBuilder` and the ceiling becomes belt-and-braces.
@@ -294,6 +294,7 @@ This keeps the decision history honest and traceable.
294
294
  | 0204 | [**The apex is dark only; light is the builders' hall's** ([task 1003336](https://cloudbongos.com/builders#/task/1003336), goal 1000080). Supersedes in part [ADR 0196](<redacted>.md) (the light-mode half of the landing build) and the landing direction's 2026-08-27 pick of "light rebuilt to the dark standard". The owner, reviewing the planets: "only black or only white is a bit bland… leaning towards only dark mode for this page and keep the dark/light mode to be solely for the builder hall", confirmed as the WHOLE apex. Decision: `/`, `/projects`, `/privacy`, `/terms` and `/contact` pin `data-mode="dark"` on `<html>` — no pre-paint script, no `cb-color-mode`, no `?mode=`, no OS-preference read; the moon/sun glyph and its wiring leave both bars; the hall's own `hall-theme` switch is the platform's only light/dark control. The light tier of `world.css` / `cosmos.css` and the dark-twins rule STAY until the tier's own removal (the light-pair contrast tests keep measuring). The kit learns `"modes": ["dark"]` in a states file (validated; `render.js` takes it as the default). Rejected: a dark default with the toggle kept; deleting the light tier in the same diff; `--modes dark` on every kit call instead of the page's contract.](<redacted>.md) | apex / design system |
295
295
  | 0205 | [**A federated check-in may not speak for a person the hub never saw sign in: the membership carries the hub's own witness, and identity is never re-synced from client credentials** ([task 1003340](https://cloudbongos.com/builders#/task/1003340), goal 1000045 — closes security report 1000027, high, LIVE on the hub). `POST /sso/membership/check-in` authenticated the CALLER (`client_id` + `client_secret`) and took the SUBJECT (`github_id`, `github_login`, `avatar_url`) from the request body — client credentials say which PROJECT is calling, never who the person is. So any registered SSO client could (1) rewrite a real person's public `github_login` and `avatar_url`, which `upsertAccount`'s `ON CONFLICT` re-synced unconditionally and stored with no scheme check, and (2) mint the `<redacted>` row that [ADR 0171](<redacted>.md) D7's `membership_required` precheck reads — so the anti-impersonation gate on `POST /sso/activity/rollup` was satisfiable by the very call it was meant to gate, and forged credits/karma/rank reached a public profile. Decisions: (1) migration `<redacted>` adds `signin_verified_at`, written ONLY by `recordFederatedSignin` from the two moments the HUB itself authenticated a human for a client — `/sso/token` (a code minted under a live hub session, bound to the pair) and `/sso/device/poll` (a GitHub approval, audience bound from the typed origin) — so no request-body field reaches the statement and the column is evidence a caller structurally cannot write; (2) `upsertMembership` becomes `touchMembership`, an UPDATE gated on that column — a check-in can no longer CREATE a membership, answering `409 signin_required` and writing nothing, while signing in accepts a standing `pending` invite and leaves any other coarse kind to the instance to report; (3) a federated client gets insert-only `ensureAccount` (`DO NOTHING`), and `upsertAccount`'s identity re-sync belongs to hub-authenticated callers only — nothing is lost, because both of those already re-sync on every sign-in from GitHub's own answer; (4) the rollup gate reads `membershipBacksRollup` (non-pending AND witnessed), not row-existence; (5) `safeAvatarUrl` admits absolute https only, applied inside both account writers rather than at each render; (6) NO backfill — no predicate separates a legitimate check-in row from a forged one, and the first sign-in after deploy heals a real membership because the witness is stamped BEFORE the check-in and rollup that follow it, so a profile goes stale, never blank. No wire-format change: the request schemas are byte-identical and the ADR 0177 `membership_kind` window is untouched, so no federated instance upgrades. Proof `tests/sso_checkin_identity_spoof.mjs` (DB-free, in the default unit gate; 10 of 14 assertions fail against the pre-fix code, 2 controls prove the gate is not a wall). Rejected: carrying the hub assertion in the check-in body (strict validation would 400 every un-upgraded instance); ADR 0156-style source-guarding (answers "did the user set this", the wrong question when the writer is a third party); backfilling `signin_verified_at = joined_at`; a silent 200; retro-filtering `listMembershipsForAccount`.](<redacted>.md) | platform identity / security |
296
296
  | 0206 | [**A leaked bearer's remedy is the account holder's own revoke, not an absolute session ceiling** ([task 1003341](https://cloudbongos.com/builders#/task/1003341), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `lookupSession` rewrites `expires_at = now() + 24h` on every authenticated request, so a CLI bearer in daily use never expires; if it leaks, the holder keeps it alive simply by using it. `POST /auth/logout` kills only the token presented on that same request — useless when the token you want dead is the one you no longer hold — and the two endpoints that can kill another session are gated on the admin `auth.session.revoke` atom, so the victim had to find an Archon, and a single-owner self-hosted instance may have none. Decision: ship the OWN-scoped remedy (`GET /me/sessions`, `POST /me/sessions/revoke`, `POST /me/sessions/revoke-all` on `requireBuilder` alone, every statement keyed on the caller's own id from the presented session, the token value never selected or returned, `revoke-all` deliberately including the requesting session) and deliberately NOT an absolute ceiling on the sliding refresh: a ceiling is unconditional (migration 035 already had to backfill a 24h grace so it would not kill the session running it), any cap loose enough not to be a daily nuisance leaves a leaked token live for weeks, and the visibility this task ships — `created_at` beside `last_used_at` in the list — is the compounding half of the fix. A demotion already sweeps sessions, and a deactivated builder is denied on the hot path, so the authority-drift case is closed by other means. Rejected: a `created_at + N` cap now; a shorter TTL; three more routes inside the already-largest `routes/auth.js` (a separate file is what lets the no-caller-named-owner rule be asserted over a WHOLE file); a `keep_current` flag; 404 on a session id that is not yours (it would confirm which ids exist); lowering the `auth.session.revoke` floor. Revisit for machine principals, a compliance maximum, or evidence that people do not prune what the list shows them.](<redacted>.md) | auth / account security |
297
+ | 0209 | [**Two routes answering one question spend one budget; whether the answer itself collapses is the owner's call** ([task 1003339](https://cloudbongos.com/builders#/task/1003339), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `GET /access-requests/status` (public, returns `member|invited|pending|dismissed|none`) and `GET /auth/web/admission-status` (its bare-boolean twin) both answer "does this GitHub login have an account here?", and neither was throttled — `rateLimitMiddleware` returns early for every non-write method, and the one read limiter is mounted only under `/public/*`. Scripted at line rate either becomes a login→membership roster: who builds here, and confirmation that a named person is present. Decision: ship the ceiling as ONE budget — `accountExistenceReadRateLimit`, 120 reads/60s per IP over a single bucket, exported as one middleware INSTANCE rather than a factory and mounted on both routes, because two limiters each configured 120/60s look identical in a diff, pass any test that counts to the limit per route, and hand an enumerator 240/min by alternating. The onboarding module reaches it through the doorway, so `src/bongos/middleware/rate-limit.js` joins `KERNEL_FILES` on `logger.js`'s argument: authz decides WHO may ask, the ceiling decides HOW OFTEN, and the file imports nothing. Deliberately NOT decided here: collapsing the response so a non-subject caller cannot tell `pending`/`dismissed`/`none` from "not admitted" — the consumer sweep found route 1 has exactly ONE consumer (`scripts/gds/login.js`), which already treats `pending` and `none` identically, so collapsing costs exactly one thing: the only message in the codebase that tells a declined applicant they were declined. That is a judgement about what an applicant is owed, so it goes to the owner as a blocker with the evidence. Noted either way: collapsing does not close the oracle — `admitted:true` still means member-or-invited. Rejected: a separate limiter per route; a `createReadRateLimit` factory (two matching call sites get two buckets — the generality loses the property); vendoring a fourth copy of the sliding window; hardening only the smaller route; collapsing on my own judgement because "nothing much" reads it.](<redacted>.md) | security / rate limiting |
297
298
  | 0207 | [**Building the connection graph: three departures from ADR 0202, and the three limits it is honest about** ([task 1002295](https://cloudbongos.com/builders#/task/1002295), goal 1000045 criterion C6 — the build of the connections wave). ADR 0202 decided the graph; building it surfaced three places where the decision's MECHANISM could not be written literally without losing the PROPERTY it was about, and three places where the shipped code cannot promise as much as a casual reading suggests. Departures: (1) **level-wise CTEs, not `WITH RECURSIVE`** — a recursive term can carry neither a per-level fan-out cap nor an ordered per-level truncation, which are the two things D3 actually decided, so a recursive build would have had a depth bound and no fan-out bound; a test asserts `RECURSIVE` appears in neither query. (2) **1st degree is an unbounded pair-index probe**, never a scan of the capped frontier — reading it off the `FANOUT_1`-truncated 1-hop reports "2nd" for someone in the viewer's own connections list once they pass 500 connections, and the rule this states is that **a cap may only ever make an answer less specific (`null`), never wrong**. (3) The shared visibility predicate lands in its own file, `modules/platform-identity/account-visibility.js` (`isAccountVisible` / `accountVisibleSql` / `accountActiveSql`), because task 1002967 had not shipped and `platform-identity.js` sat at the size ratchet with zero headroom — **1002967 adopts it rather than writing a second predicate**. Also decided: node visibility during traversal is viewer-relative and asymmetric on purpose (your OWN 1-hop floors on consent alone so a friend who goes private does not vanish from your list; every deeper hop clears the public predicate); cancel is a `DELETE` an ADDRESSEE may not use, so the 90-day cooldown cannot be dodged by calling a decline a delete; and the create response is `201 { status: 'pending' }` and nothing else, so a swallowed re-ask has nothing to fabricate. Limits stated rather than implied: a swallowed re-ask is silent in the RESPONSE and observable by its ABSENCE from the requester's outbox (the claim is narrowed, not the code — a decoy row would be a lie in the database to protect a lie in the API); the TARGET's 1-hop is capped too and that truncation is deliberately NOT reported, because saying so leaks one bit about a third party's degree; and the query COUNT is bounded while the frontier's WORK is O(sum of degree over the 1-hop), since `ORDER BY … LIMIT` materialises before it truncates — the revisit trigger is a measured p95, and the cheap next move is a `statement_timeout` returning the `null` D3 already chose. Deferred and named: a per-builder cap on OUTBOUND requests (the cooldown stops repeat asks to one person, not one builder asking a hundred — a real number is owner-signed), and a real-Postgres pass on the expire-then-insert write path, which CI cannot run.](<redacted>.md) | platform identity / social graph / privacy |
298
299
 
299
300
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -372,7 +372,7 @@
372
372
  "access-requests"
373
373
  ],
374
374
  "summary": "GET /access-requests/status",
375
- "description": "GET /access-requests/status?github_login=X — PUBLIC. The `bongos login` CLI polls this after filing a request: the device flow's not_approved is TERMINAL (the device_code is spent), so the CLI can't re-poll the flow — it polls this instead, then restarts sign-in once admitted. Exposes nothing the public POST above doesn't already reveal through its 409s (already_member / already_approved). status ∈ member | invited | pending | dismissed | none; admitted = member||invited. rank: public — a would-be builder is by definition outside the system. Declared BEFORE `GET /access-requests` so the literal `/status` path is matched as its own route (not swallowed as a query on the list route).\n\n**Rank:** `public` — No authentication — any caller.",
375
+ "description": "GET /access-requests/status?github_login=X — PUBLIC. The `bongos login` CLI polls this after filing a request: the device flow's not_approved is TERMINAL (the device_code is spent), so the CLI can't re-poll the flow — it polls this instead, then restarts sign-in once admitted. Exposes nothing the public POST above doesn't already reveal through its 409s (already_member / already_approved). status ∈ member | invited | pending | dismissed | none; admitted = member||invited. rank: public — a would-be builder is by definition outside the system. Declared BEFORE `GET /access-requests` so the literal `/status` path is matched as its own route (not swallowed as a query on the list route). RATE-LIMITED (task 1003339 / ADR 0209). Public + unthrottled, this was a login→membership oracle an attacker could script at line rate to learn who builds here. accountExistenceReadRateLimit is the doorway's SHARED 120/60s per-IP budget: core's GET /auth/web/admission-status (the boolean twin of this answer) mounts the very same instance, so the two cannot be alternated for double the rate. The limiter runs before the queries, so a refused probe costs no DB work. Whether the ANSWER should also collapse — so a caller who is not the subject cannot tell pending/dismissed/none apart — is the owner's call and is filed as a blocker, not decided here.\n\n**Rank:** `public` — No authentication — any caller.",
376
376
  "x-rank": "public",
377
377
  "x-source": "modules/onboarding/routes/access-requests.js",
378
378
  "responses": {
@@ -1159,7 +1159,7 @@
1159
1159
  "auth"
1160
1160
  ],
1161
1161
  "summary": "GET /auth/web/admission-status",
1162
- "description": "GET /auth/web/admission-status?login=X — the probe renderAccessPendingPage polls (task 1002097). Answers ONE question: would this login be admitted right now? It mirrors the real gate via auth.webAdmissionStatus so the page can never invite someone in the gate would refuse. rank: public — it must be reachable by a visitor with no session (that is the entire situation it exists for). It grants nothing: admission still runs through the normal sign-in, and this only reads state. WHAT IT LEAKS, stated plainly (task 1002430 — an earlier version of this comment claimed the opposite and was wrong). Returning only a boolean does NOT make it non-enumerable: with open enrollment off, and for any login that is not the configured firstAdmin, `admitted:true` means that login is a member or has been invited. That reveals account existence to an unauthenticated caller. It is also NOT throttled — rateLimitMiddleware returns early for non-write methods, so GETs bypass it entirely; the page's 3s poll bounds the honest client, not an attacker. Why it is nonetheless not a new exposure: the onboarding module's GET /access-requests/status?github_login=X is ALREADY public and returns the strictly richer member|invited|pending|dismissed|none plus its own `admitted`. Anything derivable here is already derivable there, so this adds no net-new surface — and hardening only this route would accomplish nothing while that one stays open. Whether unauthenticated login-existence probing is acceptable at all is a real question, but it belongs to both routes together, not to a comment here. That endpoint is also why this one exists: it models neither the firstAdmin bypass nor open enrollment, so it reports the OWNER as 'none' forever — the exact case this page has to resolve.\n\n**Rank:** `public` — No authentication — any caller.",
1162
+ "description": "GET /auth/web/admission-status?login=X — the probe renderAccessPendingPage polls (task 1002097). Answers ONE question: would this login be admitted right now? It mirrors the real gate via auth.webAdmissionStatus so the page can never invite someone in the gate would refuse. rank: public — it must be reachable by a visitor with no session (that is the entire situation it exists for). It grants nothing: admission still runs through the normal sign-in, and this only reads state. WHAT IT LEAKS, stated plainly (task 1002430 — an earlier version of this comment claimed the opposite and was wrong). Returning only a boolean does NOT make it non-enumerable: with open enrollment off, and for any login that is not the configured firstAdmin, `admitted:true` means that login is a member or has been invited. That reveals account existence to an unauthenticated caller. IT IS NOW THROTTLED (task 1003339 / ADR 0209). It used to say \"NOT throttled\" here, and that was true: rateLimitMiddleware returns early for non-write methods, so GETs bypassed it entirely and the page's 3s poll bounded the honest client, not an attacker. accountExistenceReadRateLimit below is the ceiling — 120 reads/60s per IP, and the SAME instance is mounted on the onboarding module's GET /access-requests/status, which is this route's strictly richer twin (member|invited|pending|dismissed|none plus its own `admitted`). One budget across both, because they are one oracle: hardening either alone would accomplish nothing, and giving each its own budget would just let an enumerator alternate for double the rate. WHAT IS STILL OPEN and is deliberately NOT decided here: whether the answer itself should collapse, so a caller who is not the subject cannot tell pending/dismissed/none apart from a bare \"not admitted\". That changes what a real invited user sees during onboarding, so it is the owner's call — filed as a blocker against task 1003339, not settled by a comment in this file. That endpoint is also why this one exists: it models neither the firstAdmin bypass nor open enrollment, so it reports the OWNER as 'none' forever — the exact case this page has to resolve.\n\n**Rank:** `public` — No authentication — any caller.",
1163
1163
  "x-rank": "public",
1164
1164
  "x-source": "src/bongos/routes/auth.js",
1165
1165
  "responses": {
@@ -10989,6 +10989,69 @@
10989
10989
  ]
10990
10990
  }
10991
10991
  },
10992
+ "/provisioning/instances/{id}/detail": {
10993
+ "patch": {
10994
+ "operationId": "patch_provisioning_instances_id_detail",
10995
+ "tags": [
10996
+ "provisioning"
10997
+ ],
10998
+ "summary": "PATCH /provisioning/instances/:id/detail",
10999
+ "description": "── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answer) the \"tell us about your project\" step after creation. Body: any subset of { description, detail }. rank: any authenticated builder (own resource; archon may change any — same gate as settings). THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is a supported default only because there is a way back. Without this route \"optional\" would mean \"permanently unpublishable\", i.e. a dead end, which is precisely what criterion `proj-detail-interview` refuses. Deliberately NOT a settings-apply: nothing here is a policy the running instance obeys, so there is no web.env to patch and no restart to queue. The answers live on the platform row until a catalog row materializes (the provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that reads `description` to compute publishability and name what is still missing.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
11000
+ "x-rank": "any-builder",
11001
+ "x-source": "modules/provisioning/routes/provisioning.js",
11002
+ "parameters": [
11003
+ {
11004
+ "name": "id",
11005
+ "in": "path",
11006
+ "required": true,
11007
+ "schema": {
11008
+ "type": "string"
11009
+ },
11010
+ "description": "Path parameter `id`."
11011
+ }
11012
+ ],
11013
+ "requestBody": {
11014
+ "required": false,
11015
+ "content": {
11016
+ "application/json": {
11017
+ "schema": {
11018
+ "$ref": "#/components/schemas/PatchProvisioningInstancesIdDetailRequest"
11019
+ }
11020
+ }
11021
+ },
11022
+ "x-validated": true
11023
+ },
11024
+ "responses": {
11025
+ "200": {
11026
+ "description": "Success.",
11027
+ "content": {
11028
+ "application/json": {
11029
+ "schema": {
11030
+ "$ref": "#/components/schemas/PatchProvisioningInstancesIdDetailResponse"
11031
+ }
11032
+ }
11033
+ }
11034
+ },
11035
+ "400": {
11036
+ "$ref": "#/components/responses/ValidationFailed"
11037
+ },
11038
+ "401": {
11039
+ "$ref": "#/components/responses/Unauthorized"
11040
+ },
11041
+ "403": {
11042
+ "$ref": "#/components/responses/Forbidden"
11043
+ },
11044
+ "404": {
11045
+ "$ref": "#/components/responses/NotFound"
11046
+ }
11047
+ },
11048
+ "security": [
11049
+ {
11050
+ "builderSession": []
11051
+ }
11052
+ ]
11053
+ }
11054
+ },
10992
11055
  "/provisioning/instances/{id}/domain": {
10993
11056
  "post": {
10994
11057
  "operationId": "post_provisioning_instances_id_domain",
@@ -17470,6 +17533,35 @@
17470
17533
  "project"
17471
17534
  ]
17472
17535
  },
17536
+ "PatchProvisioningInstancesIdDetailRequest": {
17537
+ "type": "object",
17538
+ "properties": {
17539
+ "description": {
17540
+ "type": "string"
17541
+ },
17542
+ "detail": {
17543
+ "type": "object"
17544
+ }
17545
+ },
17546
+ "additionalProperties": false
17547
+ },
17548
+ "PatchProvisioningInstancesIdDetailResponse": {
17549
+ "type": "object",
17550
+ "properties": {
17551
+ "ok": {
17552
+ "type": "boolean"
17553
+ },
17554
+ "description": {},
17555
+ "detail": {},
17556
+ "options": {}
17557
+ },
17558
+ "required": [
17559
+ "ok",
17560
+ "description",
17561
+ "detail",
17562
+ "options"
17563
+ ]
17564
+ },
17473
17565
  "PatchProvisioningInstancesIdSettingsRequest": {
17474
17566
  "type": "object",
17475
17567
  "properties": {
@@ -20019,6 +20111,12 @@
20019
20111
  },
20020
20112
  "type": {
20021
20113
  "type": "string"
20114
+ },
20115
+ "description": {
20116
+ "type": "string"
20117
+ },
20118
+ "detail": {
20119
+ "type": "object"
20022
20120
  }
20023
20121
  },
20024
20122
  "required": [
@@ -21262,9 +21360,9 @@
21262
21360
  "description": "A required dependency/feature is not configured or is temporarily down."
21263
21361
  }
21264
21362
  },
21265
- "x-endpoint-count": 337,
21266
- "x-schema-count": 372,
21363
+ "x-endpoint-count": 338,
21364
+ "x-schema-count": 374,
21267
21365
  "x-undocumented-bodies": 9,
21268
- "x-response-schemas": 253,
21366
+ "x-response-schemas": 254,
21269
21367
  "x-generated-by": "scripts/gds/gen-api-docs.js"
21270
21368
  }
@@ -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. 337 endpoints across 54 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 338 endpoints across 54 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`.
@@ -437,7 +437,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
437
437
  | GET | `/api/bongos/projects/featured` | `public` | — | a curated public showcase; no auth by design. |
438
438
  | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
439
439
 
440
- ## `provisioning` (18)
440
+ ## `provisioning` (19)
441
441
 
442
442
  | Method | Path | Rank | Body | Description |
443
443
  |---|---|---|---|---|
@@ -447,8 +447,9 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
447
447
  | GET | `/api/bongos/provisioning/github-app/status` | `public` | — | GET /provisioning/github-app/status?state=<> — the PUBLIC progress read the onboarding callback page polls (task 2096) to know when setup… |
448
448
  | GET | `/api/bongos/provisioning/health` | `public` | — | GET /provisioning/health — liveness + the core version this module mounted against. |
449
449
  | GET | `/api/bongos/provisioning/instances` | `any-builder` | — | GET /provisioning/instances — the caller's own instances + each open intent. |
450
- | POST | `/api/bongos/provisioning/instances` | `any-builder` | `slug`, `target_ref`, `hosting_shape`, `domain`, `no_address`, `tier`, `onboard_mode`, `type` | POST /provisioning/instances — request a new instance. |
450
+ | POST | `/api/bongos/provisioning/instances` | `any-builder` | `slug`, `target_ref`, `hosting_shape`, `domain`, `no_address`, `tier`, `onboard_mode`, `type`, `description`, `detail` | POST /provisioning/instances — request a new instance. |
451
451
  | GET | `/api/bongos/provisioning/instances/:id` | `any-builder` | — | GET /provisioning/instances/:id — read one instance (the manage page's read). |
452
+ | PATCH | `/api/bongos/provisioning/instances/:id/detail` | `any-builder` | `description`, `detail` | ── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answe… |
452
453
  | POST | `/api/bongos/provisioning/instances/:id/domain` | `any-builder` | `domain` | POST /provisioning/instances/:id/domain — give the caller's OWN instance its address (task 1002692, gap 5: no path existed to add a domai… |
453
454
  | POST | `/api/bongos/provisioning/instances/:id/force-teardown` | `metic+archon` | — | POST /provisioning/instances/:id/force-teardown — Archon tears down ANY instance. |
454
455
  | 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… |