@bongos/core 1.19.668 → 1.19.670

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.668",
6
- "core_contract": "1.19.668",
7
- "source_commit": "7ef8e22537f40e46f6f068d2479fbbb853f91ac6",
5
+ "core_version": "1.19.670",
6
+ "core_contract": "1.19.670",
7
+ "source_commit": "8bd867dda772e1b204c7932e1f386eff9a18955f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-11T15:53:04.954Z",
9
+ "built_at": "2026-09-11T16:30:19.479Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 473,
12
+ "docs_redacted": 474,
13
13
  "agent_docs_stubbed": 24,
14
14
  "functional_verbatim": 2117,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2614,
20
- "tree_sha256": "f753736a9456c435a166c5d07d41ab343a26cf1d0ccbd1ddf50fe23325cb3bd3",
19
+ "file_count": 2615,
20
+ "tree_sha256": "2b95ae714107f111f69a22dc7453ae4cf4b963a33d811f179fc01dc54b3d80ba",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -197,7 +197,7 @@
197
197
  {
198
198
  "path": ".claude/skills/new-project/SKILL.md",
199
199
  "mode": "0000644",
200
- "sha256": "5187e211cb0ff19f41ade425016b6f6909aa35f220fc77dba87e56784bf16d21"
200
+ "sha256": "d530f15de6e7c4a07c595d624d403c5616d66380e99e564ef3c63b3518141441"
201
201
  },
202
202
  {
203
203
  "path": ".claude/skills/otb-character-review/SKILL.md",
@@ -1899,10 +1899,15 @@
1899
1899
  "mode": "0000644",
1900
1900
  "sha256": "e5e99f19152b5a98c21f70fc0e54a09e9ae59beeb81fea8d26df2de4ef196b90"
1901
1901
  },
1902
+ {
1903
+ "path": "docs/adr/0278-a-gated-project-still-takes-applications.md",
1904
+ "mode": "0000644",
1905
+ "sha256": "3d941c0e1f1ce5618fa6861a720a4ac1c1988495c5f17aed77a348a4a3b7b718"
1906
+ },
1902
1907
  {
1903
1908
  "path": "docs/adr/README.md",
1904
1909
  "mode": "0000644",
1905
- "sha256": "5ee80f0739cfbe9543f746ed13efbfeeaa56c350fb4e9570c7c122723b86a34a"
1910
+ "sha256": "d9dcea00e72214a902f4c4b49f996645495df14334eea2b4baf53af5e55dbcc2"
1906
1911
  },
1907
1912
  {
1908
1913
  "path": "docs/api-reference.md",
@@ -2787,7 +2792,7 @@
2787
2792
  {
2788
2793
  "path": "docs/module-api-changelog.md",
2789
2794
  "mode": "0000644",
2790
- "sha256": "6eba5a3ec525e945d04f4665e23d6b3731df16547f7b51593c589ff2569c682c"
2795
+ "sha256": "3ae8c777f5212119f103d906a17893b73817b206b538a212f53dc01001443357"
2791
2796
  },
2792
2797
  {
2793
2798
  "path": "docs/modules-contract.md",
@@ -6457,7 +6462,7 @@
6457
6462
  {
6458
6463
  "path": "modules/provisioning/onboard-plan.js",
6459
6464
  "mode": "0000644",
6460
- "sha256": "31f14cbf3d649c25f1f17c508ae19be2f39d9e1991fde17cdce08cb15837ffa1"
6465
+ "sha256": "3c36bb1b163a35492ccd3d231e50039bded945fbccac076166c70a67767a687d"
6461
6466
  },
6462
6467
  {
6463
6468
  "path": "modules/provisioning/paid-shape-gate.js",
@@ -7757,12 +7762,12 @@
7757
7762
  {
7758
7763
  "path": "package-lock.json",
7759
7764
  "mode": "0000644",
7760
- "sha256": "2b109c02111e9aff72bc2a9b6a1a85805c7544cca1e68399ceaf5f8de8df0f47"
7765
+ "sha256": "56e1f3d38ea08d502a77017a9ba9722349740d7c3577a7ffb1b9135d87612d62"
7761
7766
  },
7762
7767
  {
7763
7768
  "path": "package.json",
7764
7769
  "mode": "0000644",
7765
- "sha256": "41264c98f8188d3443d6195d223de91f9eeed86ce21ed880e0b0a138e72669ff"
7770
+ "sha256": "9ba86a842073a1c0721d3bcbea43ff5a159a243c73b55031ae65ba524f345cbf"
7766
7771
  },
7767
7772
  {
7768
7773
  "path": "public-docs/index.html",
@@ -9357,7 +9362,7 @@
9357
9362
  {
9358
9363
  "path": "src/bongos/platform-visibility-gate.js",
9359
9364
  "mode": "0000644",
9360
- "sha256": "b09f1c0bcbb066ae80cbeebad730db8a8665dd077c3f2026c3b3d78298fdd866"
9365
+ "sha256": "f422cf189a4d29fd2cc9a0ff4a6c71f4032f7cb56eab254f9b304a973afbb88d"
9361
9366
  },
9362
9367
  {
9363
9368
  "path": "src/bongos/pool.js",
@@ -9522,7 +9527,7 @@
9522
9527
  {
9523
9528
  "path": "src/module-api.js",
9524
9529
  "mode": "0000644",
9525
- "sha256": "43b74af9de322914ac0f43a8cd4adacc1de90fb874692744450ff18beeaa67e7"
9530
+ "sha256": "06583dcc270fa2f445c15716a48602247303e40769e20cd4332297c2bb6bd9d5"
9526
9531
  },
9527
9532
  {
9528
9533
  "path": "src/module-loader/catalog.js",
@@ -11752,7 +11757,7 @@
11752
11757
  {
11753
11758
  "path": "tests/onboard.mjs",
11754
11759
  "mode": "0000644",
11755
- "sha256": "b77d62d7dd83ff167e25c53787527ea044bd99df88482ee24ee02ba2137000f7"
11760
+ "sha256": "932cb047550c1fe02875f752e2a65fd1c592e3196fca1a25cec6a43e7ca45264"
11756
11761
  },
11757
11762
  {
11758
11763
  "path": "tests/onboarding_gate.mjs",
@@ -11867,7 +11872,7 @@
11867
11872
  {
11868
11873
  "path": "tests/platform_visibility_gate.mjs",
11869
11874
  "mode": "0000644",
11870
- "sha256": "7a4a3677e25bd55a6ebb64130f724f030f0b57d0ccc07172de3860cbeb4a66d7"
11875
+ "sha256": "bc2cf20f85fa231b0a8edf7bbf6eef4ef698674397ad9a7009aaaa3399e2dd1f"
11871
11876
  },
11872
11877
  {
11873
11878
  "path": "tests/post_login_landing.mjs",
@@ -22,18 +22,35 @@ Interview the owner for: product name, world name, company; the **GitHub repo**
22
22
 
23
23
  ### 2. Tell us about the project — OPTIONAL, and skipping it is fine (task 1002334)
24
24
 
25
- Ask two things, and offer the skip in the same breath — a "you can leave this blank" that arrives *after* the question reads as a test:
25
+ Ask three things, and offer the skip in the same breath — a "you can leave this blank" that arrives *after* the question reads as a test:
26
26
 
27
27
  - **What are you building?** A sentence or two. This is the project's **description**, and it is half of the publish gate ([ADR 0182](../../../docs/adr/<redacted>.md) D4: a project is on the public map iff it has a name AND a description).
28
28
  - **Who's building it?** `solo` / `small-team` / `community`. This is what the platform's setup recommendations read.
29
+ - **What kind of project is it?** `game` / `research` / `business` / `non-profit` / `not-sure` — the declared **type**, and the one answer that does real work downstream: it keys the starter bundle the next step preselects ([ADR 0237](../../../docs/adr/<redacted>.md)). `not-sure` is a real answer, not a missing one.
29
30
 
30
- Both ride the provisioning request as `description` and `detail: { team_shape }`. **Skipping is a supported default, not a failure:** the project stands up exactly the same, it just stays *dark matter* — off the public map and un-joinable — until a description exists. Say that plainly when the owner skips, and say the way back with it: `PATCH /provisioning/instances/:id/detail` fills either answer in later, and publishes the project the moment the description lands.
31
+ All three ride the provisioning request `description`, `detail: { team_shape }`, and `type` — and each is independently skippable. **Skipping is a supported default, not a failure:** the project stands up exactly the same, it just stays *dark matter* — off the public map and un-joinable — until a description exists. Say that plainly when the owner skips, and say the way back with it: `PATCH /provisioning/instances/:id/detail` takes `description`, `detail` and `modules` later, and publishes the project the moment the description lands.
32
+
33
+ **`type` is the one answer with no way back yet** — the detail PATCH does not accept it, and an owner-facing "change my project type" surface is still open work (task 1003504). Do not tell an owner they can change it later. It is still safe to leave unanswered: `not-sure` and an absent type both simply mean the next step has no type-specific bundle to preselect, and the modules themselves stay editable forever.
31
34
 
32
35
  Never gate the standup on this step, and never invent an answer the owner didn't give — a recommendation built on a guess is worse than no recommendation.
33
36
 
34
37
  **Read the verdict back rather than asserting one (task 1002335).** Every instance read and the detail PATCH answer with `publish: { publishable, missing, missing_phrase }` — the server's own derivation of the gate. Report *that*, verbatim: "it's on the public map" or "it has no description, so it isn't on the map yet". Do not compute publishability yourself from the fields you happen to have sent; the owner's project page renders the same server verdict, and two surfaces disagreeing about whether a project is published is worse than either being briefly stale. If `publish` is absent, this platform has no public map — say nothing about one.
35
38
 
36
- ### 3. DNS token preflightBEFORE you commit the address (task 1980)
39
+ ### 3. Pick its extrasOPTIONAL, and the skip is the recommendation (task 1002339)
40
+
41
+ Every project runs the **always-on core** whatever you do here; this step is only about the **optional** modules on top. Read the preselection out of the server, never a list you carry: `GET /provisioning/starter-bundles` returns the always-on core once, then one resolved bundle per type — and the bundle for the type answered in step 2 is what the picker shows ticked ([ADR 0237](../../../docs/adr/<redacted>.md)). Show the owner what is already ticked and why, then let them add or remove.
42
+
43
+ The type is not the only input: the **team shape** from step 2 adjusts the bundle too, and the server reports each adjustment that actually changed something, with its reason. Those belong to the bundle itself, not to a note beside it — render them as part of what is ticked, so cause and effect arrive together ([ADR 0243](../../../docs/adr/<redacted>.md)). A rule whose module the bundle already carries records nothing, so an empty adjustment list means the shape changed nothing, not that it was ignored.
44
+
45
+ **Skipping means "take the bundle", and that is a real default, not a deferral** ([ADR 0240](../../../docs/adr/<redacted>.md)). Three answers, three different meanings — say them apart, because two of them look alike and are not:
46
+
47
+ - **Skip** — send no `modules` key at all. The row stores nothing and the bundle is resolved **on read**, so the project ends up with exactly the set the picker had ticked. A later change to the bundle for that type reaches this project too.
48
+ - **An explicit list** — `modules: ["…"]`, the opt-in keys only, never the always-on core. This is now the project's own answer and it stops tracking the bundle.
49
+ - **`modules: []`** — the deliberate "none of them". Not the same as skipping, and a flow that collapses the two takes a choice away.
50
+
51
+ Nothing here is final: modules can be added after creation from the project's own page, which is why this step should never feel like a gate. If the owner is unsure, say that the bundle is the recommendation and move on — an owner stalled choosing modules is the failure this step's default exists to prevent.
52
+
53
+ ### 4. DNS token preflight — BEFORE you commit the address (task 1980)
37
54
 
38
55
  Before anything is provisioned, verify the control-plane's Cloudflare token can actually manage the domain's zone (a mismatched-zone token is exactly what broke the Emersonian standup mid-flow):
39
56
 
@@ -44,12 +61,12 @@ node scripts/gds/provision.js dns-check <domain>
44
61
  - ✓ reachable → continue.
45
62
  - ✗ not reachable → **STOP and resolve it now**, don't push past it. The command prints the fix; usually: widen the Cloudflare API token to include the domain's zone (CF dashboard → My Profile → API Tokens → edit → Zone Resources → Include → the zone), then re-run `dns-check` until green. Only then proceed.
46
63
 
47
- ### 4. `bongos init` — scaffold (greenfield) **or** `--adopt` (brownfield)
64
+ ### 5. `bongos init` — scaffold (greenfield) **or** `--adopt` (brownfield)
48
65
 
49
66
  **This is the one leg that branches** (ADR 0121 §Decision 1). Pick the mode that matches the repo:
50
67
 
51
68
  - **Greenfield** — a NEW, empty repo. The default below.
52
- - **Brownfield / adopt** — an EXISTING repo (code, history, a backlog). Run `node bin/bongos.js init --adopt` instead. It DETECTS the repo's stack, runs a conflict **pre-flight** (migration-namespace / fitness-boundary / protected-path / CI-check collisions) and writes **nothing until you accept** (interactive y/N, or `--accept`); a genuine BLOCK (e.g. a `core_`-prefixed migration) is a hard stop, not accept-able. On accept it LAYERS the lean config + the pinned `@cloudbongos/core` dep + `.claude/` **additively** (merges-or-leaves-and-reports; never clobbers, never the `--force` path), captures the repo's **REAL** current version/goal, and imports its existing backlog (open GitHub issues via `GITHUB_TOKEN`/`GH_TOKEN`, else a `TODO`/`ROADMAP`/`BACKLOG` file). Then **skip step 5** — adopt layered onto the existing repo, there is no empty repo to scaffold — and continue from step 6 (provision). Every other leg is identical.
69
+ - **Brownfield / adopt** — an EXISTING repo (code, history, a backlog). Run `node bin/bongos.js init --adopt` instead. It DETECTS the repo's stack, runs a conflict **pre-flight** (migration-namespace / fitness-boundary / protected-path / CI-check collisions) and writes **nothing until you accept** (interactive y/N, or `--accept`); a genuine BLOCK (e.g. a `core_`-prefixed migration) is a hard stop, not accept-able. On accept it LAYERS the lean config + the pinned `@cloudbongos/core` dep + `.claude/` **additively** (merges-or-leaves-and-reports; never clobbers, never the `--force` path), captures the repo's **REAL** current version/goal, and imports its existing backlog (open GitHub issues via `GITHUB_TOKEN`/`GH_TOKEN`, else a `TODO`/`ROADMAP`/`BACKLOG` file). Then **skip step 6** — adopt layered onto the existing repo, there is no empty repo to scaffold — and continue from step 7 (provision). Every other leg is identical.
53
70
 
54
71
  Greenfield:
55
72
 
@@ -59,11 +76,11 @@ node bin/bongos.js init # interview (or: init --from spec.json for non-
59
76
 
60
77
  It writes lean `config/branding.json` / `config/modules.json` / `config/hierarchy.json`, seeds the first version + its done-when goal, files the human-only kickoff tasks, and — when you opt into hosting — files a **provisioning request** (`POST /provisioning/instances`). Choose **hosting shape `standalone`**. Enable the `provisioning` module on the target so the request is honored (`config/modules.json` → `"provisioning": true`).
61
78
 
62
- ### 5. Scaffold the standalone repo
79
+ ### 6. Scaffold the standalone repo
63
80
 
64
- Materialize the instance's own repo (ADR 0108): clone the empty GitHub repo to the standalone root (`${PROVISION_STANDALONE_BASE:-/srv/cloudbongos}/<slug>` on the control plane), drop in the `config/` + `.claude/` from step 4, and pin the core as a dependency (`@cloudbongos/core` in the instance `package.json`). Commit + push. This checkout is what the standalone service will run from.
81
+ Materialize the instance's own repo (ADR 0108): clone the empty GitHub repo to the standalone root (`${PROVISION_STANDALONE_BASE:-/srv/cloudbongos}/<slug>` on the control plane), drop in the `config/` + `.claude/` from step 5, and pin the core as a dependency (`@cloudbongos/core` in the instance `package.json`). Commit + push. This checkout is what the standalone service will run from.
65
82
 
66
- ### 6. Provision — core + DB + address + service (task 1978)
83
+ ### 7. Provision — core + DB + address + service (task 1978)
67
84
 
68
85
  The web tier only **enqueued** the request; the **control-plane runner** does the real work (it alone holds the DO/Cloudflare tokens):
69
86
 
@@ -72,9 +89,9 @@ node scripts/gds/provision.js run-intents --apply # drains the queue
72
89
  # or, for one instance: node scripts/gds/provision.js provision <slug> --apply
73
90
  ```
74
91
 
75
- For `standalone` this composes: allocate a port → `git pull` + `npm ci --omit=dev` (installs the pinned core) → `createdb` + **instance-root** migrate (`node_modules/@cloudbongos/core/scripts/migrate.sh`) → per-instance `/etc/<slug>/web.env` → a **standalone systemd unit** (runs the core package's `platform-server.js` from the instance repo) → DNS A-record UPSERT → per-instance Caddy snippet (on-demand TLS) → `/healthz`. It is idempotent + dry-run-by-default (omit `--apply` to preview). It **fail-fasts on the step-2 DNS preflight** if step 2's token check was skipped.
92
+ For `standalone` this composes: allocate a port → `git pull` + `npm ci --omit=dev` (installs the pinned core) → `createdb` + **instance-root** migrate (`node_modules/@cloudbongos/core/scripts/migrate.sh`) → per-instance `/etc/<slug>/web.env` → a **standalone systemd unit** (runs the core package's `platform-server.js` from the instance repo) → DNS A-record UPSERT → per-instance Caddy snippet (on-demand TLS) → `/healthz`. It is idempotent + dry-run-by-default (omit `--apply` to preview). It **fail-fasts on the step-4 DNS preflight** if that token check was skipped.
76
93
 
77
- ### 7. GitHub sign-in — ONE click (App Manifest flow), or a manual OAuth app + first sign-in
94
+ ### 8. GitHub sign-in — ONE click (App Manifest flow), or a manual OAuth app + first sign-in
78
95
 
79
96
  **Recommended — one click (task 2080, ADR 0133).** GitHub has no create-an-OAuth-App API, but it *does* let us pre-fill a **GitHub App Manifest**: the owner clicks one link, approves on GitHub, and GitHub hands the control plane the credentials automatically — **no OAuth app to hand-create, no secret to copy-paste ever.** From the control-plane hall, **Start a project → “Set up GitHub sign-in”** (the button appears once the instance is requested; it needs the instance's **domain** set). Under the hood it:
80
97
 
@@ -103,7 +120,7 @@ node scripts/gds/oauth-secret.js place <slug> --client-id <id> --client-secret <
103
120
 
104
121
  **Human-only — first sign-in.** The owner opens `https://<domain>` and signs in with GitHub **once**. This seats the **founding Archon** — config alone seats no one, so this step is inherent and cannot be automated.
105
122
 
106
- ### 8. Verify
123
+ ### 9. Verify
107
124
 
108
125
  ```bash
109
126
  curl -s https://<domain>/healthz # → {"ok":true,"auth_configured":true}
@@ -111,25 +128,56 @@ curl -s https://<domain>/healthz # → {"ok":true,"auth_configured":tru
111
128
 
112
129
  Confirm the owner is **Archon** (their profile / the hall roster). If `auth_configured` is `false`, re-run the `oauth-secret place` step and recheck the OAuth app values.
113
130
 
114
- ### 9. Brand
131
+ ### 10. Brand
115
132
 
116
133
  Fill the instance **look** — theme / palette / fonts in `config/branding.json` (see `docs/branding-fill-prompt.md`). The identity strings were set at init; this is the visual layer. "Brand with your own LLM."
117
134
 
118
- ### 10. Enter your builders hall
135
+ ### 11. Enter your builders hall
119
136
 
120
137
  The hall is the point of everything above — end there, not at brand (task 1002703):
121
138
 
122
139
  - **With a domain:** open `https://<domain>/builders` — that's the project's home.
123
140
  - **No domain yet:** run `bongos dev` in the repo checkout — it stands the instance up locally and serves the hall on the owner's machine ([ADR 0149](../../../docs/adr/<redacted>.md)).
124
141
 
142
+ ### 12. Decide how the project stands — the four axes (BV1.R05/R10/R12, ADR 0182)
143
+
144
+ **These are not asked during creation, and that is deliberate.** A new project starts on today's behaviour — `platform_visibility: public`, `joinability: apply`, `visibility: public`, `join_grant: full` — and the owner changes them afterwards from the project's own page, each card PATCHing `…/instances/:id/settings` with its own key. Do not invent a visibility question in the creation flow; walk the owner through these once they are in, when the project is real enough for the answers to mean something.
145
+
146
+ Four axes, four different questions — they are **not** synonyms, and an owner may set them independently ([ADR 0182](../../../docs/adr/<redacted>.md)):
147
+
148
+ | Key | Asks | Values |
149
+ |---|---|---|
150
+ | `platform_visibility` | who may **read** this project's own pages | `public` · `gated` |
151
+ | `joinability` | **how** someone becomes a member | `open` · `apply` · `invite_only` |
152
+ | `visibility` | how it stands on the platform's **map** | `public` · `private` · `stealth` |
153
+ | `join_grant` | what joining a **public** project grants | `view` · `apply` · `full` |
154
+
155
+ Four things worth saying plainly, each of which has already confused someone:
156
+
157
+ - **`visibility` does not put the project on the map — publishability does.** A project is on the public map iff it has a name AND a description ([ADR 0182](../../../docs/adr/<redacted>.md) D4); that is derived, never stored, and no value of `visibility` puts a husk on the map. Read the server's `publish: { publishable, missing, missing_phrase }` back verbatim rather than computing it (step 2's rule, and it applies here too).
158
+ - **`gated` is enforced on the instance, not by the hub** — a middleware ahead of every surface refuses a session-less stranger with a door that carries the way in ([ADR 0192](../../../docs/adr/<redacted>.md)). It grants nothing; every rank check behind it still runs.
159
+ - **Gated and apply-to-join compose, and a stranger can still apply** ([ADR 0278](../../../docs/adr/<redacted>.md)): the member door exempts the application write and the applicant's own status poll, each scoped to one verb, so the owner's applicant queue keeps the door in front of it. Before that fix the two settings silently composed into "nobody can apply".
160
+ - **The door is one composed answer, and a refusal never names which knob closed it** ([ADR 0247](../../../docs/adr/<redacted>.md)). `join_grant` is the ceiling on the visibility axis that `joinability` may only narrow, and it governs the `public` state alone.
161
+
162
+ A settings change rides web.env and is applied by a **restart**, so the project's own manifest is the only honest source for what it currently runs — the saved choice and the running value differ for the whole life of a push, and a surface that collapses them is lying in one direction or the other ([ADR 0190](../../../docs/adr/<redacted>.md)).
163
+
164
+ ### 13. Invite the first builder — the done panel's first act, not a rail step (ADR 0249)
165
+
166
+ Deliberately **outside** the numbered rail above: a step that can only run once the project exists is not a step in creating it, and putting it in the rail would buy a renumbering coupling and still have to be reordered to sit after Create ([ADR 0249](../../../docs/adr/<redacted>.md)). It appears the moment the created record has an id and a slug, it is skippable there, and **the same widget is the way back** — the project's own page carries it beside *Its extras*, one implementation mounted twice, so an owner who skipped is never stuck.
167
+
168
+ `GET /provisioning/instances/:id/invite-suggestions` answers who is worth inviting. Treat that list as a **recruiting surface**: it owes the people on it the recruiting opt-out, and an owner who is shown names has to be shown them honestly ([ADR 0251](../../../docs/adr/<redacted>.md)).
169
+
125
170
  ## Done-when
126
171
 
127
- A new owner has gone zero → live: their own standalone repo on a pinned core, provisioned with no hand-editing of servers, the four human-only steps done UI-first + verified, and they are signed in as the founding Archon on their own domain. That is goal 35's `project-creation-flow` criterion.
172
+ A new owner has gone zero → live: their own standalone repo on a pinned core, provisioned with no hand-editing of servers, the six human-only steps done UI-first + verified, and they are signed in as the founding Archon on their own domain. That is goal 35's `project-creation-flow` criterion.
173
+
174
+ Steps 12 and 13 are **not** part of that criterion and must never gate it. They are what the owner does once the project is theirs — how it stands on the platform, and who else is in it — and both have a way back, so an owner who does neither has still finished creating a project.
128
175
 
129
176
  ## Constraints
130
177
 
131
- - **This skill is the source of truth for the sequence.** If a step changes, change it here; `bongos onboard` (task 1982) and the hall wizard (task 1983) must render the same sequence.
178
+ - **This skill is the source of truth for the sequence.** If a step changes, change it here; `bongos onboard` (task 1982) and the hall wizard (task 1983) must render the same sequence. The machine-readable twin is [`modules/provisioning/onboard-plan.js`](../../../modules/provisioning/onboard-plan.js), served at `GET /provisioning/onboard-plan` — a **rail** step added here gets a step there in the same change, or the two drift and each claims to be canonical.
179
+ - **Steps 12 and 13 are deliberately NOT in that plan**, and that is the distinction to keep: the plan is the creation rail, and both of those run only once the project exists. A step that can only run after the terminal step is not a step in the rail ([ADR 0249](../../../docs/adr/<redacted>.md)) — it belongs to the done panel and the project's own page, each of which carries its own way back.
132
180
  - **Standalone only** (ADR 0108) — do not fall back to editing this monorepo's checkout for a new project.
133
181
  - **Never weaken the trust boundary** — the web tier enqueues; only the control-plane runner (`provision.js`) touches cloud tokens (ADR 0016 / 0111 §2).
134
182
  - **Live docs are automatic** — a standalone instance's session-log index regenerates on deploy (`provision.js` runs `regen-instance-docs.js` after migrate; `bongos init`/`upgrade` seed+refresh it), and the whole-file nav docs are gitignored so `git reset --hard` never wipes them ([ADR 0147](../../../docs/adr/<redacted>.md)). If you wire a **bespoke** deploy script for an instance, it MUST call `node node_modules/@bongos/core/scripts/gds/regen-instance-docs.js` after `git reset` + migrate — see [`docs/recipes/standalone-live-docs.md`](../../../docs/recipes/standalone-live-docs.md).
135
- - **Adopting an EXISTING repo** (brownfield) is the other branch of the scaffold leg (step 4) — `bongos init --adopt` ([ADR 0121](../../../docs/adr/<redacted>.md)): detect → accept-gated conflict pre-flight → additive layering (never clobbers) → real version/goal + backlog import. Built; the `bongos onboard` CLI and the hall "start a project" wizard offer the same greenfield-vs-adopt choice.
183
+ - **Adopting an EXISTING repo** (brownfield) is the other branch of the scaffold leg (step 5) — `bongos init --adopt` ([ADR 0121](../../../docs/adr/<redacted>.md)): detect → accept-gated conflict pre-flight → additive layering (never clobbers) → real version/goal + backlog import. Built; the `bongos onboard` CLI and the hall "start a project" wizard offer the same greenfield-vs-adopt choice.
@@ -0,0 +1,112 @@
1
+ # ADR 0278 — A gated project still takes applications, and the exemption is scoped to the verb
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-11
5
+ - **Tasks:** [task 1003525](https://cloudbongos.com/builders#/task/1003525) (this decision + the applicant's poll), with the apply write itself in [task 1003624](https://cloudbongos.com/builders#/task/1003624).
6
+ - **Goal:** [#1000106](https://cloudbongos.com/builders#/goal/1000106) — Working area 1, Project creation.
7
+ - **Decider:** the owner, 2026-09-11. The question was raised as an owner decision by task 1003525's own body and by [ADR 0192](<redacted>.md) §3, and is answered here rather than by whoever held the task.
8
+ - **Extends, does not amend:** [ADR 0192](<redacted>.md) (the member door and its exempt list), [ADR 0194](<redacted>.md) (the join door), [ADR 0209](<redacted>.md) (one budget across the two account-existence reads; the response-collapse question, still open).
9
+
10
+ ## Context
11
+
12
+ A project's owner sets two things on the hub independently:
13
+
14
+ - **who may SEE it** — `project.platformVisibility`, `public` or `gated` (ADR 0192);
15
+ - **who may JOIN it** — `project.joinability`, `open`, `apply` or `invite_only` (ADR 0194).
16
+
17
+ Set both to their middle values — members-only *and* apply-to-join — and the
18
+ project took no applications at all. `platform-visibility-gate.js` mounts ahead
19
+ of every surface and refused each cookie-less request with `401` before the
20
+ public `POST <api>/access-requests` could answer, because that write was not on
21
+ the exempt list. The two settings composed into **"nobody can apply"**, a state
22
+ the owner never chose and no surface offered them.
23
+
24
+ Nothing lied about it, which is why it survived: the hub's join box reported the
25
+ project's own relayed `401` honestly as `members_only`
26
+ (`my-projects.js joinRelayOutcome`), and the hall's landing — where the apply
27
+ form lives — is itself behind the door. The composition was simply unreachable.
28
+ Found by the R14 proof ([task 1002333](https://cloudbongos.com/builders#/task/1002333)).
29
+
30
+ ## The question, and why it was the owner's
31
+
32
+ ADR 0192 §3 fixed the exempt list at "the door, the manifest, the probes and the
33
+ downloads — and nothing wider", and named the cost of the one subtree it exempts
34
+ whole (`auth/*`). Widening it is a judgement about what a members-only project
35
+ owes a stranger, not an engineering detail, so §3 left it and task 1003525 asked
36
+ it directly: **may a gated project take applications from outside?**
37
+
38
+ The alternative was coherent and was genuinely on the table: gated means gated,
39
+ and the fix is to stop offering a door that cannot work — say so in the manage
40
+ page's joinability blurb, and hide *Apply to join* while visibility is gated.
41
+
42
+ ## Decision
43
+
44
+ **Yes. A gated project still takes applications, and both halves of that are
45
+ exempted — each scoped to one path and one verb.**
46
+
47
+ 1. **`POST <api>/access-requests` is exempt** (task 1003624). The application
48
+ itself. It grants nothing: an application is a row in a queue the owner still
49
+ reviews (ADR 0201), it carries its own per-IP and per-login limits, and it is
50
+ public on every non-gated project already.
51
+
52
+ 2. **`GET <api>/access-requests/status` is exempt** (task 1003525). Without it
53
+ the answer is half an answer. `bongos login` cannot re-poll the device flow
54
+ after a `not_approved` — the `device_code` is spent — so it polls this route
55
+ instead and restarts sign-in once admitted. Exempting only the write would
56
+ let an applicant file a request and then wait on an approval they can never
57
+ observe.
58
+
59
+ 3. **The exempt list learns about verbs, and the new entries use them.**
60
+ `EXEMPT` entries may now be `{ re, methods }` beside the existing bare
61
+ `RegExp`s, and `isExempt(path, method)` takes the method as an OPTIONAL second
62
+ argument that **fails closed** for a scoped entry when no verb is given — so
63
+ the existing one-argument static callers (`tests/version_literals.mjs`,
64
+ `tests/prelaunch_gate.mjs`) are unchanged and cannot accidentally widen.
65
+
66
+ The verb is load-bearing, not tidiness. The bare `GET` on
67
+ `<api>/access-requests` is the **owner's queue**
68
+ (`requireBuilder` + `access_request.review`) — the surface that lists
69
+ would-be builders by name, with their vouch state. A path-only exemption
70
+ would have silently removed the member door from in front of it, leaving only
71
+ its own rank check where there had been two layers. `…/invite` and the
72
+ per-row review routes sit one segment over and stay gated for the same reason.
73
+
74
+ ## Consequences
75
+
76
+ - On a gated + apply project a stranger can reach exactly two routes: file an
77
+ application, and ask after their own. Every other surface still meets the door.
78
+ - **This opens no oracle the gate was closing.** The boolean twin of the status
79
+ route, `GET <api>/auth/web/admission-status`, is *already* reachable on a gated
80
+ project inside the `auth/*` subtree ADR 0192 §3 exempts whole — §3 records that
81
+ cost in as many words. The two routes deliberately share ONE per-IP budget
82
+ (`accountExistenceReadRateLimit`, ADR 0209) precisely so neither can be
83
+ alternated against the other for double the rate, and that stays true here.
84
+ - **What it does add is applicant detail**, and this is the honest cost: over the
85
+ twin's bare `admitted`, the status route distinguishes `pending` / `dismissed` /
86
+ `none`. Whether *that* answer should collapse is ADR 0209's open owner question,
87
+ filed as a blocker against [task 1003339](https://cloudbongos.com/builders#/task/1003339).
88
+ It is unchanged by this decision — the question was already live on a public
89
+ project, and a gated one now reaches the same route under the same budget.
90
+ - **The hub needed no change.** `joinRelayOutcome` maps the *relayed* status
91
+ (`401` → `members_only`), so the moment the project stops answering `401` the
92
+ hub relays the project's real answer — the door refusal, the `409`, or the
93
+ receipt — with no second edit.
94
+ - A project whose pinned core predates this keeps the old behaviour and refuses
95
+ applications; like every `project.*` setting it is applied by the pin, not by
96
+ the hub's row (ADR 0190).
97
+
98
+ ## Rejected
99
+
100
+ - **"Gated means gated" — no exemption, and hide the apply door instead.** The
101
+ coherent alternative, and the owner's call went the other way. It would have
102
+ made the two settings honest by removing a choice rather than by honouring it.
103
+ - **Exempting the path without the verb.** One line shorter and it hands the
104
+ owner's applicant queue a demotion from two layers to one. The queue's own
105
+ `requirePermission` would still hold, which is exactly what makes the loss easy
106
+ to miss.
107
+ - **Exempting the write alone**, leaving the poll gated. Cheaper, and it produces
108
+ an applicant who has applied and cannot be told they were admitted.
109
+ - **Collapsing the status response here**, while the route was already being
110
+ touched. It is ADR 0209's deferred owner question with its own evidence and its
111
+ own blocker; answering it as a side effect of a visibility fix is how a
112
+ deferred decision gets made by accident.
@@ -369,3 +369,4 @@ This keeps the decision history honest and traceable.
369
369
  | 0275 | [**A role's written responsibility has one source, and the pack's copy is generated** ([task 1003732](https://cloudbongos.com/builders#/task/1003732) · goal 1000095 — *Working area 6*, criterion `wa6-written-responsibilities`; statements fixed by the area owner 2026-09-08). The criterion is one sentence — *"one source, shown on the profile, injected into the pack, referenced by grading"* — and before this task the three statements existed only in the criterion record, nowhere in the tree. The interesting half is why ONE source and not three good copies: if each consumer holds its own, they drift, and the drift has a specific unfair shape — the project shows a builder one standard on their profile, teaches their session a second, and grades the shipped work against a third, so someone is judged by a rule they were never shown. **Decision: a frozen map at `src/role-responsibilities.js` (verbatim owner text, imports nothing), read by three consumers, with the one copy that CAN drift generated and CI-gated.** The hall reads it through `clientModules()` — already injected into every served page as `window.__MODULES__` and already carrying the discipline roster the statements are keyed by, so the profile renders with no new route, no fetch and no copy. The grader reads `responsibilityFor()` through the module doorway and states the standard in the prompt as the project's, not its own. The PACKS get a generated block: markdown cannot require a JS module, so their copy is the only one that can go stale — and it is the worst to lose, being what a session is actually instructed by. `gen-role-responsibilities.js` writes it from the ROLE REGISTRY (so a renamed pack, or Governor when the owner decides, needs no edit), and `role-pack-guard.js` fails CI on drift. Three consequences worth the record. (1) `fitness.js` needed an EXEMPTION for the owner's own prose: the ideator's statement opens "Make good ideas" and `ideas` is a module key, so the no-module-keys scan flagged an English word — paraphrasing to satisfy a scanner is precisely what the task forbids, so the reason is written down and the file's import DIRECTION is still guarded. (2) Registering the freshness check inside `checkGeneratedArtifactsFresh` pushed `fitness.js` one line past its 1,500-line ratchet; rather than raise the baseline (which the check forbids in its own message) the assertion MOVED into `role-pack-guard.js`, which already owns the packs — one check now answers "are the packs correct". (3) The generator matches each pack's own line ending, because `.md` checks out CRLF on Windows and LF in CI, and a generator that always wrote `\n` would make the gate red on one checkout and green on the other for a file nobody touched. A craft with no statement (`ui`, module-contributed per [ADR 0272](<redacted>.md); Governor, deferred per [ADR 0274](<redacted>.md)) renders NOTHING everywhere — no empty quote, no grader line — because a grader told a role has no written standard would supply its own. The profile's avatar was repinned to the top row (the head grid is `align-items:center`, fine for three short lines, wrong once the block made the column tall); verified in both themes in the hall preview. `tests/role_responsibilities.mjs` asserts the criterion's actual content by sweeping `src/`, `scripts/`, `modules/`, `tests/` and `docs/packs/` for any second copy. Rejected: three hand-written paragraphs (the drift would be invisible); a dedicated route for the hall (a fetch and a failure mode for static text already on a rail); storing the statements in the DB (not live state — a row puts the project's written standard outside code review); letting the grader describe a craft from its name (the invented standard this removes); raising `oversized_file_count` to let `fitness.js` grow.](<redacted>.md) | roles / grading / generated artifacts |
370
370
  | 0276 | [**The skill-listing budget cannot hold 64 skills and every trigger, so the number is ratcheted and the choice goes to the owner** ([task 1003620](https://cloudbongos.com/builders#/task/1003620) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). Every SKILL.md description is resident in EVERY session for every craft, and `skill-lint` budgets the whole listing at 8,000 chars. It has been trimmed twice ([#1003548], [#1003587]) and grown back both times, because the 400-char per-description aim was a WARNING THAT ACCUMULATES. This task rewrote 49 of the 50 `.claude/skills/` descriptions — 17 warnings to 0, listing 25,624 → 19,316 chars, about 1,570 tokens returned to every session — with every trigger phrase preserved and CHECKED MECHANICALLY (a checker diffed the quoted phrases against HEAD and caught five real losses, all restored; four dropped strings were UI labels, not triggers). **8,000 was not reached, and the arithmetic says it cannot be:** across 64 skills the names (880) plus the mandatory quoted triggers (~5,370) are an irreducible ~6,250, leaving ~27 chars per skill to say what the skill DOES. Reaching the budget means trigger-only descriptions — trading truncation for the loss of the semantic signal routing leans on when the user’s words do not literally match a trigger. **Decision: ratchet `skill_listing_chars` at 19,316** in `fitness-ratchets.js` (fails open if the linter cannot load), so it cannot grow while the real question is open; the baseline is deliberately ABOVE skill-lint’s budget and is not a claim the budget is met. **Open and owner-gated:** delist skills (64 is a menu), scale the budget with the roster, or accept trigger-only text — recommendation is delist then rescale, filed as a blocker. `fitness.js`’s over-budget warning STAYS, as the visible trace of that question. Rejected: raising `LISTING_BUDGET_CHARS` to silence the warning (deletes the signal, not the debt); re-cutting the 14 module ui-design descriptions a week after [#1003587] wrote them, for ~2,000 chars toward a target still 9,000 away.](<redacted>.md) | skills / context budget / routing |
371
371
  | 0277 | [**A box is "in use" only while a human is attached, and the claim expires** ([task 1003507](https://cloudbongos.com/builders#/task/1003507) · goal 1000095 — *Working area 6*, criterion `wa6-role-experience`). `sweep-idle` skipped any box with `claude_active = true` at ANY age, and `claude_active` is a LATCH, not a level: only a heartbeat ping writes it, and `infra/box-heartbeat.sh` exits WITHOUT pinging when it sees nothing — so silence, the very signal the sweep exists to act on, could never clear it. Reproduced against the shipped selector: skipped at `idleMinutes` of 10, 120, 1440 and **5,256,000** (ten years). Not reaped late; never. The second leg is that `load > 0.2` kept `last_activity_at` bumping every 5 minutes anyway (a devcontainer plus an idle `claude` clears that floor on its own), so EITHER leg alone kept a box alive — which is why bounding only the veto looks like a fix and is not: the incident box's heartbeat was FRESH. Measured: `example-owner`'s box up since 2026-08-12, tmux `otb` UNATTACHED since 2026-08-15 18:01 UTC, `claude` burning 16 min of CPU across 25h of wall clock, **$20.21** of mostly-unattended compute. A THIRD defect, found while testing this and confirmed on clean `origin/main`, made the sweep inert regardless: `loadDeps()` read `_deps` before anything declared it, so it threw `ReferenceError` on first call and every command resolving a DigitalOcean client went with it — including `sweep-idle --apply`, which builds that client before the park loop. **The idle sweep could not park any box at all**, hidden because the suite's only `apply: true` test relied on the veto emptying the idle set before `makeDo()` was reached: one bug shielded by the other. **Decision: a box may not stay active longer than `BOX_UNATTENDED_MAX_HOURS` (12) without evidence a HUMAN was attached.** The heartbeat already computed that signal and folded it into one boolean; it now reports `attached` separately (login session, inbound SSH, open ttyd, or an ATTACHED tmux client via `#{session_attached}` — the signal that separates this box from a working one) and the server stamps `last_attached_at`. `claude_active` keeps its veto but it EXPIRES, bounded by a `claude_active_since` edge stamp. `pgrep -x claude` and the load floor remain reasons the box PINGS, never evidence anyone is THERE — a running process is not a person, and that distinction is the whole decision. The two `NULL` defaults deliberately DISAGREE: `last_attached_at` NULL means "no data" and keeps a pre-1003507 box on the old behaviour (core_238 pointedly does NOT backfill it — a backfilled `now()` starts a clock nothing can advance and parks every un-upgraded box one cap later, and box source sync is not prompt: idea 1000745 records 257 commits behind for three days), while `claude_active_since` NULL is REFUSED because an unknown latch age is the forever-latch itself, and is backfilled so the state is unreachable after deploy. 12h because parking is reversible since task 1002726 (snapshot kept), so a false positive costs one wake against $20.21 for no cap. Both clocks clear at park/wake/deprovision, or a woken box inherits an expired latch and is parked instantly — the fix reintroducing the bug from the far side. Rejected: tracking attachment INSTEAD of bounding the latch (the silent box keeps `true` forever, so the reported hole survives); bounding the latch alone (built first, and the verification probe caught it — the fresh heartbeat meant lifting the veto changed nothing); deleting the load floor (it is what makes an autonomous run count); measuring from `active_since` (that is uptime — parks a box worked on for days); raising `BOX_IDLE_MINUTES` (no threshold reaches an unbounded veto).](<redacted>.md) | dev box / cost / idle sweep |
372
+ | 0278 | [**A gated project still takes applications, and the exemption is scoped to the verb** ([task 1003525](https://cloudbongos.com/builders#/task/1003525) · the apply write itself in [task 1003624](https://cloudbongos.com/builders#/task/1003624) · goal 1000106 — *Working area 1, Project creation*; owner decision 2026-09-11). A project's owner sets who may SEE it (`platformVisibility`, [ADR 0192](<redacted>.md)) and who may JOIN it (`joinability`, [ADR 0194](<redacted>.md)) independently — and set to their middle values, members-only AND apply-to-join, the project took no applications at all: the member door refused every cookie-less request with `401` before the public `POST <api>/access-requests` could answer, because that write was not on the exempt list. The two settings composed into **"nobody can apply"**, which nobody chose. It survived because nothing LIED about it — the hub's join box relayed the project's own `401` honestly as `members_only`, and the hall's landing, where the apply form lives, is itself behind the door; the composition was simply unreachable. Found by the R14 proof ([task 1002333](https://cloudbongos.com/builders#/task/1002333)). ADR 0192 §3 had fixed the exempt list at "the door, the manifest, the probes and the downloads — and nothing wider" and left widening it as an owner call, which is what this is. **Decision: yes — and BOTH halves are exempted, each scoped to one path and one verb.** `POST <api>/access-requests` (it grants nothing — an application is a row in a queue the owner still reviews, [ADR 0201](<redacted>.md), already public on every non-gated project) and `GET <api>/access-requests/status` (without it the answer is half an answer: `bongos login` cannot re-poll the device flow after a `not_approved` — the `device_code` is spent — so an applicant would file a request and then wait on an approval they can never observe). `EXEMPT` entries may now be `{ re, methods }` beside the bare `RegExp`s, and `isExempt(path, method)` takes the verb as an OPTIONAL second argument that **fails closed** for a scoped entry when none is given, so the one-argument static callers cannot accidentally widen. **The verb is load-bearing, not tidiness:** the bare `GET` on `<api>/access-requests` is the OWNER'S QUEUE (`requireBuilder` + `access_request.review`), the surface listing would-be builders by name with their vouch state — a path-only exemption would have silently taken the member door off the front of it, leaving one layer where there were two, and the queue's own `requirePermission` still holding is exactly what makes that loss easy to miss. **It opens no oracle the gate was closing:** the status route's boolean twin `GET <api>/auth/web/admission-status` is ALREADY reachable on a gated project inside the `auth/*` subtree §3 exempts whole (§3 records that cost in as many words), and the two share ONE per-IP budget on purpose ([ADR 0209](<redacted>.md)) so neither can be alternated against the other. What it DOES add, stated as the honest cost: applicant detail — `pending`/`dismissed`/`none` over the twin's bare `admitted`. Whether that answer should collapse is ADR 0209's still-open owner question and is deliberately NOT decided here. No hub change: `joinRelayOutcome` maps the RELAYED status, so it carries the project's real answer the moment the `401` stops. Rejected: "gated means gated" — hide *Apply to join* and say so in the manage blurb (coherent, and the call went the other way); exempting the path without the verb; exempting the write alone; collapsing the status response while the route happened to be open (that is how a deferred decision gets made by accident).](<redacted>.md) | project visibility / join door / member door |
@@ -1795,5 +1795,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1795
1795
  landed since 1.19.666 with no explicit bump. run 34576878372. (task 1002620)
1796
1796
  1.19.668 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1797
1797
  landed since 1.19.667 with no explicit bump. run 34618700494. (task 1002620)
1798
+ 1.19.669 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1799
+ landed since 1.19.668 with no explicit bump. run 34619821252. (task 1002620)
1800
+ 1.19.670 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1801
+ landed since 1.19.669 with no explicit bump. run 34622305231. (task 1002620)
1798
1802
  ---------------------------------------------------------------------------
1799
1803
  ```
@@ -49,6 +49,14 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
49
49
  // collects it, so it is not one of the irreducibly-manual legs a done panel
50
50
  // lists. `optional` is what makes it skippable — see the header.
51
51
  { key: 'project-detail', kind: 'auto', optional: true, label: 'Tell us about your project — optional, skip it and add it later' },
52
+ // The creation picker's module choice (BV1.R20/R24, task 1002339). 'auto' and
53
+ // optional for project-detail's reasons, and it sits immediately after it
54
+ // because it READS that step's answer: the declared `type` keys the starter
55
+ // bundle this step preselects (ADR 0237). Skipping stores nothing and the
56
+ // bundle is resolved on read (ADR 0240), so a skip yields exactly the set the
57
+ // picker had ticked — which is why the label says "take the recommended set"
58
+ // rather than "add them later": the modules are already there.
59
+ { key: 'project-modules', kind: 'auto', optional: true, label: 'Pick its extras — optional, skip it to take the recommended set for your project type' },
52
60
  { key: 'github-repo', kind: 'human', label: adopt ? 'Use your EXISTING GitHub repository' : 'Create the empty GitHub repository' },
53
61
  { key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
54
62
  { key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.668",
3
+ "version": "1.19.670",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.668",
9
+ "version": "1.19.670",
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.668",
3
+ "version": "1.19.670",
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",
@@ -82,6 +82,20 @@ const API = `(?:${ALL_API_PREFIXES.map(escRe).join('|')})`;
82
82
  // access_request.review), so the exemption is scoped to the
83
83
  // VERB and the queue keeps the member door ahead of its own
84
84
  // rank check.
85
+ // <api>/access-requests/status GET ONLY — the other half of the same door (task
86
+ // 1003525). `bongos login` cannot re-poll the device flow
87
+ // after a not_approved (the device_code is spent), so it polls
88
+ // this instead and restarts sign-in once admitted; without it
89
+ // an applicant to a gated project is told to wait for an
90
+ // approval they can never observe. It opens no oracle the
91
+ // gate was closing: its boolean twin, <api>/auth/web/admission-
92
+ // status, is ALREADY reachable on a gated project inside the
93
+ // exempt auth/* subtree (ADR 0192 §3 names that cost), and both
94
+ // routes spend ONE shared per-IP budget on purpose, so neither
95
+ // can be alternated against the other (ADR 0209). What this
96
+ // adds over the twin is applicant detail — pending vs dismissed
97
+ // vs none — and whether THAT answer should collapse is the
98
+ // owner's open question in ADR 0209, unchanged by being here.
85
99
  const EXEMPT = [
86
100
  new RegExp(`^${API}/auth(/|$)`),
87
101
  new RegExp(`^${API}/instance$`),
@@ -94,6 +108,7 @@ const EXEMPT = [
94
108
  /^\/__stealth$/,
95
109
  /^\/downloads\//,
96
110
  { re: new RegExp(`^${API}/access-requests$`), methods: ['POST'] },
111
+ { re: new RegExp(`^${API}/access-requests/status$`), methods: ['GET'] },
97
112
  ];
98
113
 
99
114
  // The policy, read through the memoized pack. A pack that cannot be read at all
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.668'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.670'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/onboard.mjs CHANGED
@@ -41,10 +41,28 @@ test('the OPTIONAL project-detail step sits in the canonical sequence, and optio
41
41
  assert.equal(step.optional, true, 'the flag is what tells a form it may be walked past');
42
42
  assert.equal(step.kind, 'auto',
43
43
  "'auto' like name-address: the FORM collects it, so a done panel of outstanding human steps must not list it");
44
- // one optional step keeps the flag meaningful; a second would need its own rule
45
- assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail']);
46
- // and the adopt branch carries it identically the sequence branches only at scaffold
47
- assert.ok(ob.onboardPlan('adopt').some((s) => s.key === 'project-detail' && s.optional === true));
44
+ // THE RULE the second optional step needed (task 1002350). `optional` is not a
45
+ // general "nice to have" flag — it marks a CREATION-FORM answer that (a) rides the
46
+ // same POST /provisioning/instances body, (b) has a DEFINED meaning when omitted
47
+ // rather than an absent one, and (c) has a documented way back afterwards. Both
48
+ // members qualify: project-detail omitted leaves the project dark-matter until a
49
+ // description lands (PATCH …/detail is the way back, ADR 0182 D4), and
50
+ // project-modules omitted resolves the declared type's starter bundle on read
51
+ // (ADR 0240; modules are addable from the project's own page). A step that fails
52
+ // any of the three does not get this flag — which is why steps 12 and 13 of the
53
+ // skill (visibility, invite) are not in this plan at all: they run after the
54
+ // terminal step and belong to the done panel (ADR 0249), not the rail.
55
+ assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail', 'project-modules']);
56
+ // the picker follows the interview because it READS its answer: the declared
57
+ // `type` keys the bundle the picker preselects (ADR 0237), so the order is a
58
+ // dependency, not a preference.
59
+ assert.equal(keys.indexOf('project-modules'), keys.indexOf('project-detail') + 1,
60
+ 'the module picker follows the step whose answer decides what it preselects');
61
+ // and the adopt branch carries BOTH identically — the sequence branches only at scaffold
62
+ for (const key of ['project-detail', 'project-modules']) {
63
+ assert.ok(ob.onboardPlan('adopt').some((s) => s.key === key && s.optional === true),
64
+ `${key}: adopt carries it too`);
65
+ }
48
66
  });
49
67
 
50
68
  test('every form OFFERS the optional step and names what skipping costs — skill / CLI / wizard / route (task 1002334)', () => {
@@ -203,6 +203,28 @@ test('the apply door: POST <api>/access-requests answers a stranger — and only
203
203
  assert.equal(isExempt('/api/gds/instance', 'GET'), true);
204
204
  });
205
205
 
206
+ // task 1003525 — the other half of the apply door. `bongos login` cannot re-poll the
207
+ // device flow after a not_approved (the device_code is spent), so it polls the status
208
+ // route instead; gated, an applicant waits for an approval they can never observe.
209
+ test('the status poll: GET <api>/access-requests/status answers a stranger — that verb, that path', () => {
210
+ for (const p of [
211
+ '/api/gds/access-requests/status', '/api/bongos/access-requests/status',
212
+ '/api/bongos/v1/access-requests/status',
213
+ '/API/BONGOS/ACCESS-REQUESTS/STATUS', '/api/gds/access-requests/status/',
214
+ ]) assert.equal(isExempt(p, 'GET'), true, `GET ${p} must reach the status route`);
215
+
216
+ // the two halves do not lend each other their verbs
217
+ assert.equal(isExempt('/api/gds/access-requests/status', 'POST'), false, 'the status route takes no write');
218
+ assert.equal(isExempt('/api/gds/access-requests', 'GET'), false, 'the owner queue is still not a status poll');
219
+
220
+ // and neither opens the review surfaces that share the prefix
221
+ for (const p of ['/api/gds/access-requests/invite', '/api/gds/access-requests/1/approve', '/api/gds/access-requests/1']) {
222
+ for (const m of ['GET', 'POST']) {
223
+ assert.equal(isExempt(p, m), false, `${m} ${p} must stay gated`);
224
+ }
225
+ }
226
+ });
227
+
206
228
  test('gated mode: an exempt path is never looked up, and a CORS preflight passes', async () => {
207
229
  let looked = 0;
208
230
  const mw = platformVisibilityGate({ isGated: () => true, resolveSession: async () => { looked += 1; return null; } });
@@ -324,6 +346,28 @@ test('booted gated + apply: the application reaches the door it was refused at,
324
346
  }
325
347
  });
326
348
 
349
+ // task 1003525, end to end. The applicant's poll must survive the member door; the
350
+ // review surfaces one path segment over must not.
351
+ test('booted gated + apply: the status poll answers, and the review surfaces beside it do not', async () => {
352
+ for (const p of [
353
+ '/api/gds/access-requests/status', '/api/bongos/access-requests/status',
354
+ '/api/bongos/v1/access-requests/status',
355
+ ]) {
356
+ // no ?github_login: the route's OWN validation answers, ahead of any query
357
+ const res = await request(p);
358
+ assert.doesNotMatch(res.body, /Sign in to continue/, `GET ${p}: never the HTML door`);
359
+ const j = JSON.parse(res.body);
360
+ assert.ok(j.error, `GET ${p}: the API envelope, from the route`);
361
+ assert.equal(j.error.code, 'bad_github_login', `GET ${p}: the status route answered, not the member door`);
362
+ }
363
+ // the owner's review surfaces share the prefix and stay behind the door
364
+ for (const p of ['/api/gds/access-requests', '/api/gds/access-requests/invite']) {
365
+ const res = await request(p);
366
+ assert.equal(res.status, 401, `GET ${p} stays gated`);
367
+ assert.equal(JSON.parse(res.body).error.code, 'unauthenticated');
368
+ }
369
+ });
370
+
327
371
  test('booted gated: a bogus session cookie is not a member', async () => {
328
372
  // resolves to no session (or fails closed if there is no database here) —
329
373
  // either way the stranger stays outside