@bongos/core 1.19.568 → 1.19.569
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 +31 -21
- package/docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md +204 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +5 -3
- package/src/bongos/auth-admission.js +48 -50
- package/src/bongos/project-door.js +75 -2
- package/src/bongos/routes/auth.js +17 -4
- package/src/bongos/routes/instance.js +7 -0
- package/src/branding.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/admission_ceiling_gate.mjs +203 -0
- package/tests/joinability_invite_only.mjs +7 -1
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.569",
|
|
6
|
+
"core_contract": "1.19.569",
|
|
7
|
+
"source_commit": "5cdee5559949c616ec9d68e725884fb87424850e",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-06T20:30
|
|
9
|
+
"built_at": "2026-09-06T20:34:30.476Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 449,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2038,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2511,
|
|
20
|
+
"tree_sha256": "5c3c486ba0465ca763a11d15487bb2b540558af8430a93ea27a07323d75352e1",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1794,10 +1794,15 @@
|
|
|
1794
1794
|
"mode": "0000644",
|
|
1795
1795
|
"sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
|
|
1796
1796
|
},
|
|
1797
|
+
{
|
|
1798
|
+
"path": "docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md",
|
|
1799
|
+
"mode": "0000644",
|
|
1800
|
+
"sha256": "4b7d5b4ca5ad92a128235a6c3407617f2d8b34c26ed6e1c8b2a72e63eed7adac"
|
|
1801
|
+
},
|
|
1797
1802
|
{
|
|
1798
1803
|
"path": "docs/adr/README.md",
|
|
1799
1804
|
"mode": "0000644",
|
|
1800
|
-
"sha256": "
|
|
1805
|
+
"sha256": "52422b934d0b3245605ec0913049be65768a814a6f9aee5f7ad8149c4221a093"
|
|
1801
1806
|
},
|
|
1802
1807
|
{
|
|
1803
1808
|
"path": "docs/api-reference.md",
|
|
@@ -2672,7 +2677,7 @@
|
|
|
2672
2677
|
{
|
|
2673
2678
|
"path": "docs/file-map.md",
|
|
2674
2679
|
"mode": "0000644",
|
|
2675
|
-
"sha256": "
|
|
2680
|
+
"sha256": "dd40cd29025789ec5a9447aa44ec6c7d3bee6aaae183c65713a6a3ca8ed8275b"
|
|
2676
2681
|
},
|
|
2677
2682
|
{
|
|
2678
2683
|
"path": "docs/handoff-template.md",
|
|
@@ -2682,7 +2687,7 @@
|
|
|
2682
2687
|
{
|
|
2683
2688
|
"path": "docs/module-api-changelog.md",
|
|
2684
2689
|
"mode": "0000644",
|
|
2685
|
-
"sha256": "
|
|
2690
|
+
"sha256": "5942f6c9fae093d6fbb85032ca496e5f0549cc5044d8ffa4ecb90c3c9d2695dd"
|
|
2686
2691
|
},
|
|
2687
2692
|
{
|
|
2688
2693
|
"path": "docs/modules-contract.md",
|
|
@@ -7532,12 +7537,12 @@
|
|
|
7532
7537
|
{
|
|
7533
7538
|
"path": "package-lock.json",
|
|
7534
7539
|
"mode": "0000644",
|
|
7535
|
-
"sha256": "
|
|
7540
|
+
"sha256": "e2279fd35b142a93a9037638445d5e6e616c69cd8322d05fad2aa228a5de3515"
|
|
7536
7541
|
},
|
|
7537
7542
|
{
|
|
7538
7543
|
"path": "package.json",
|
|
7539
7544
|
"mode": "0000644",
|
|
7540
|
-
"sha256": "
|
|
7545
|
+
"sha256": "1ba4dccdc1e8d591a783bdac3b0ac4f0d121ad687fef73347ac56e32e69524f7"
|
|
7541
7546
|
},
|
|
7542
7547
|
{
|
|
7543
7548
|
"path": "public-docs/index.html",
|
|
@@ -7977,7 +7982,7 @@
|
|
|
7977
7982
|
{
|
|
7978
7983
|
"path": "scripts/gds/fitness.js",
|
|
7979
7984
|
"mode": "0000644",
|
|
7980
|
-
"sha256": "
|
|
7985
|
+
"sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
|
|
7981
7986
|
},
|
|
7982
7987
|
{
|
|
7983
7988
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -8947,7 +8952,7 @@
|
|
|
8947
8952
|
{
|
|
8948
8953
|
"path": "src/bongos/auth-admission.js",
|
|
8949
8954
|
"mode": "0000644",
|
|
8950
|
-
"sha256": "
|
|
8955
|
+
"sha256": "8dc4cc0ab956d387b8044c22e83fce883f2f0129ed27c03503f7348e9d4f00bb"
|
|
8951
8956
|
},
|
|
8952
8957
|
{
|
|
8953
8958
|
"path": "src/bongos/auth-config.js",
|
|
@@ -9082,7 +9087,7 @@
|
|
|
9082
9087
|
{
|
|
9083
9088
|
"path": "src/bongos/project-door.js",
|
|
9084
9089
|
"mode": "0000644",
|
|
9085
|
-
"sha256": "
|
|
9090
|
+
"sha256": "60a342af5b1d4aaeb781faf5bfea75d69a32b9bba71d0740ccc4ca1a9af2bba4"
|
|
9086
9091
|
},
|
|
9087
9092
|
{
|
|
9088
9093
|
"path": "src/bongos/project-settings.js",
|
|
@@ -9122,7 +9127,7 @@
|
|
|
9122
9127
|
{
|
|
9123
9128
|
"path": "src/bongos/routes/auth.js",
|
|
9124
9129
|
"mode": "0000644",
|
|
9125
|
-
"sha256": "
|
|
9130
|
+
"sha256": "f7e24d83d5fceceb33eacc7d82ee8136a2177bb190e5b912e5857ffc07090009"
|
|
9126
9131
|
},
|
|
9127
9132
|
{
|
|
9128
9133
|
"path": "src/bongos/routes/backup.js",
|
|
@@ -9147,7 +9152,7 @@
|
|
|
9147
9152
|
{
|
|
9148
9153
|
"path": "src/bongos/routes/instance.js",
|
|
9149
9154
|
"mode": "0000644",
|
|
9150
|
-
"sha256": "
|
|
9155
|
+
"sha256": "c3bfdf5f1242fe4bbbb51ea79401dd5ae09cca95adc475e0dc0e5c76055677c4"
|
|
9151
9156
|
},
|
|
9152
9157
|
{
|
|
9153
9158
|
"path": "src/bongos/routes/live.js",
|
|
@@ -9217,7 +9222,7 @@
|
|
|
9217
9222
|
{
|
|
9218
9223
|
"path": "src/branding.js",
|
|
9219
9224
|
"mode": "0000644",
|
|
9220
|
-
"sha256": "
|
|
9225
|
+
"sha256": "2bbb5774c9ae8659b4d5ce9b62cb558d28d036b2aa0075f9329ae85c15734a2d"
|
|
9221
9226
|
},
|
|
9222
9227
|
{
|
|
9223
9228
|
"path": "src/build-info.js",
|
|
@@ -9237,7 +9242,7 @@
|
|
|
9237
9242
|
{
|
|
9238
9243
|
"path": "src/module-api.js",
|
|
9239
9244
|
"mode": "0000644",
|
|
9240
|
-
"sha256": "
|
|
9245
|
+
"sha256": "6c1005562c9950b7cee858382e3cfd643f5c2be27918859de46927c2fbd29c06"
|
|
9241
9246
|
},
|
|
9242
9247
|
{
|
|
9243
9248
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9309,6 +9314,11 @@
|
|
|
9309
9314
|
"mode": "0000644",
|
|
9310
9315
|
"sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
|
|
9311
9316
|
},
|
|
9317
|
+
{
|
|
9318
|
+
"path": "tests/admission_ceiling_gate.mjs",
|
|
9319
|
+
"mode": "0000644",
|
|
9320
|
+
"sha256": "79c216e3ad3d91388a4a48f4ec5f3af9cf440d82791f05a1eb89860573be8dff"
|
|
9321
|
+
},
|
|
9312
9322
|
{
|
|
9313
9323
|
"path": "tests/adopt_detect.mjs",
|
|
9314
9324
|
"mode": "0000644",
|
|
@@ -11017,7 +11027,7 @@
|
|
|
11017
11027
|
{
|
|
11018
11028
|
"path": "tests/joinability_invite_only.mjs",
|
|
11019
11029
|
"mode": "0000644",
|
|
11020
|
-
"sha256": "
|
|
11030
|
+
"sha256": "0c2c540a794fcdafbdf4c496f2dd3efc508edfb9f30f88a6c0f977fc99bb75cc"
|
|
11021
11031
|
},
|
|
11022
11032
|
{
|
|
11023
11033
|
"path": "tests/joinability_open.mjs",
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# ADR 0256 — The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-06
|
|
5
|
+
- **Task:** [task 1003579](https://cloudbongos.com/builders#/task/1003579) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-join-policy`)
|
|
6
|
+
- **Deciders:** Claude, under the Archon's standing scope
|
|
7
|
+
- **Closes:** [ADR 0247](<redacted>.md)'s written *KNOWN LIMIT* — *"the door narrows, the sign-in gate does not"*
|
|
8
|
+
- **Implements:** [ADR 0182](<redacted>.md) D6's **Self-join** column (the half ADR 0247 enforced was the *Applications* column)
|
|
9
|
+
- **Related:** [ADR 0194](<redacted>.md) (the one-reader move this repeats one level up; D2's fail-closed rule) · [ADR 0192](<redacted>.md) §2 (where that fail-closed rule comes from) · [ADR 0050](<redacted>.md) (the legacy `OPEN_XENOS_ENROLLMENT` flag) · [ADR 0190](<redacted>.md) (how a knob reaches the instance)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Context
|
|
14
|
+
|
|
15
|
+
ADR 0182 D6 gave the project plane a two-column table — who may **self-join**, and who
|
|
16
|
+
may **apply** — and one composition rule: *visibility sets the ceiling; join policy may
|
|
17
|
+
only narrow it.* ADR 0247 built that composition (`src/bongos/project-door.js`,
|
|
18
|
+
`projectJoinDoor()`) and wired it to the **applications** column: the public
|
|
19
|
+
`POST /access-requests`, and through it the hall's landing form, the CLI and the hub's
|
|
20
|
+
join relay.
|
|
21
|
+
|
|
22
|
+
It did not wire the **self-join** column, and said so in as many words:
|
|
23
|
+
|
|
24
|
+
> **KNOWN LIMIT — the door narrows, the sign-in gate does not.** `openEnrollmentEnabled()`
|
|
25
|
+
> is still `joinabilityMode() === 'open'` … Closing it means composing the ceiling into
|
|
26
|
+
> `src/bongos/auth-admission.js`, a kernel admission surface this task was scoped out of
|
|
27
|
+
> touching.
|
|
28
|
+
|
|
29
|
+
So an owner who took their project off the map — `visibility: private`, or `stealth`, or
|
|
30
|
+
`joinGrant: view` — while leaving `joinability: open` got two surfaces answering the same
|
|
31
|
+
stranger differently: the application endpoint queued a request for review, and a
|
|
32
|
+
first-time GitHub sign-in handed the same person a `xenos` account with no request and no
|
|
33
|
+
invite. Half of D6's table held only where the owner had never explicitly opened enrolment.
|
|
34
|
+
|
|
35
|
+
The gap's direction was safe — the door was *stricter* than the gate, never looser, so
|
|
36
|
+
nobody was admitted whom `joinability` alone had not already admitted — which is why
|
|
37
|
+
ADR 0247 shipped it as a written limit rather than halting.
|
|
38
|
+
|
|
39
|
+
## Decision 1 — `openEnrollmentEnabled()` reads the composed door, and nothing else moves
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
function openEnrollmentEnabled(deps = {}) {
|
|
43
|
+
return effectiveJoinDoor(deps) === 'open';
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That one expression is the whole behavioural change. It is deliberately the *same* SR-19
|
|
48
|
+
move ADR 0194 D1 made when `joinabilityMode` replaced the raw env flag, one level further
|
|
49
|
+
up: the four sign-in call sites (`auth-github.js` web + device, `auth-idp.js` federated
|
|
50
|
+
twins), `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest's
|
|
51
|
+
`admission.policy` all read this one function, so they follow without being touched. Two
|
|
52
|
+
surfaces that ask the same function cannot answer one stranger differently; two surfaces
|
|
53
|
+
that each read "the relevant knob" always eventually do.
|
|
54
|
+
|
|
55
|
+
**Nothing changes for any existing project.** `src/branding.js` normalises an absent
|
|
56
|
+
`visibility` to `public` and an absent `joinGrant` to `full` (ADR 0247 D1's "the default
|
|
57
|
+
is the value that narrows nothing"), so `public` + `full` puts the ceiling at `open` and
|
|
58
|
+
the composed door is `joinability` verbatim. Every live project is `public`; a self-hosted
|
|
59
|
+
instance carrying the legacy `OPEN_XENOS_ENROLLMENT` flag (ADR 0050) still resolves to
|
|
60
|
+
`open` through the same branch it always did.
|
|
61
|
+
|
|
62
|
+
**The ceiling closes self-enrolment and nothing else.** An invited first-timer, an
|
|
63
|
+
existing builder and the configured `firstAdmin` are all admitted to a `private` or
|
|
64
|
+
`stealth` project exactly as before — `firstTimeAdmissionBlocked`'s other three terms are
|
|
65
|
+
untouched. An owner taking their project off the map is closing the front door, not
|
|
66
|
+
evicting the people they let in, and a `private` project that locked out its own founder
|
|
67
|
+
would be the R62 bootstrap dead-end all over again.
|
|
68
|
+
|
|
69
|
+
## Decision 2 — The manifest's `admission.policy` reports the GATE; `brand.project` keeps reporting the SETTING
|
|
70
|
+
|
|
71
|
+
`GET /instance` carries both, and after this change they can differ: a `private` +
|
|
72
|
+
`joinability: open` project reports `brand.project.joinability: "open"` and
|
|
73
|
+
`admission.policy: "invite-only"`.
|
|
74
|
+
|
|
75
|
+
That is not a contradiction, it is the two questions the manifest has always been
|
|
76
|
+
answering — *what did the owner set?* and *what will the gate do?* — finally giving
|
|
77
|
+
honest answers to both. Before this, `admission.policy` said `open` while the gate would
|
|
78
|
+
have refused, and a CLI reading it to decide whether to offer `bongos login` believed it.
|
|
79
|
+
|
|
80
|
+
**The narrowed value leaks no state.** `admission.policy` is binary and `invite-only` is
|
|
81
|
+
already what every `apply` project reports, so `private`, `stealth`, a `view` grant and a
|
|
82
|
+
plain `invite_only` project remain indistinguishable through it — ADR 0247 D3's rule that
|
|
83
|
+
a refusal may never be read backwards into a state, preserved because the field had only
|
|
84
|
+
two values to begin with. Keeping the read-back honest on `brand.project` is the other
|
|
85
|
+
half of the same rule (ADR 0194 D1: the manage page's *"did my save take effect?"* must
|
|
86
|
+
show the owner's own value, which is why the ceiling is composed on the instance rather
|
|
87
|
+
than folded into what the platform pushes).
|
|
88
|
+
|
|
89
|
+
## Decision 3 — The knob reader moves to the composer, because the alternative is a require cycle
|
|
90
|
+
|
|
91
|
+
`joinabilityMode()` and its `legacyOpenEnrollmentFlag()` helper moved from
|
|
92
|
+
`src/bongos/auth-admission.js` into `src/bongos/project-door.js`. `auth-admission`
|
|
93
|
+
**re-exports** `joinabilityMode`, so `auth.js`, the doorway's `api.joinabilityMode` and
|
|
94
|
+
every existing import keep resolving; the doorway surface is unchanged, so this needs no
|
|
95
|
+
`module.json` floor bump (contrast ADR 0194 D7, which added an export).
|
|
96
|
+
|
|
97
|
+
The reason is mechanical and worth writing down, because the obvious patch does not
|
|
98
|
+
compile past the gate. `project-door.js` resolved its own `joinability` input with a lazy
|
|
99
|
+
`require('./auth-admission')`. The moment the admission gate requires the door back, the
|
|
100
|
+
two files point at each other — and `fitness-ratchets.js` counts require cycles from the
|
|
101
|
+
**source text**, so a lazy require inside a function body is still an edge. The
|
|
102
|
+
`require_cycle_count` ratchet is shrink-only at 4; adding a fifth is a hard build failure,
|
|
103
|
+
and it would be right to fail: a cycle here is a real one, not a measurement artifact.
|
|
104
|
+
|
|
105
|
+
Three ways out were considered:
|
|
106
|
+
|
|
107
|
+
1. **Duplicate the composition in the gate.** Rejected on sight — it is the exact drift
|
|
108
|
+
SR-19, ADR 0194 and ADR 0247 were each written to prevent, and the ratchet would only
|
|
109
|
+
be measuring that we had hidden it.
|
|
110
|
+
2. **Make `joinabilityMode` a required argument of `effectiveJoinDoor`, supplied by the
|
|
111
|
+
doorway.** Zero new files and no cycle, but it turns `effectiveJoinDoor()` — the
|
|
112
|
+
no-argument call ADR 0247 D2 published as *the* one call a door surface makes — into a
|
|
113
|
+
footgun that silently answers `invite_only` for any future caller who forgets.
|
|
114
|
+
3. **Move the reader to the composer** (chosen). The file whose header already says it is
|
|
115
|
+
*"composed from every knob that can close it"* now owns the readers for all of them.
|
|
116
|
+
The dependency runs one way — gate → door → branding/env — `effectiveJoinDoor()` keeps
|
|
117
|
+
working with no arguments, and one lazy cross-require disappears rather than a second
|
|
118
|
+
appearing.
|
|
119
|
+
|
|
120
|
+
`joinabilityMode` was only ever in the admission file because the admission gate was its
|
|
121
|
+
first caller (ADR 0194); the layering was accidental, and this task is what surfaced it.
|
|
122
|
+
Its `[auth]` log prefix is kept verbatim across the move — operators grep sign-in refusals
|
|
123
|
+
by it, and the child logger's `src` field already says which file emitted the line.
|
|
124
|
+
|
|
125
|
+
## Decision 4 — `locked` still cannot fire from the sign-in gate
|
|
126
|
+
|
|
127
|
+
`openEnrollmentEnabled` passes **no** `publishable` verdict, so the tri-state stays `null`
|
|
128
|
+
— *no verdict* — and the door is answered on the three stored knobs alone. ADR 0247 D4's
|
|
129
|
+
finding has not changed: neither plane holds a live, correct dark-matter verdict, the
|
|
130
|
+
instance's branding pack carries no `description`, and the hub's cached copy says every
|
|
131
|
+
live project is dark matter. A gate that read that column would refuse every first sign-in
|
|
132
|
+
on the platform. The `deps` object is forwarded verbatim, so wiring a verdict later is one
|
|
133
|
+
argument at one call site, not a redesign.
|
|
134
|
+
|
|
135
|
+
## Decision 5 — The sign-in refusal page tests the door, not the knob
|
|
136
|
+
|
|
137
|
+
`routes/auth.js` chose between its two refusal pages on
|
|
138
|
+
`joinabilityMode() === 'invite_only'`, which was equivalent to the door only while the
|
|
139
|
+
knob *was* the door. Under Decision 1 the ceiling can close a project whose `joinability`
|
|
140
|
+
still reads `apply` — `stealth`, or a `view` grant — and that page would have handed
|
|
141
|
+
the stranger a "request access at the hall" form the application endpoint 403s. The test
|
|
142
|
+
becomes `effectiveJoinDoor() !== 'apply'`: `apply` is the only door that accepts an
|
|
143
|
+
application, so everything else lands on the invite-only page.
|
|
144
|
+
|
|
145
|
+
**Both closed doors show the same sentence, and that is the point.** Distinguishing them
|
|
146
|
+
would be the state read-out ADR 0247 D3 forbids; showing one honest "this project takes no
|
|
147
|
+
applications, an invite is the way in" for all of them is the same indistinguishability
|
|
148
|
+
the application endpoint already has. `open` cannot reach this branch (the gate would have
|
|
149
|
+
admitted) and `locked` cannot fire at all (D4), so in practice it is `invite_only`.
|
|
150
|
+
|
|
151
|
+
## What was deliberately not built
|
|
152
|
+
|
|
153
|
+
- **No new refusal COPY.** The two existing pages are reused verbatim; only which one is
|
|
154
|
+
chosen changed (Decision 5). Saying *"this project is private"* would be the state
|
|
155
|
+
read-out ADR 0247 D3 forbids, and a sentence written for this case alone would make the
|
|
156
|
+
refusals distinguishable — the same leak by another route.
|
|
157
|
+
- **The hall landing's door copy is left on the raw knob — a NAMED limit, in the same
|
|
158
|
+
shape ADR 0247 named this one.** `modules/hall-ui/public/hall-render.js` renders one
|
|
159
|
+
landing per `window.__BRANDING__.project.joinability`, and its own comment already calls
|
|
160
|
+
that *"cosmetic — the server refuses on its own terms either way"*. After this change
|
|
161
|
+
a `private` + `open` project's landing still reads "sign in and you're in" while the gate
|
|
162
|
+
refuses, so the visitor meets the truth one click later instead of on the page. Fixing it
|
|
163
|
+
properly means the server injecting the COMPOSED door into the pack the browser reads
|
|
164
|
+
— re-deriving the composition in browser JS is the drift this whole line of ADRs
|
|
165
|
+
exists to prevent — which is a public-field addition, not a side effect of this one.
|
|
166
|
+
It is reachable only on a project whose owner has taken it off the map, and every live
|
|
167
|
+
project is `public` (ADR 0247's own Consequences).
|
|
168
|
+
- **No `webAdmissionStatus` special case.** It reads `openEnrollmentEnabled()` like the
|
|
169
|
+
gate does, and `tests/web_admission_status.mjs` already sweeps the input space asserting
|
|
170
|
+
the probe and the gate agree. The composition arrives there for free; a second code path
|
|
171
|
+
would be a second thing to keep in sync.
|
|
172
|
+
- **No manage-page control for the grant.** Still a rendering task, unchanged from
|
|
173
|
+
ADR 0247's "what was deliberately not built".
|
|
174
|
+
|
|
175
|
+
## Consequences
|
|
176
|
+
|
|
177
|
+
- ADR 0182 D6's table is enforced in **both** columns, from one function, on every surface
|
|
178
|
+
that admits or refuses a stranger.
|
|
179
|
+
- `tests/admission_ceiling_gate.mjs` boots a real instance on `visibility: private` +
|
|
180
|
+
`joinability: open` — the exact pair ADR 0247's limit paragraph named — through the
|
|
181
|
+
production channel (`<PREFIX>_VISIBILITY` / `<PREFIX>_JOINABILITY` in the environment,
|
|
182
|
+
read once through the memoized pack, as the provisioning runner writes them into
|
|
183
|
+
`web.env`). It then sweeps all 75 knob combinations asserting `openEnrollmentEnabled()`
|
|
184
|
+
equals *"the composed door is `open`"*, and that **exactly one** combination —
|
|
185
|
+
`public` / `full` / `open` — self-enrols.
|
|
186
|
+
- `src/bongos/project-door.js` no longer imports `src/bongos/auth-admission.js`; the
|
|
187
|
+
`KERNEL_FILES` note in `fitness.js` that described that import is updated with it.
|
|
188
|
+
`require_cycle_count` stays at 4.
|
|
189
|
+
- A surface that genuinely wants the owner's *setting* rather than the *door* — the
|
|
190
|
+
manifest read-back, the hall's door copy — still calls `joinabilityMode()`. The
|
|
191
|
+
distinction is now written on the function itself, because "read the knob" and "decide
|
|
192
|
+
admission" being the same call is what produced this bug.
|
|
193
|
+
|
|
194
|
+
## Alternatives rejected
|
|
195
|
+
|
|
196
|
+
- **Composing the ceiling into the `joinability` value the platform pushes**, so the
|
|
197
|
+
instance needs no second reader. Rejected here for the same reasons ADR 0247 rejected
|
|
198
|
+
it: stored and pushed values would disagree, the manage page's read-back would break,
|
|
199
|
+
and it cannot express `locked`. This task is additional evidence for that call — the
|
|
200
|
+
read-back is exactly what `tests/admission_ceiling_gate.mjs` now pins.
|
|
201
|
+
- **Leaving the gap and documenting it harder.** It was already documented; a written
|
|
202
|
+
limit that survives two releases is a bug with good manners. The direction being safe
|
|
203
|
+
bought the deferral once, not indefinitely — and the owner-facing statement *"my project
|
|
204
|
+
is private"* is false while any stranger can still sign themselves in.
|
package/docs/adr/README.md
CHANGED
|
@@ -340,6 +340,7 @@ This keeps the decision history honest and traceable.
|
|
|
340
340
|
| 0250 | [**Strict versioning — the version boundary is the scope gate** ([task 1003589](https://cloudbongos.com/builders#/task/1003589) · goal 1000086). The work hierarchy ([ADR 0086](<redacted>.md)) has a bottom and no top: tasks close, criteria close, goals close, and a version never does. Measured live — **14 goals reading `achieved`/`archived` while holding 35 unfinished tasks**, 523 open tasks across 37 open goals, and **two versions `building` on one project**; every number was worse than the planning session had recorded two days earlier, which is the argument: the drift is the steady state, not a backlog. The cause is four mechanical gaps, not a social one — `POST /goals` fetches the version row and never reads its `status`; goal achievement is computed from criteria alone and never consults `tasks` (both the `db-goals.js` writer and the `done-when.js` cascade); `POST /goals/:id/archive` checks nothing at all; and no route ships a version, so version status has only ever moved as hand-run SQL on the droplet. **Decision, five rules:** the goal set closes when a version starts `building` (new goals go on the `planning` version — where the next version is scoped in advance) with a named 409 an agent can act on; a goal reaches `achieved` only when its criteria are satisfied **and** no task in it is non-terminal, where terminal is `shipped`|`abandoned` per [task 1003588](https://cloudbongos.com/builders#/task/1003588)'s one vocabulary and the mid-ship `completed`/`confirmed` hold it open; archiving a goal that holds open tasks refuses until the caller dispositions each (abandon with the reason, or move to a named surviving goal); `goal_id` becomes required and the per-version catch-all fallback is deleted; and exactly one version builds per project, auto-closing when its last non-maintenance goal achieves, with the single `planning` version promoting itself so the project is never without a live train. **The escape hatches are deliberate and visible**: an Archon may admit one *named* goal to a building version with a reason, and the overrides are counted on the version (the standard failure of a hard scope gate is a fake hotfix version cut to hold what did not fit — an untraced override is just a slower version of no rule); plus one auto-created maintenance goal per version, exempt from the auto-close count, so bugs never need an override to be filed. **Nothing here can refuse a ship, a claim, or a task** — every rule acts on a *closing* move. Rejected: counting `completed`/`confirmed` as finished (they can fail a grade or strand — the goals **board** buckets them as done, but that is a progress ring, and `task-status.js` keeps the two questions apart); a nightly reconciler that reopens wrongly-closed goals (detection is what you build when prevention is unavailable, and it is available here); a human review gate before a goal may close (the queue [ADR 0183](<redacted>.md) dismantled — the check is a fact the table already holds); concurrent building versions scoped by module (two live trains means every "what is in this version" question has two answers); retiring versions entirely (the version is the unit the owner sets scope in and `limitations/<version>-shipped.md` archives). **Amends [ADR 0157](<redacted>.md) for exactly one key:** `version.create` returns to the Archon floor. 0157's "authoring delegates, disposition does not" was right about what a version *was* — but once the boundary carries the scope gate, cutting a new version is no longer authoring, it is the way *around* the gate, so create joins close as a disposition over scope.](<redacted>.md) | lifecycle / versioning / scope |
|
|
341
341
|
| 0251 | [**A suggestion of who to invite is a recruiting surface, and owes the recruiting opt-out** ([task 1002347](https://cloudbongos.com/builders#/task/1002347) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R28 fills the seam [ADR 0239](<redacted>.md) §6 left open: the wizard’s pure rule engine emits a **seek** (`solo` → `null`, `small-team` → 3, `community` → 5) and never a list, because ranking builders is a directory read a pure file may not run and `provisioning` may not import a sibling. Two readers could answer “who are the top builders” and they differ by **one SQL predicate**: `economy`’s `leaderboard()` joins `rollupVisibleSql` and deliberately honours **no** recruiting opt-out, while `platform-identity`’s `listScoutingRollup()` joins `scoutingListableSql`, which carries it. The leaderboard is cheaper in every mechanical sense — already a public route, no port, callable straight from the browser. **Decision: use the recruiting directory anyway**, because `account-visibility.js` says the split in its own words (`hide_stats` suppresses *numbers* everywhere; the recruiting opt-out “removes an account from the RECRUITING SURFACE only”, community spec D3), and a list of people the platform proposes you recruit **is** that surface — sourcing it from the leaderboard would route around the one control built for this exact case and name every builder who asked not to be recruited. The rule for later readers: **the cheapness of a public read is not an argument about who consented to appear in it.** The lookup ships as `listInviteSuggestions` on the existing `platform-identity.hub` port, reached with `resolveOptional` and NOT declared in `consumes` (the `catalog-bridge.js` precedent — platform-identity is hub-only, so its absence is “no suggestions”, never a 500); `github_id` **does not cross the port** (explicit whitelist — login, display name, avatar, disciplines, two counts — so a new rollup column cannot leak by default), and the owner-exclusion is applied inside, over-read by the exclusion count so filtering never returns short. The route `GET /provisioning/instances/:id/invite-suggestions` is own-scoped by the **existing** `instanceAccessDenied` (same 404 as the manage read) and deliberately does NOT reuse `/scouting`’s gate ([ADR 0210](<redacted>.md)): a brand-new owner has no hub-witnessed owner row at the moment the done panel paints, so the correct-looking gate would refuse the exact caller the feature exists for — and reaching the route IS the ownership answer ([ADR 0249](<redacted>.md) §4). On the surface: a **second door in** (`setInviteSuggestions`) beside `setInviteSlug`, so the widget still answers neither “signed in” nor “theirs”; the reason is **always** said including when the answer is nobody (`seeking: null` costs no query — rendering a stated answer as an empty list would make it look like a failed lookup); a picked name fills the field and takes the **same write path** as a typed one, so the refusal vocabulary cannot fork; a filed invite stops being a suggestion; and every failure is silent, because a suggestion is an extra and a just-created project must not look broken over one. Rejected: the browser reading the economy leaderboard (the whole decision — consent, not architecture); reusing `/scouting` (right data, gate that refuses this caller); filling `invites.suggested` inside the engine (I/O in a pure file + a sibling import); a `provisioning → economy` port (legal, still the wrong directory — the boundary was never the objection).](<redacted>.md) | provisioning / platform-identity / privacy |
|
|
342
342
|
| 0252 | [**The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever** ([task 1002321](https://cloudbongos.com/builders#/task/1002321) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R02 — a spike whose output is this decision; gates [task 1002327](https://cloudbongos.com/builders#/task/1002327) / R08). **The premise failed first:** a self-hosted instance has no channel to the hub and no credential to open one — `registerClient` has exactly one caller in the tree (the operator's own `provision-net.js`), gated `hosting_shape !== 'co-tenant'`, and no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant* and *self-hosted* holds nothing, deliberately: [ADR 0145](<redacted>.md) says do NOT extend federation to `standalone`, and [ADR 0144](<redacted>.md)'s Known Limitation rests on "every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients." [ADR 0182](<redacted>.md) D5's "accepts a report from the authenticated `sso_client`" described a client that, for this population, does not exist. **Decision 1 — the channel is a PULL of the instance's already-public manifest** (`GET <origin>/api/gds/instance` → `branding.project.visibility`, a value every core already serves and whose author anticipated this consumer). It buys five things a push must build: no credential (0144's tripwire untouched — the whole reason), **no replay surface** (a pull reads current state, so the `jti` machinery D5 handed R02 is moot — replay is answered by not having a message), no wire change on another instance's upgrade clock ([ADR 0205](<redacted>.md)'s and [ADR 0201](<redacted>.md) D2.2's rejected-required-field problem simply absent), the hub still authorising nothing (0182 D5 satisfied, [ADR 0238](<redacted>.md)'s rejected `PATCH /projects/:id/visibility` still rejected), and a fetch that already exists behind a real SSRF guard (`project-stats.js` — DNS-resolve-all, `redirect: 'error'`, TTL + negative caches). The pull carries `visibility` and NOTHING else, so the property the map has today — a project owner controls no free-text field that reaches the feed — is not spent. **Decision 2 (the Archon's) — auto-appear:** no human stands between a public project and the map; `featured` keeps only the ranking job [ADR 0182](<redacted>.md) D3 left it. Conditional on Decision 4. **Decision 3 (the Archon's) — trust follows provenance:** both populations auto-appear, and the asymmetry is in the GUARDS, not a gate — a control-plane row's `visibility` is *assigned* by the bridge from an own-scoped audited route (authoritative), a self-hosted row's is *pulled* from a manifest the hub does not control (a cached claim with a recency stamp, able to move its own row and nothing else). An instance is authoritative about its own door and about nothing else. **Absent is a THIRD state and must not purge:** `null` (never read) ≠ `false` (read, negative). 0182 D5's fail-closed default holds only for a self-hosted row that has NEVER answered; a row that answered before and is unreachable now keeps its last state with a recency stamp ([ADR 0199](<redacted>.md), [ADR 0226](<redacted>.md)), because [ADR 0247](<redacted>.md) D4 found the live shape of that trap — every catalog row at `description: null`, so naive fail-closed would have emptied the map at once. Narrowing is free; widening is what costs. **Decision 4 — auto-appear buys a retraction lever, in the same task** ([ADR 0047](<redacted>.md): the straight-through path and its abuse guard are one task, treated as binding): `platform_identity_projects.status` is `(active,hidden)` and **no route writes it** — takedown is hand SQL today, and a one-way door onto a public surface is not a door. R08 ships `PATCH /projects/:id/status` (curate floor, audited), a per-IP limit on the unmetered CORS-open feed read ([ADR 0209](<redacted>.md)), and above all **`hidden` STICKY against the pull** — a retracted project that keeps reporting `public` stays hidden, or the next sweep silently undoes every takedown and the lever is decorative. Also named: `hall_origin` reaches the feed from the remote's own discovery index checked only for *differing* from the project host — the opposite of containment — and gets `shipUrlWithinClientOrigin`'s downward-only rule, filed against R08. Rejected: a pushed credentialed report (the literal R08 — needs the credentials 0144/0145 forbid, plus replay, rate and a wire-compatible field, none of which the pull has); `PATCH /projects/:id/visibility` (0238 already); a one-time admission and ongoing curation (both offered to the Archon, both declined); trusting the pull to move `status`; enforcing "unreported ⇒ dark matter" across all rows; letting the pull carry name/tagline/art. The later ANALYTICS feed is deliberately NOT decided here — it carries per-person data, exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report, and it needs the 0144 credential work first.](<redacted>.md) | platform-identity / provisioning / federation trust |
|
|
343
|
+
| 0256 | [**The visibility ceiling binds the SIGN-IN gate too, and the knob reader moves to the composer** ([task 1003579](https://cloudbongos.com/builders#/task/1003579) · goal 1000046, criterion `proj-join-policy`). Closes the *KNOWN LIMIT* [ADR 0247](<redacted>.md) wrote down: it composed `visibility × joinGrant × joinability` into one door and enforced it at the public application endpoint, leaving `openEnrollmentEnabled()` reading the `joinability` knob alone — so a project set `private` (or `stealth`, or `joinGrant: view`) while `joinability` stayed `open` **queued applications for review and still let any first-time GitHub account sign itself in at xenos**. Half of [ADR 0182](<redacted>.md) D6’s table — its whole *Self-join* column — was unenforced wherever the owner had explicitly opened enrolment. **Decision 1:** `openEnrollmentEnabled()` becomes `effectiveJoinDoor(deps) === 'open'`, the same SR-19 move [ADR 0194](<redacted>.md) D1 made one level down, so the four sign-in call sites, `firstTimeAdmissionBlocked`, `webAdmissionStatus` and the manifest all follow untouched — and **nothing changes for any existing project**, because `branding.js` normalises an absent visibility to `public` and an absent grant to `full`, the pair that narrows nothing. The ceiling closes **self-enrolment only**: an invited first-timer, an existing builder and the configured `firstAdmin` still get in, or a private project locks out its own founder. **Decision 2:** the manifest’s `admission.policy` now reports the GATE while `brand.project` keeps reporting the SETTING — they may differ, because they answer different questions, and `invite-only` is already what every `apply` project reports, so narrowing into it names no state ([ADR 0247](<redacted>.md) D3). **Decision 3 — the mechanical one:** `joinabilityMode()` moves from `src/bongos/auth-admission.js` into `src/bongos/project-door.js` (re-exported, so no import path and no doorway export changes). The door resolved its own joinability input with a lazy `require('./auth-admission')`; the moment the gate requires the door back the two files point at each other, and the `require_cycle_count` ratchet counts edges from **source text**, so laziness is no escape. Rejected: duplicating the composition in the gate (the drift SR-19 exists to prevent), and making `joinabilityMode` a required argument of `effectiveJoinDoor` (turns the published no-argument call into a footgun that silently answers `invite_only`). The reader was only in the admission file because the gate was its first caller; the composer owns its inputs. **Decision 4:** the gate passes no `publishable` verdict, so `locked` still cannot fire from it — [ADR 0247](<redacted>.md) D4’s finding stands, and a gate reading the hub’s cached column would refuse every first sign-in on the platform. Also rejected: refusal copy that names the private state (the read-out D3 forbids, and a case-specific sentence makes the refusals distinguishable by another route); a `webAdmissionStatus` special case (it reads the same function, and its anti-drift sweep already pins the agreement). Proof: `tests/admission_ceiling_gate.mjs` boots a real instance on `private` + `open` through the production env channel, then sweeps all 75 knob combinations asserting the gate **is** the door and that exactly one combination — `public`/`full`/`open` — self-enrols.](<redacted>.md) | admission / project visibility / kernel layering |
|
|
343
344
|
| 0253 | [**The takedown lever has two writers, and provenance has to be a column** ([task 1002327](https://cloudbongos.com/builders#/task/1002327) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R08 — the build). Amends [ADR 0252](<redacted>.md) §4/§5: three premises that did not survive contact with the tree. The decisions stand; what changes is what has to be BUILT. **(1) `hidden` is sticky against TWO writers, and 0252 names the wrong one.** 0252 §5 says "no route writes [`status`]" and defends the takedown against the PULL. But `project-catalog.js:upsertOwnedProject` writes `status = EXCLUDED.status` on every conflict, sourced from `catalog-bridge.js:catalogStatusForInstance` — which returns `active` for any instance not torn down — and [ADR 0246](<redacted>.md) re-takes that projection at EVERY provisioning write. So the live failure is: curator hides a project → its owner saves any setting → it is back on the map. Stickiness therefore belongs to the ROW (`status_pinned`, set by the curated PATCH, honoured by the projection), not to one writer's manners; the pull separately writes no `status` at all, asserted against the statement text. `retireOwnedProject` deliberately does NOT honour the pin — it is a safety retraction for an origin the project no longer answers on ([ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2), and narrowing is always free. **(2) Provenance must be a stored column.** 0252 §4 tabulates control-plane vs self-hosted rows as though the catalog knew which was which; it has no `hosting_shape` (that lives on `provisioning_instances`, in a module platform-identity may not import — [ADR 0083](<redacted>.md)) and `owner_github_id` separates nothing (set by the bridge, set by `registerClient`, null on hand-seeded rows). `visibility_source` (`control-plane`|`pull`|NULL) records it on the row, re-asserted in the UPDATE's own WHERE so a mid-sweep projection cannot lose the race — making "the two never write the same row" an invariant the SQL enforces. Rejected a `provisioning.instanceShape` port: a cross-module read in a background loop, for a value that never changes per row. **(3) The tri-state cannot live in `visibility`** (`NOT NULL DEFAULT 'public'`), so the recency stamp IS the null-carrier: `visibility_reported_at IS NULL` is the never-read state, and a failed read writes nothing — which is what makes "keep the last known state" fall out rather than be implemented. The fail-closed feed predicate needed a BACKFILL for [ADR 0247](<redacted>.md) D4's exact reason: both columns are new, so unbackfilled it would have emptied the public map on deploy. Migration 020 stamps every existing row `control-plane`, which is safe because it is TRUE — no instance has ever been asked. Two smaller corrections: the manifest key is **`brand`**, not `branding` (0252 §2 and the task body both misname it; reading only the documented spelling makes every pull a silent no-verdict), and the pull is a **POLLER, not a passenger on the feed's fan-out** — the feed excludes exactly the rows that need pulling (a never-answered row is dark matter, so it is never enriched, so it never answers), stealth rows are excluded from enrichment by design, and a read-path pull would make a DB write a side effect of an anonymous CORS-open read. Honest limit recorded: 0247 D4's `description: null` problem is NOT fixed here — 0252 §7 refuses to let the pull carry name/tagline/description, so the publish gate stays unmet for self-hosted rows. The pull moves the door; it does not publish the project.](<redacted>.md) | platform-identity / provisioning / abuse guard |
|
|
344
345
|
| 0255 | [**A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing** ([task 1003561](https://cloudbongos.com/builders#/task/1003561) · goal 1000046, which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)). The public projects feed redacts a stealth row down to an allow-list of `state` + `orb` and carries **no stable per-project key on purpose** ([ADR 0182](<redacted>.md) D2a) — then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer one anyway. **Ordering is a covert column:** a position is a comparison result, and enough comparisons reconstruct the key. Three consequences, the third load-bearing — `updated_at` was published despite being deliberately withheld from the projection (it lies as a public "last updated"); a black hole sat BETWEEN NAMED NEIGHBOURS whose order is visible, bracketing its timestamp between two identifiable rows; and it **MOVED when its hidden project was touched**, which is an activity oracle on a public, CORS-open, unauthenticated route, and stable enough between touches to follow one black hole across snapshots. **Decision: a row may be ordered only by what it publishes.** An identified row publishes `created_at` + `origin`, so it sorts by those under `featured` (D3's remaining ranking job) — an order that is a PURE FUNCTION OF THE WIRE PAYLOAD, reproducible by a reader holding only the response. An anonymous row publishes nothing, so it is ordered by nothing about itself: the black holes collate into ONE TRAILING BLOCK permuted by a keyed hash. Four sort terms; the first partitions the blocks so no later term compares across them, and **terms 2-4 are GUARDED** — the "anonymous rows go last" half without the "and are blanked" half is not a fix, since the trailing block would simply re-sort by `created_at`, which narrows *which* project it is for anyone who watched the catalog fill. The identified set is DERIVED from `ORB_BY_STATE` (never re-typed, so SQL and `feedShapeFor()` cannot drift) and is an ALLOW-list, so an unknown fourth enum value sorts anonymous as well as drawing anonymous. The permutation salt is **secret** (origins are guessable domain names — a predictable salt lets an observer hash candidates and match ranks) and **rotates on a 24h epoch** (a permanently stable permutation still makes POSITION a durable handle); random per process, since nothing persists or reads it back. **Pagination is the same decision, not a neighbouring one** — task 1003560's premise was already stale (`<redacted> = 500` shipped with the feed; its own comment called the cap "the cheap half" and pagination "its own task"), and the surviving half edits this exact statement, so it merged in: `?limit=&offset=` per [ADR 0119](0119-api-pagination-contract.md), **no cursor** because the obvious encoding is the last row's timestamp — the very channel this closes — and the **default page IS the maximum (500)** so a static orbs site in ANOTHER REPO is byte-for-byte unchanged, with `page` added beside `projects` rather than wrapping it. Paging cannot aim more fan-out than one request already could: `?limit=` only shrinks, and `?offset=` is bounded by `project-stats.js`'s 10-min per-origin cache, not the request count ([ADR 0252](<redacted>.md) §5.3's per-IP limiter unchanged). Rejected: sorting everything by `created_at` (positions a black hole by a date its row refuses to carry); alphabetical `origin` order for the block (an oracle against a candidate set); `ORDER BY random()` (breaks paging — a row could appear twice or not at all); coarse `updated_at` buckets (a sort key that must be blurred should not be there). **Honest limits, stated rather than implied fixed:** existence and count stay public by design; **the identified set still CHANGES when a project goes stealth**, a LARGER channel than this one and untouched here; and position is stable within one epoch.](<redacted>.md) | platform-identity / privacy boundary / public API |
|
|
345
346
|
| 0254 | [**A relayed link is contained downward, or it is not published** ([task 1003670](https://cloudbongos.com/builders#/task/1003670) · goal 1000046, criterion `proj-visibility-states` / C1) — the gap [ADR 0252](<redacted>.md) §5 named and filed. **The defect:** `hall_origin` reaches the public projects feed from the REMOTE instance’s own discovery index (`GET <origin>/api/bongos` → `index.hall`, `project-stats.js`) and the hub renders it as an `<a href>` under that project’s name — checked only for **differing** from the project’s site host, which is containment turned inside out: it admits every host on the internet and refuses the one that is certainly safe. `{"hall": "https://evil.example/builders"}` was published on the hub’s own domain, attached to the victim. The rule answered a DIFFERENT question correctly ([task 1003257](https://cloudbongos.com/builders#/task/1003257), owner decision (a): *a door only where it opens somewhere else*); nobody had asked it the security one. It stopped being survivable when [ADR 0252](<redacted>.md) §3 replaced “whom the operator provisioned” with “whoever points a domain at the hub”. **Decision:** the same **downward-only** rule a pushed ship link already obeys — a relayed URL must point AT OR BELOW the origin that reported it (own host or a **dot-anchored** subdomain; `http(s)` only; no embedded credentials; **refused whole at the length cap, never truncated**, because slicing a URL changes where it points). Containment runs FIRST and compares **hostnames** (a port is not a trust boundary); the unchanged “different door” test runs second and compares **hosts with the port** (a hall on another port really does open elsewhere). Net: a hall link survives only as a strict subdomain — or another port — of the project’s own origin. **One home:** `shipUrlWithinClientOrigin` (a private helper in `platform-identity.js`) is lifted to `origin-containment.js:urlWithinOrigin` and required by BOTH callers — the defect WAS two surfaces answering one question differently, and leaving the answer where only one could reach it is what let them differ. Its own file for the reason its siblings give: `platform-identity.js` is AT the 1500-line ratchet (the lift shrinks it by 11), and deliberately NOT re-exported there — a re-export is a third name for one answer. **Downward only, and the cost stated:** the mirror rule looks symmetric and is not (on a shared apex it lets `foo.github.io` publish links on bare `github.io`, and vouch for every neighbour), so a project whose hall is a SIBLING of its site (site `www.example.com`, hall `builders.example.com`) loses the glyph — the hall stays reachable, the hub stops vouching for the address. [ADR 0253](<redacted>.md) §2’s asymmetry again: **narrowing is free; widening is what costs**. One guard added on the way in: a hostless origin (`file:`, `data:` — a stored value is not a validated one) is refused outright, because the old suffix test would have matched a legal trailing-dot FQDN like `evil.example.` against the bare `.` it leaves behind. **Rejected:** mirroring the check in the browser ([ADR 0182](<redacted>.md) D2a — the page is not the gate, and a second copy of a containment rule is the drift this ends); a per-project allow-list of hall hosts; copying the rule into `project-stats.js` (the cheapest diff, and it re-creates the cause); truncating at the cap (fine for an `<img src>` display bound, wrong for a containment decision); folding both tests into one hostname comparison (it would silently drop a legitimate same-host, different-port hall).](<redacted>.md) | platform-identity / public feed / link containment |
|
package/docs/file-map.md
CHANGED
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
│ │ ├── secret-box.js / sensitive-surfaces.js ← at-rest secret encryption + the sensitive-file classifier
|
|
246
246
|
│ │ ├── cascade-dispatch.js ← (dormant System 3, ADR 0079) the model-tier escalation ladder for autonomous dispatch
|
|
247
247
|
│ │ ├── goal-scope-check.js / hierarchy-config.js / module-scope-map.js ← goal-scope protected-path checks, the configurable work-hierarchy tiers, and the module→file-glob map
|
|
248
|
-
│ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR
|
|
248
|
+
│ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0247). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently — and since task 1003579 (ADR 0256) the SIGN-IN gate reads it too (openEnrollmentEnabled), which is why joinabilityMode() lives here rather than in auth-admission.js
|
|
249
249
|
│ │ ├── path-match.js / repo-info.js / metrics.js / uptime-poller.js / app-pair.js ← touches[] overlap matcher, git-remote parser, Prometheus metrics endpoint, uptime poller, Claude-Desktop pairing store
|
|
250
250
|
│ └── world/
|
|
251
251
|
│ ├── terrain.js ← (Phase D + ADR 0012, CJS): TERRAIN + OBJECT enums + BLOCKING_TERRAINS + BLOCKING_OBJECTS (server twin of public/game/world/terrain.js)
|
|
@@ -1585,5 +1585,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1585
1585
|
landed since 1.19.566 with no explicit bump. run 34057551680. (task 1002620)
|
|
1586
1586
|
1.19.568 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1587
1587
|
landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
|
|
1588
|
+
1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1589
|
+
landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
|
|
1588
1590
|
---------------------------------------------------------------------------
|
|
1589
1591
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.569",
|
|
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.569",
|
|
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.569",
|
|
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",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -534,9 +534,11 @@ const KERNEL_FILES = [
|
|
|
534
534
|
// route-rank-check.js and goal-scope-check.js above (a trust decision the doorway
|
|
535
535
|
// hands to modules so no module re-derives it). The doorway re-exports it, so
|
|
536
536
|
// leaving it off this roster reads as a kernel->non-kernel leak; it imports only
|
|
537
|
-
// branding.js and
|
|
538
|
-
// nothing and buys the import-direction guarantee on the file that
|
|
539
|
-
// may knock.
|
|
537
|
+
// branding.js, instance-config.js and logger.js — all on this roster — so
|
|
538
|
+
// joining costs nothing and buys the import-direction guarantee on the file that
|
|
539
|
+
// decides who may knock. (Task 1003579 removed its require of auth-admission.js
|
|
540
|
+
// and moved joinabilityMode the other way: the gate now reads the composed door,
|
|
541
|
+
// so the reader had to sit with the composer or the two files would have cycled.)
|
|
540
542
|
'src/bongos/project-door.js',
|
|
541
543
|
];
|
|
542
544
|
|
|
@@ -29,6 +29,10 @@ const seams = require('../module-seams');
|
|
|
29
29
|
const ic = require('../instance-config');
|
|
30
30
|
const { branding, userAgent } = require('../branding');
|
|
31
31
|
const { loadIdpConfig } = require('./auth-config.js');
|
|
32
|
+
// task 1003579: the sign-in gate reads the project's COMPOSED join door, not the
|
|
33
|
+
// single `joinability` knob it used to. joinabilityMode moved into that file with
|
|
34
|
+
// the two knobs it composes against and is re-exported below, unchanged.
|
|
35
|
+
const { effectiveJoinDoor, joinabilityMode } = require('./project-door.js');
|
|
32
36
|
// task 1003208: structured logging — was console.*. A core file logs through
|
|
33
37
|
// the kernel logger directly; only MODULES go via api.logger (the doorway).
|
|
34
38
|
const log = require('./logger').logger.child({ src: 'auth-admission' });
|
|
@@ -201,57 +205,41 @@ async function reportActivityRollupForShippedTask(task) {
|
|
|
201
205
|
// already exists for their github_id) ALWAYS passes — the gate never locks out
|
|
202
206
|
// someone already enrolled.
|
|
203
207
|
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
function legacyOpenEnrollmentFlag() {
|
|
221
|
-
const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
|
|
222
|
-
return v === '1' || v === 'true';
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// joinabilityMode — the project's effective membership door policy: 'open' |
|
|
226
|
-
// 'apply' | 'invite_only'. The one reader every admission surface uses (both
|
|
227
|
-
// sign-in flows, the public access-request route via the doorway, the manifest,
|
|
228
|
-
// the refusal pages), so none of them can disagree about which door this is.
|
|
229
|
-
// `read` is injectable for the unit tests only (the pack is memoized).
|
|
230
|
-
let warnedUnknownJoinability = false;
|
|
231
|
-
function joinabilityMode(read = branding) {
|
|
232
|
-
let v;
|
|
233
|
-
try { v = read().project?.joinability; } catch (err) {
|
|
234
|
-
log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
|
|
235
|
-
return 'invite_only';
|
|
236
|
-
}
|
|
237
|
-
if (v === 'open' || v === 'invite_only') return v;
|
|
238
|
-
if (v === undefined || v === null || v === '' || v === 'apply') {
|
|
239
|
-
return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
|
|
240
|
-
}
|
|
241
|
-
// A value this core cannot read ('invite-only' with a hyphen, a truncated
|
|
242
|
-
// write) is a policy it does not know — never the wider door.
|
|
243
|
-
if (!warnedUnknownJoinability) {
|
|
244
|
-
warnedUnknownJoinability = true;
|
|
245
|
-
log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
|
|
246
|
-
}
|
|
247
|
-
return 'invite_only';
|
|
248
|
-
}
|
|
208
|
+
// WHICH of those a first-timer gets is the project's JOIN DOOR — and the door is
|
|
209
|
+
// three owner-set knobs composed into one answer, not the `joinability` knob
|
|
210
|
+
// alone (visibility × joinGrant × joinability; ADR 0182 D6, ADR 0247, and
|
|
211
|
+
// src/bongos/project-door.js for the table). The owner picks them on the hub's
|
|
212
|
+
// manage page; the platform pushes them into this instance's branding pack (ADR
|
|
213
|
+
// 0190) and they are read through branding() — memoized for the process, applied
|
|
214
|
+
// by a restart.
|
|
215
|
+
//
|
|
216
|
+
// THE COMPOSITION IS WHY THIS READS THE DOOR AND NOT THE KNOB (task 1003579).
|
|
217
|
+
// ADR 0247 shipped the composed door at the public application endpoint and left
|
|
218
|
+
// this gate reading `joinability` alone, so an owner who set their project
|
|
219
|
+
// `private` (or `stealth`, or joinGrant `view`) while leaving `joinability: open`
|
|
220
|
+
// got a door that queued applications and a sign-in that still self-enrolled
|
|
221
|
+
// strangers — half of D6's table unenforced. Both surfaces now ask the same
|
|
222
|
+
// function, which is the only arrangement in which they cannot answer one
|
|
223
|
+
// stranger differently.
|
|
249
224
|
|
|
250
|
-
// openEnrollmentEnabled — true when a first-time GitHub account self-enrols
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
225
|
+
// openEnrollmentEnabled — true when a first-time GitHub account self-enrols: the
|
|
226
|
+
// project's composed join door is `open`. Kept as the name the four sign-in call
|
|
227
|
+
// sites, webAdmissionStatus and the public manifest read, so composing the
|
|
228
|
+
// ceiling in changed one expression rather than six surfaces (the same SR-19 move
|
|
229
|
+
// ADR 0194 made when joinabilityMode replaced the raw env flag).
|
|
230
|
+
//
|
|
231
|
+
// A door that is `apply`, `invite_only` or `locked` all answer false here, and
|
|
232
|
+
// deliberately with the SAME false: the gate must not leak which knob closed it
|
|
233
|
+
// (ADR 0247 D3 — stealth's anonymity is a privacy primitive, so a refusal may
|
|
234
|
+
// never be read backwards into a state). It passes NO publish verdict, so the
|
|
235
|
+
// `locked` branch cannot fire from here — this plane holds no trustworthy
|
|
236
|
+
// dark-matter verdict yet (ADR 0247 D4).
|
|
237
|
+
//
|
|
238
|
+
// `deps` is effectiveJoinDoor's own injection seam ({ branding, joinabilityMode,
|
|
239
|
+
// publishable }), forwarded verbatim so a unit test can drive the whole matrix
|
|
240
|
+
// without booting a pack. Production calls it with none.
|
|
241
|
+
function openEnrollmentEnabled(deps = {}) {
|
|
242
|
+
return effectiveJoinDoor(deps) === 'open';
|
|
255
243
|
}
|
|
256
244
|
|
|
257
245
|
// PURE admission decision shared by both flows so they CANNOT drift (SR-19).
|
|
@@ -259,6 +247,12 @@ function openEnrollmentEnabled() {
|
|
|
259
247
|
// builder row (or null) from getBuilderByGithubId; `approved` is the result of
|
|
260
248
|
// hasInvitedAccessRequest. Blocks only when: not already a builder AND open
|
|
261
249
|
// enrollment is off AND not invite-approved. Exported for direct unit testing.
|
|
250
|
+
//
|
|
251
|
+
// Note what the visibility ceiling does and does not close (task 1003579): it
|
|
252
|
+
// narrows SELF-enrolment only. An invited first-timer, an existing builder and
|
|
253
|
+
// the configured firstAdmin are all admitted to a `private` or `stealth` project
|
|
254
|
+
// exactly as before — an owner who takes their project off the map is closing the
|
|
255
|
+
// front door, not evicting the people they let in.
|
|
262
256
|
function firstTimeAdmissionBlocked({ existing, approved, login }) {
|
|
263
257
|
if (existing || openEnrollmentEnabled() || approved) return false;
|
|
264
258
|
// The configured firstAdmin is always admitted on a fresh instance so the
|
|
@@ -403,6 +397,10 @@ async function safeSyncRankRole(builder) {
|
|
|
403
397
|
module.exports = {
|
|
404
398
|
firstAdminLogin,
|
|
405
399
|
firstTimeAdmissionBlocked,
|
|
400
|
+
// Re-exported, not defined here (task 1003579 moved it to project-door.js with
|
|
401
|
+
// the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
|
|
402
|
+
// and four test files import it by this path, and the read-back it answers —
|
|
403
|
+
// "what did the owner set?" — is still an admission-surface question.
|
|
406
404
|
joinabilityMode,
|
|
407
405
|
maybeSeatFirstAdmin,
|
|
408
406
|
seedKickoffChecklist,
|
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
// plus `publishable`, which is DERIVED and never stored (ADR 0182 D1/D4: a project
|
|
28
28
|
// with no name or no description is "dark matter" — off the map and un-joinable).
|
|
29
29
|
//
|
|
30
|
+
// THE KNOB READERS LIVE HERE TOO. `joinabilityMode()` moved in from
|
|
31
|
+
// src/bongos/auth-admission.js (task 1003579, ADR 0256). It was born in the
|
|
32
|
+
// admission gate (ADR 0194) because the gate was its only caller; once the gate
|
|
33
|
+
// started reading the COMPOSED door instead of that one knob, leaving the reader
|
|
34
|
+
// on the far side would have made this file require the whole admission gate back
|
|
35
|
+
// to answer one question — the two files pointing at each other. The composer owns
|
|
36
|
+
// its inputs and the gate reads the answer, so the dependency runs one way.
|
|
37
|
+
// auth-admission re-exports the name, so every existing import still resolves.
|
|
38
|
+
//
|
|
30
39
|
// THE REFUSAL NEVER NAMES THE STATE. `stealth`, `public`+`view`, and an explicit
|
|
31
40
|
// `invite_only` all answer with the SAME door, so a refusal can never be read
|
|
32
41
|
// backwards to learn which state a project is in. That is not a nicety: ADR 0182
|
|
@@ -42,6 +51,12 @@
|
|
|
42
51
|
|
|
43
52
|
'use strict';
|
|
44
53
|
|
|
54
|
+
// The ONE non-lazy require in this file. logger.js pulls in pino and node
|
|
55
|
+
// built-ins and nothing else — no branding pack, no env, no database — so it does
|
|
56
|
+
// not cost the pure half its "importable with nothing behind it" property, which
|
|
57
|
+
// is why every OTHER require here is still deferred to call time.
|
|
58
|
+
const log = require('./logger').logger.child({ src: 'project-door' });
|
|
59
|
+
|
|
45
60
|
// The door vocabulary, ORDERED widest → strictest. The order IS the composition
|
|
46
61
|
// rule: two knobs never negotiate, the higher index simply wins.
|
|
47
62
|
const JOIN_DOORS = Object.freeze(['open', 'apply', 'invite_only', 'locked']);
|
|
@@ -102,13 +117,70 @@ function projectJoinDoor({ visibility, joinGrant, joinability, publishable = nul
|
|
|
102
117
|
return strictestDoor(ceiling, JOINABILITY_DOOR[joinability] || 'invite_only');
|
|
103
118
|
}
|
|
104
119
|
|
|
120
|
+
// The legacy OPEN_XENOS_ENROLLMENT escape hatch (ADR 0050). It still opens an
|
|
121
|
+
// *apply* project, so a self-hosted instance that set it keeps its open door
|
|
122
|
+
// across the upgrade — but it never reopens an explicit `invite_only`: the
|
|
123
|
+
// stricter, newer choice wins.
|
|
124
|
+
function legacyOpenEnrollmentFlag() {
|
|
125
|
+
const ic = require('../instance-config'); // eslint-disable-line global-require
|
|
126
|
+
const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
|
|
127
|
+
return v === '1' || v === 'true';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// joinabilityMode — the project's stored membership door policy, and ONLY that:
|
|
131
|
+
// 'open' | 'apply' | 'invite_only' (ADR 0194). The one reader for the knob, so
|
|
132
|
+
// the manifest's read-back, the hall's door copy and the refusal pages cannot
|
|
133
|
+
// disagree about what the owner set:
|
|
134
|
+
//
|
|
135
|
+
// open a first-time GitHub sign-in self-enrols at xenos — no request,
|
|
136
|
+
// no invite (what OTB_OPEN_XENOS_ENROLLMENT used to switch on)
|
|
137
|
+
// apply (the default — today's behaviour) a stranger files an access
|
|
138
|
+
// request and an Archon marks it 'invited'; then sign-in admits
|
|
139
|
+
// invite_only the public application endpoint refuses; only an access request
|
|
140
|
+
// an Archon CREATED at 'invited' admits a first-timer
|
|
141
|
+
//
|
|
142
|
+
// IT IS NOT THE DOOR. `projectJoinDoor` below may narrow this further, and since
|
|
143
|
+
// task 1003579 every surface that DECIDES whether someone gets in — the public
|
|
144
|
+
// application endpoint and the sign-in gate both — reads that composed answer
|
|
145
|
+
// instead. Read this one only where the question really is "what did the owner
|
|
146
|
+
// set?"; reading it to admit someone is the drift ADR 0247 was written against.
|
|
147
|
+
//
|
|
148
|
+
// An unreadable pack, or a value this core cannot read, fails CLOSED to
|
|
149
|
+
// `invite_only`: when the door's policy is unknown the widest door is never the
|
|
150
|
+
// fallback (ADR 0192). The `[auth]` log prefix is kept verbatim across the move —
|
|
151
|
+
// operators grep sign-in refusals by it, and the child logger's `src` already
|
|
152
|
+
// says which file emitted the line. `read` is injectable for the unit tests only
|
|
153
|
+
// (the pack is memoized for the process).
|
|
154
|
+
let warnedUnknownJoinability = false;
|
|
155
|
+
function joinabilityMode(read) {
|
|
156
|
+
const readPack = read || require('../branding').branding; // eslint-disable-line global-require
|
|
157
|
+
let v;
|
|
158
|
+
try { v = readPack().project?.joinability; } catch (err) {
|
|
159
|
+
log.error('[auth] joinability: branding pack unreadable — treating the project as invite-only:', err.message);
|
|
160
|
+
return 'invite_only';
|
|
161
|
+
}
|
|
162
|
+
if (v === 'open' || v === 'invite_only') return v;
|
|
163
|
+
if (v === undefined || v === null || v === '' || v === 'apply') {
|
|
164
|
+
return legacyOpenEnrollmentFlag() ? 'open' : 'apply';
|
|
165
|
+
}
|
|
166
|
+
// A value this core cannot read ('invite-only' with a hyphen, a truncated
|
|
167
|
+
// write) is a policy it does not know — never the wider door.
|
|
168
|
+
if (!warnedUnknownJoinability) {
|
|
169
|
+
warnedUnknownJoinability = true;
|
|
170
|
+
log.error(`[auth] joinability: unrecognised value ${JSON.stringify(v)} — treating the project as invite-only`);
|
|
171
|
+
}
|
|
172
|
+
return 'invite_only';
|
|
173
|
+
}
|
|
174
|
+
|
|
105
175
|
// effectiveJoinDoor — this project's LIVE door: projectJoinDoor() over the policy
|
|
106
176
|
// this instance actually resolved. The one call a door surface makes; published
|
|
107
177
|
// through the module doorway as `api.projectJoinDoor()` so a module never
|
|
108
178
|
// re-derives the composition (the drift `joinabilityMode` exists to prevent).
|
|
109
179
|
//
|
|
110
180
|
// The requires are deliberately lazy: this file's pure half must stay importable by
|
|
111
|
-
// a test with no branding pack, no env and no database behind it.
|
|
181
|
+
// a test with no branding pack, no env and no database behind it. The joinability
|
|
182
|
+
// reader is now a local function rather than a require of the admission gate, which
|
|
183
|
+
// is what lets the gate read this door without the two files forming a cycle.
|
|
112
184
|
//
|
|
113
185
|
// A pack that will not load leaves BOTH visibility and the grant unknown, and
|
|
114
186
|
// `visibilityCeiling` answers that with `invite_only` — the same fail-closed door
|
|
@@ -116,7 +188,7 @@ function projectJoinDoor({ visibility, joinGrant, joinability, publishable = nul
|
|
|
116
188
|
// something that reader had already closed.
|
|
117
189
|
function effectiveJoinDoor(deps = {}) {
|
|
118
190
|
const readBranding = deps.branding || require('../branding').branding; // eslint-disable-line global-require
|
|
119
|
-
const readJoinability = deps.joinabilityMode ||
|
|
191
|
+
const readJoinability = deps.joinabilityMode || joinabilityMode;
|
|
120
192
|
let project = {};
|
|
121
193
|
try { project = readBranding().project || {}; } catch { project = {}; }
|
|
122
194
|
return projectJoinDoor({
|
|
@@ -133,6 +205,7 @@ module.exports = {
|
|
|
133
205
|
PROJECT_JOIN_GRANTS,
|
|
134
206
|
strictestDoor,
|
|
135
207
|
visibilityCeiling,
|
|
208
|
+
joinabilityMode,
|
|
136
209
|
projectJoinDoor,
|
|
137
210
|
effectiveJoinDoor,
|
|
138
211
|
};
|
|
@@ -31,6 +31,10 @@ const crypto = require('node:crypto');
|
|
|
31
31
|
|
|
32
32
|
const auth = require('../auth');
|
|
33
33
|
const { reportActivityRollupForShippedTask } = require('../auth-admission.js');
|
|
34
|
+
// task 1003579: the sign-in refusal page names the door the project actually
|
|
35
|
+
// runs, so it cannot offer an application form to a project whose application
|
|
36
|
+
// endpoint refuses. Same composed answer the gate itself reads.
|
|
37
|
+
const { effectiveJoinDoor } = require('../project-door.js');
|
|
34
38
|
const db = require('../db');
|
|
35
39
|
const { pairStore } = require('../app-pair');
|
|
36
40
|
const { publicOrigin, parseCookie, validateOrRespond } = require('./_helpers');
|
|
@@ -705,10 +709,19 @@ module.exports = function buildAuthRouter() {
|
|
|
705
709
|
// approval brings them back to it — the return cookie says where.
|
|
706
710
|
const founderReturn = safeReturnTo(parseCookie(req, RETURN_COOKIE)) || '';
|
|
707
711
|
const onWayToProject = founderReturn.startsWith('/projects');
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
+
// A project that takes no applications says so, and names the one way in
|
|
713
|
+
// — an invite from the admitter — instead of sending a stranger to a
|
|
714
|
+
// request form the project will refuse (task 1003044).
|
|
715
|
+
//
|
|
716
|
+
// The test is the COMPOSED door, not the `joinability` knob (task 1003579):
|
|
717
|
+
// `apply` is the only door that accepts an application, so `stealth` and a
|
|
718
|
+
// `view` grant must land on this page too, or the refusal hands a stranger
|
|
719
|
+
// a form the application endpoint 403s. Both closed doors show the SAME
|
|
720
|
+
// sentence, which is the point — a refusal that distinguished them would
|
|
721
|
+
// be the state read-out ADR 0247 D3 forbids. `open` cannot reach here (the
|
|
722
|
+
// gate would have admitted) and `locked` cannot fire at all (no publish
|
|
723
|
+
// verdict is passed, ADR 0247 D4), so this is `invite_only` in practice.
|
|
724
|
+
if (effectiveJoinDoor() !== 'apply') {
|
|
712
725
|
return res.status(403).type('text/html').send(renderAuthErrorPage(
|
|
713
726
|
'This project is invite-only',
|
|
714
727
|
`Signing in with GitHub is only for builders the ${admitterLabel()} has invited. This project doesn’t take applications, so there is nothing to request here. If you know the ${admitterLabel()}, ask them for an invite — once you’re invited, this sign-in lets you in.`,
|
|
@@ -124,6 +124,13 @@ module.exports = function buildInstanceRouter() {
|
|
|
124
124
|
rankTier: RANK_TIER,
|
|
125
125
|
repo: loadRepoInfo(),
|
|
126
126
|
authConfigured: authConfigured(),
|
|
127
|
+
// The COMPOSED join door, not the raw `joinability` knob (task 1003579):
|
|
128
|
+
// `admission.policy` says whether a stranger can actually sign themselves
|
|
129
|
+
// in, so a project that is off the map reports 'invite-only' even while its
|
|
130
|
+
// `joinability` reads back 'open' under brand.project. The two fields answer
|
|
131
|
+
// different questions on purpose — one is the owner's setting, the other is
|
|
132
|
+
// what the gate will do — and 'invite-only' already covers every non-open
|
|
133
|
+
// door, so narrowing it names no state (ADR 0247 D3).
|
|
127
134
|
openEnrollment: openEnrollmentEnabled(),
|
|
128
135
|
devBoxEnabled: isModuleEnabled('dev-box'),
|
|
129
136
|
// Project OUT the client secret — the manifest is public; only origin +
|
package/src/branding.js
CHANGED
|
@@ -220,7 +220,8 @@ function clientBranding(b = branding()) {
|
|
|
220
220
|
// the honest place to learn what it RESOLVED from its environment is its own
|
|
221
221
|
// GET /instance manifest. Enforced on the instance by the platform-visibility
|
|
222
222
|
// gate (task 1003043, ADR 0192) and the admission machinery (task 1003044,
|
|
223
|
-
// ADR 0194 — joinabilityMode() in
|
|
223
|
+
// ADR 0194 — joinabilityMode() in project-door.js, which composes it with
|
|
224
|
+
// visibility + joinGrant into the one door every admission surface reads).
|
|
224
225
|
project: {
|
|
225
226
|
platformVisibility: b.project?.platformVisibility || 'public',
|
|
226
227
|
joinability: b.project?.joinability || 'apply',
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.569'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// tests/admission_ceiling_gate.mjs — the visibility ceiling reaches the SIGN-IN
|
|
2
|
+
// gate, not only the application door (task 1003579, goal 1000046; ADR 0256).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. ADR 0247 composed the project's join door — visibility ×
|
|
5
|
+
// joinGrant × joinability — and enforced it at the public application endpoint,
|
|
6
|
+
// but left `openEnrollmentEnabled()` reading the `joinability` knob alone. So an
|
|
7
|
+
// owner who took their project off the map (`visibility: private`, or `stealth`,
|
|
8
|
+
// or `joinGrant: view`) while leaving `joinability: open` got a door that queued
|
|
9
|
+
// applications for review and a sign-in that still handed any first-time GitHub
|
|
10
|
+
// account a xenos account. Half of ADR 0182 D6's table — its whole "Self-join"
|
|
11
|
+
// column — was unenforced wherever the owner had explicitly opened enrolment.
|
|
12
|
+
//
|
|
13
|
+
// The boot half sets the knobs the way production does: <PREFIX>_VISIBILITY and
|
|
14
|
+
// <PREFIX>_JOINABILITY in the environment, read once through the memoized
|
|
15
|
+
// branding pack, which is exactly what the provisioning runner writes into
|
|
16
|
+
// web.env before restarting the service (ADR 0190). `private` + `open` is the
|
|
17
|
+
// precise pair ADR 0247's "KNOWN LIMIT" paragraph named.
|
|
18
|
+
//
|
|
19
|
+
// The sweep half drives effectiveJoinDoor's injection seam across every knob
|
|
20
|
+
// combination and asserts the gate is the door EVERYWHERE, not just at the one
|
|
21
|
+
// booted point — the property that makes this a fix rather than a special case.
|
|
22
|
+
//
|
|
23
|
+
// DB-free: every decision under test answers from the pack and the pure
|
|
24
|
+
// composition, before any lookup. Run: node --test tests/admission_ceiling_gate.mjs
|
|
25
|
+
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { test, before, after } from 'node:test';
|
|
28
|
+
import http from 'node:http';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
|
|
33
|
+
// Set BEFORE the first require that could read branding(): the pack is memoized
|
|
34
|
+
// and deep-frozen for the process lifetime, which is why a real instance applies
|
|
35
|
+
// one of these with a restart rather than a re-read.
|
|
36
|
+
process.env.CLOUDBONGOS_VISIBILITY = 'private';
|
|
37
|
+
process.env.CLOUDBONGOS_JOINABILITY = 'open';
|
|
38
|
+
// The bootstrap owner, so the test can prove the ceiling does not gate them out
|
|
39
|
+
// of their own instance. The neutral pack ships firstAdmin: "" (an unclaimed
|
|
40
|
+
// instance has no owner yet), so it is configured through the documented env
|
|
41
|
+
// override rather than a pack edit.
|
|
42
|
+
const FIRST_ADMIN = 'configured-owner';
|
|
43
|
+
process.env.CLOUDBONGOS_FIRST_ADMIN = FIRST_ADMIN;
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
joinabilityMode, openEnrollmentEnabled, firstTimeAdmissionBlocked, firstAdminLogin,
|
|
47
|
+
} = require('../src/bongos/auth-admission.js');
|
|
48
|
+
const { effectiveJoinDoor, projectJoinDoor } = require('../src/bongos/project-door.js');
|
|
49
|
+
const platformMod = require('../src/platform-server.js');
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// The booted instance: private on the map, enrolment explicitly open.
|
|
53
|
+
|
|
54
|
+
test('the stored knob still reads back exactly what the owner set', () => {
|
|
55
|
+
// The composition may narrow what the door DOES; it may never rewrite what the
|
|
56
|
+
// owner chose, or the manage page's "did my save take effect?" read-back lies
|
|
57
|
+
// (ADR 0194 D1 — the reason the ceiling is composed on the instance instead of
|
|
58
|
+
// folded into the pushed value at the platform).
|
|
59
|
+
assert.equal(joinabilityMode(), 'open');
|
|
60
|
+
assert.equal(firstAdminLogin(), FIRST_ADMIN);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the application door still queues — private accepts applications', () => {
|
|
64
|
+
// private's ceiling is `apply`, and `open` cannot widen it back. The door and
|
|
65
|
+
// the gate are allowed to differ; what they may not do is disagree about the
|
|
66
|
+
// ceiling, which is the whole bug.
|
|
67
|
+
assert.equal(effectiveJoinDoor(), 'apply');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('THE FIX: the sign-in gate obeys the ceiling — a stranger no longer self-enrols', () => {
|
|
71
|
+
assert.equal(openEnrollmentEnabled(), false, 'private + joinability:open must not self-enrol');
|
|
72
|
+
assert.equal(
|
|
73
|
+
firstTimeAdmissionBlocked({ existing: null, approved: false, login: 'stranger' }), true,
|
|
74
|
+
'a first-time GitHub account is blocked at the gate, not admitted at xenos',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the ceiling narrows SELF-enrolment only — invited, existing and the owner still get in', () => {
|
|
79
|
+
// An owner taking their project off the map is closing the front door, not
|
|
80
|
+
// evicting the people they let in. If this ever flips, a `private` project
|
|
81
|
+
// locks out its own members and its own founder.
|
|
82
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: true, login: 'invited' }), false);
|
|
83
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: { id: 42 }, approved: false, login: 'member' }), false);
|
|
84
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: FIRST_ADMIN }), false);
|
|
85
|
+
assert.equal(firstTimeAdmissionBlocked({ existing: null, approved: false, login: FIRST_ADMIN.toUpperCase() }), false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the gate IS the door — one decision, not two that happen to agree', () => {
|
|
89
|
+
assert.equal(openEnrollmentEnabled(), effectiveJoinDoor() === 'open');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// The manifest, from the real booted app.
|
|
94
|
+
|
|
95
|
+
let server;
|
|
96
|
+
let port;
|
|
97
|
+
before(async () => {
|
|
98
|
+
server = http.createServer(platformMod.buildPlatformApp());
|
|
99
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
100
|
+
port = server.address().port;
|
|
101
|
+
});
|
|
102
|
+
after(async () => {
|
|
103
|
+
await new Promise((r) => server.close(r));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function request(pathname) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const req = http.request({ host: '127.0.0.1', port, path: pathname, method: 'GET' }, (res) => {
|
|
109
|
+
let data = '';
|
|
110
|
+
res.on('data', (c) => { data += c; });
|
|
111
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
|
112
|
+
});
|
|
113
|
+
req.on('error', reject);
|
|
114
|
+
req.end();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test('booted: the manifest reports the GATE, while the brand block reports the SETTING', async () => {
|
|
119
|
+
const res = await request('/api/bongos/instance');
|
|
120
|
+
assert.equal(res.status, 200);
|
|
121
|
+
const m = JSON.parse(res.body);
|
|
122
|
+
// The owner's settings, unchanged — a client can still say what the owner chose.
|
|
123
|
+
assert.equal(m.brand.project.visibility, 'private');
|
|
124
|
+
assert.equal(m.brand.project.joinability, 'open');
|
|
125
|
+
// ...and admission.policy now answers the different question it always claimed
|
|
126
|
+
// to: can a stranger sign themselves in? Before this task it said 'open' here
|
|
127
|
+
// and the CLI believed it.
|
|
128
|
+
assert.equal(m.admission.policy, 'invite-only');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('booted: the refusal names no state — every closed door reports the same word', async () => {
|
|
132
|
+
// 'invite-only' is what an `apply` project has always reported, so narrowing a
|
|
133
|
+
// private project into it adds no way to tell private, stealth, a `view` grant
|
|
134
|
+
// and a plain invite-only project apart from the manifest (ADR 0247 D3 —
|
|
135
|
+
// stealth's anonymity is a privacy primitive, not a nicety).
|
|
136
|
+
const m = JSON.parse((await request('/api/bongos/instance')).body);
|
|
137
|
+
const closed = ['apply', 'invite_only'].map((j) => (effectiveJoinDoor({
|
|
138
|
+
branding: () => ({ project: { visibility: 'public', joinGrant: 'full' } }),
|
|
139
|
+
joinabilityMode: () => j,
|
|
140
|
+
}) === 'open' ? 'open' : 'invite-only'));
|
|
141
|
+
assert.deepEqual([...new Set([...closed, m.admission.policy])], ['invite-only']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// The sweep: the gate equals the door across the whole knob space.
|
|
146
|
+
|
|
147
|
+
const VISIBILITIES = ['public', 'private', 'stealth', 'unlisted', undefined];
|
|
148
|
+
const GRANTS = ['full', 'apply', 'view', 'FULL', undefined];
|
|
149
|
+
const JOINABILITIES = ['open', 'apply', 'invite_only'];
|
|
150
|
+
|
|
151
|
+
const packOf = (visibility, joinGrant) => () => ({ project: { visibility, joinGrant } });
|
|
152
|
+
|
|
153
|
+
test('every knob combination: openEnrollmentEnabled === (the composed door is open)', () => {
|
|
154
|
+
let checked = 0;
|
|
155
|
+
for (const visibility of VISIBILITIES) {
|
|
156
|
+
for (const joinGrant of GRANTS) {
|
|
157
|
+
for (const joinability of JOINABILITIES) {
|
|
158
|
+
const gate = openEnrollmentEnabled({
|
|
159
|
+
branding: packOf(visibility, joinGrant), joinabilityMode: () => joinability,
|
|
160
|
+
});
|
|
161
|
+
const door = projectJoinDoor({ visibility, joinGrant, joinability });
|
|
162
|
+
assert.equal(
|
|
163
|
+
gate, door === 'open',
|
|
164
|
+
`drift: visibility=${visibility} grant=${joinGrant} joinability=${joinability} → gate=${gate} door=${door}`,
|
|
165
|
+
);
|
|
166
|
+
checked++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
assert.equal(checked, VISIBILITIES.length * GRANTS.length * JOINABILITIES.length);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('exactly ONE combination self-enrols: on the map, granting membership, open', () => {
|
|
174
|
+
// The inverse statement of the sweep, written out so a future widening of any
|
|
175
|
+
// one knob's table fails here loudly instead of quietly admitting strangers.
|
|
176
|
+
const opened = [];
|
|
177
|
+
for (const visibility of VISIBILITIES) {
|
|
178
|
+
for (const joinGrant of GRANTS) {
|
|
179
|
+
for (const joinability of JOINABILITIES) {
|
|
180
|
+
if (openEnrollmentEnabled({ branding: packOf(visibility, joinGrant), joinabilityMode: () => joinability })) {
|
|
181
|
+
opened.push([visibility, joinGrant, joinability].join('/'));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
assert.deepEqual(opened, ['public/full/open']);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a pack that will not load fails CLOSED at the gate, exactly as it does at the door', () => {
|
|
190
|
+
const boom = () => { throw new Error('pack unreadable'); };
|
|
191
|
+
assert.equal(openEnrollmentEnabled({ branding: boom, joinabilityMode: () => 'open' }), false);
|
|
192
|
+
assert.equal(openEnrollmentEnabled({ branding: () => ({}), joinabilityMode: () => 'open' }), false,
|
|
193
|
+
'a pack with no project block is not a public project by default');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the gate forwards a publish verdict when its caller holds one', () => {
|
|
197
|
+
// Nothing in the sign-in path passes one today — this plane holds no
|
|
198
|
+
// trustworthy dark-matter verdict yet (ADR 0247 D4) — but the seam is the
|
|
199
|
+
// door's own, so wiring the verdict later is one argument, not a redesign.
|
|
200
|
+
const pack = packOf('public', 'full');
|
|
201
|
+
assert.equal(openEnrollmentEnabled({ branding: pack, joinabilityMode: () => 'open', publishable: false }), false);
|
|
202
|
+
assert.equal(openEnrollmentEnabled({ branding: pack, joinabilityMode: () => 'open', publishable: true }), true);
|
|
203
|
+
});
|
|
@@ -158,7 +158,13 @@ const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
|
158
158
|
|
|
159
159
|
test('the web sign-in refusal names the invite-only door instead of sending a stranger to a request form', () => {
|
|
160
160
|
const auth = src('src/bongos/routes/auth.js');
|
|
161
|
-
|
|
161
|
+
// task 1003579: the branch tests the COMPOSED door, not the `joinability` knob.
|
|
162
|
+
// `apply` is the only door that accepts an application, so a project closed by
|
|
163
|
+
// its VISIBILITY ceiling (stealth, or a `view` grant, with `joinability` still
|
|
164
|
+
// reading `apply`) lands on this page too instead of being handed a request form
|
|
165
|
+
// the application endpoint refuses. Both closed doors show the same sentence,
|
|
166
|
+
// which is ADR 0247 D3 — a refusal may not be read backwards into a state.
|
|
167
|
+
assert.match(auth, /effectiveJoinDoor\(\) !== 'apply'/);
|
|
162
168
|
assert.match(auth, /'This project is invite-only'/);
|
|
163
169
|
assert.match(auth, /doesn’t take applications/);
|
|
164
170
|
});
|