@bongos/core 1.19.667 → 1.19.669
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 +19 -14
- package/docs/adr/0278-a-gated-project-still-takes-applications.md +112 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/platform-visibility-gate.js +38 -3
- package/src/module-api.js +1 -1
- package/tests/platform_visibility_gate.mjs +96 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.669",
|
|
6
|
+
"core_contract": "1.19.669",
|
|
7
|
+
"source_commit": "db01ff449e0ec3309c33d4eae81dfdedad7b5a62",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T16:04:31.249Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
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":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2615,
|
|
20
|
+
"tree_sha256": "5afa597392cde4d8a5783032728f5ea81ba2c271f4226d71e893d0fc19f2cf04",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/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": "
|
|
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": "
|
|
2795
|
+
"sha256": "f2ca27e78e014e1980d45622dbd0ff7e71f049c69e7ac84485fb6d12cac8aa23"
|
|
2791
2796
|
},
|
|
2792
2797
|
{
|
|
2793
2798
|
"path": "docs/modules-contract.md",
|
|
@@ -7757,12 +7762,12 @@
|
|
|
7757
7762
|
{
|
|
7758
7763
|
"path": "package-lock.json",
|
|
7759
7764
|
"mode": "0000644",
|
|
7760
|
-
"sha256": "
|
|
7765
|
+
"sha256": "11cc0033b8ee7fa774319888e0f0fe6dc24af043e607d5d99ce59bfc7969efba"
|
|
7761
7766
|
},
|
|
7762
7767
|
{
|
|
7763
7768
|
"path": "package.json",
|
|
7764
7769
|
"mode": "0000644",
|
|
7765
|
-
"sha256": "
|
|
7770
|
+
"sha256": "8d94b2f718aeb052f7e3c0464fbde8afa6f06c2a8096c90f26abddbf368aeb9f"
|
|
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": "
|
|
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": "
|
|
9530
|
+
"sha256": "5c09f13b72c589166b1fb7152b89bb76cccbef4475ae7d3047a515a59cfb31a2"
|
|
9526
9531
|
},
|
|
9527
9532
|
{
|
|
9528
9533
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11867,7 +11872,7 @@
|
|
|
11867
11872
|
{
|
|
11868
11873
|
"path": "tests/platform_visibility_gate.mjs",
|
|
11869
11874
|
"mode": "0000644",
|
|
11870
|
-
"sha256": "
|
|
11875
|
+
"sha256": "bc2cf20f85fa231b0a8edf7bbf6eef4ef698674397ad9a7009aaaa3399e2dd1f"
|
|
11871
11876
|
},
|
|
11872
11877
|
{
|
|
11873
11878
|
"path": "tests/post_login_landing.mjs",
|
|
@@ -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.
|
package/docs/adr/README.md
CHANGED
|
@@ -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 |
|
|
@@ -1793,5 +1793,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1793
1793
|
landed since 1.19.665 with no explicit bump. run 34567142386. (task 1002620)
|
|
1794
1794
|
1.19.667 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1795
1795
|
landed since 1.19.666 with no explicit bump. run 34576878372. (task 1002620)
|
|
1796
|
+
1.19.668 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
1796
1800
|
---------------------------------------------------------------------------
|
|
1797
1801
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.669",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.669",
|
|
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.
|
|
3
|
+
"version": "1.19.669",
|
|
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",
|
|
@@ -70,6 +70,32 @@ const API = `(?:${ALL_API_PREFIXES.map(escRe).join('|')})`;
|
|
|
70
70
|
// /__prelaunch the pre-launch gate's own submit, when both gates are on.
|
|
71
71
|
// /__stealth is its pre-rename alias (task 1003152) and is
|
|
72
72
|
// exempt for as long as the gate still accepts it.
|
|
73
|
+
// <api>/access-requests POST ONLY — a stranger's application to a project that is
|
|
74
|
+
// BOTH gated and "apply to join" (task 1003624). The owner
|
|
75
|
+
// turned that door on deliberately; without this exemption the
|
|
76
|
+
// member door answers 401 first and the two settings compose
|
|
77
|
+
// into "nobody can apply", which nobody chose. The write is
|
|
78
|
+
// public on every other project and carries its own per-IP and
|
|
79
|
+
// per-login limits, and it GRANTS nothing — an application is a
|
|
80
|
+
// row in a queue the owner still reviews (ADR 0201). The bare
|
|
81
|
+
// GET on the same path is the owner's QUEUE (requireBuilder +
|
|
82
|
+
// access_request.review), so the exemption is scoped to the
|
|
83
|
+
// VERB and the queue keeps the member door ahead of its own
|
|
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.
|
|
73
99
|
const EXEMPT = [
|
|
74
100
|
new RegExp(`^${API}/auth(/|$)`),
|
|
75
101
|
new RegExp(`^${API}/instance$`),
|
|
@@ -81,6 +107,8 @@ const EXEMPT = [
|
|
|
81
107
|
/^\/__prelaunch$/,
|
|
82
108
|
/^\/__stealth$/,
|
|
83
109
|
/^\/downloads\//,
|
|
110
|
+
{ re: new RegExp(`^${API}/access-requests$`), methods: ['POST'] },
|
|
111
|
+
{ re: new RegExp(`^${API}/access-requests/status$`), methods: ['GET'] },
|
|
84
112
|
];
|
|
85
113
|
|
|
86
114
|
// The policy, read through the memoized pack. A pack that cannot be read at all
|
|
@@ -106,9 +134,16 @@ function normalizePath(p) {
|
|
|
106
134
|
return s;
|
|
107
135
|
}
|
|
108
136
|
|
|
109
|
-
|
|
137
|
+
// `method` is OPTIONAL, and each entry decides whether it matters: a bare RegExp
|
|
138
|
+
// exempts the path whatever the verb, while a method-scoped entry matches only the
|
|
139
|
+
// verbs it names. A caller that passes no method therefore gets the fail-CLOSED
|
|
140
|
+
// answer for the scoped entries (the static path checks in tests/version_literals.mjs
|
|
141
|
+
// and tests/prelaunch_gate.mjs call it that way) — never an accidental exemption of a
|
|
142
|
+
// sibling route that shares a path with an exempt one.
|
|
143
|
+
function isExempt(path, method) {
|
|
110
144
|
const p = normalizePath(path);
|
|
111
|
-
|
|
145
|
+
const m = String(method || '').toUpperCase();
|
|
146
|
+
return EXEMPT.some((e) => (e instanceof RegExp ? e.test(p) : e.re.test(p) && e.methods.includes(m)));
|
|
112
147
|
}
|
|
113
148
|
|
|
114
149
|
function isApiPath(path) { return /^\/api\//i.test(String(path || '')); }
|
|
@@ -233,7 +268,7 @@ function platformVisibilityGate(deps = {}) {
|
|
|
233
268
|
return function platformVisibility(req, res, next) {
|
|
234
269
|
if (!gated()) return next();
|
|
235
270
|
if (req.method === 'OPTIONS') return next(); // a CORS preflight carries no identity
|
|
236
|
-
if (isExempt(req.path)) return next();
|
|
271
|
+
if (isExempt(req.path, req.method)) return next();
|
|
237
272
|
// Async middleware must own its rejection (Express 4 drops the promise) —
|
|
238
273
|
// and it FAILS CLOSED: if the session cannot be resolved, the caller's
|
|
239
274
|
// identity is unknown, and admitting is never an option (auth.js's rule).
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.669'; // 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');
|
|
@@ -171,9 +171,60 @@ test('the exempt list: the door, the manifest, the probes, the downloads — on
|
|
|
171
171
|
'/api/bongos/public/progress', '/api/gds/public/leaderboard', '/api/bongos/instances', '/api/gds/authors',
|
|
172
172
|
'/api/bongos/v1/public/progress', '/api/gds/instance/anything', '/instance', '/healthzz', '/downloads',
|
|
173
173
|
'/api/other/instance', '/api/bongos/provisioning/instances',
|
|
174
|
+
// the apply door is METHOD-scoped, so the bare path — no verb — is not exempt
|
|
175
|
+
'/api/gds/access-requests', '/api/gds/access-requests/status',
|
|
174
176
|
]) assert.equal(isExempt(p), false, `${p} must be gated`);
|
|
175
177
|
});
|
|
176
178
|
|
|
179
|
+
// task 1003624. A project can be BOTH gated and "apply to join"; before this the
|
|
180
|
+
// member door answered 401 ahead of the apply route, so the two settings the owner
|
|
181
|
+
// set composed into "nobody can apply". The exemption is the narrowest thing that
|
|
182
|
+
// fixes it: one path, one verb.
|
|
183
|
+
test('the apply door: POST <api>/access-requests answers a stranger — and only that verb, only that path', () => {
|
|
184
|
+
for (const p of [
|
|
185
|
+
'/api/gds/access-requests', '/api/bongos/access-requests', '/api/bongos/v1/access-requests',
|
|
186
|
+
// the router's own tolerances, the same ones the rest of the list is held to
|
|
187
|
+
'/API/BONGOS/ACCESS-REQUESTS', '/api/gds/access-requests/',
|
|
188
|
+
]) assert.equal(isExempt(p, 'POST'), true, `POST ${p} must reach the apply route`);
|
|
189
|
+
|
|
190
|
+
// The bare GET on that path is the OWNER'S QUEUE (requireBuilder +
|
|
191
|
+
// access_request.review). It has its own rank check, and the member door stays
|
|
192
|
+
// in front of it regardless — an exemption that ignored the verb would have
|
|
193
|
+
// silently removed a layer from the queue that lists would-be builders by name.
|
|
194
|
+
for (const m of ['GET', 'HEAD', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', '']) {
|
|
195
|
+
assert.equal(isExempt('/api/gds/access-requests', m), false, `${m || '(no verb)'} on the queue must stay gated`);
|
|
196
|
+
}
|
|
197
|
+
// and the exemption does not spread down the path
|
|
198
|
+
for (const p of ['/api/gds/access-requests/status', '/api/gds/access-requests/invite', '/api/gds/access-requests/1']) {
|
|
199
|
+
assert.equal(isExempt(p, 'POST'), false, `POST ${p} must stay gated`);
|
|
200
|
+
}
|
|
201
|
+
// a method-BLIND entry is unchanged by the new shape: it still exempts any verb
|
|
202
|
+
assert.equal(isExempt('/healthz', 'POST'), true, 'a bare-RegExp entry ignores the verb');
|
|
203
|
+
assert.equal(isExempt('/api/gds/instance', 'GET'), true);
|
|
204
|
+
});
|
|
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
|
+
|
|
177
228
|
test('gated mode: an exempt path is never looked up, and a CORS preflight passes', async () => {
|
|
178
229
|
let looked = 0;
|
|
179
230
|
const mw = platformVisibilityGate({ isGated: () => true, resolveSession: async () => { looked += 1; return null; } });
|
|
@@ -272,6 +323,51 @@ test('booted gated: the door, the manifest, the probes and the downloads still a
|
|
|
272
323
|
assert.doesNotMatch(dl.body, /Sign in to continue/);
|
|
273
324
|
});
|
|
274
325
|
|
|
326
|
+
// task 1003624, end to end on the booted app. The pack at the top of this file is
|
|
327
|
+
// the composition that was broken: platformVisibility 'gated' AND joinability
|
|
328
|
+
// 'apply'. The stranger's application must now reach the apply route; the owner's
|
|
329
|
+
// queue on the same path must not.
|
|
330
|
+
test('booted gated + apply: the application reaches the door it was refused at, and the owner queue does not', async () => {
|
|
331
|
+
for (const p of ['/api/gds/access-requests', '/api/bongos/access-requests', '/api/bongos/v1/access-requests']) {
|
|
332
|
+
const res = await request(p, { method: 'POST' });
|
|
333
|
+
assert.doesNotMatch(res.body, /Sign in to continue/, `POST ${p}: never the HTML door`);
|
|
334
|
+
// The route is reached, so whatever it answers is ITS answer (a bodiless POST
|
|
335
|
+
// is refused by the join door or by validation — both ahead of any query).
|
|
336
|
+
// What must not come back is the member door's own refusal.
|
|
337
|
+
let j = null;
|
|
338
|
+
try { j = JSON.parse(res.body); } catch { /* not JSON — asserted below */ }
|
|
339
|
+
assert.ok(j && j.error, `POST ${p}: the API envelope, from the route`);
|
|
340
|
+
assert.notEqual(j.error.code, 'unauthenticated', `POST ${p}: the member door must not answer first`);
|
|
341
|
+
|
|
342
|
+
// the same path, the owner's verb: still the member door
|
|
343
|
+
const q = await request(p, { method: 'GET' });
|
|
344
|
+
assert.equal(q.status, 401, `GET ${p}: the owner queue stays behind the member door`);
|
|
345
|
+
assert.equal(JSON.parse(q.body).error.code, 'unauthenticated');
|
|
346
|
+
}
|
|
347
|
+
});
|
|
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
|
+
|
|
275
371
|
test('booted gated: a bogus session cookie is not a member', async () => {
|
|
276
372
|
// resolves to no session (or fails closed if there is no database here) —
|
|
277
373
|
// either way the stranger stays outside
|