@bongos/core 1.19.567 → 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 +42 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +169 -0
- 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 +2 -0
- package/docs/api/openapi.json +7 -3
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/platform-identity.js +2 -1
- package/modules/platform-identity/projects-feed.js +126 -20
- package/modules/platform-identity/routes/projects.js +29 -3
- 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/tests/visible_projects_feed.mjs +184 -13
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:
|
|
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",
|
|
@@ -392,7 +392,7 @@
|
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.d.ts",
|
|
394
394
|
"mode": "0000644",
|
|
395
|
-
"sha256": "
|
|
395
|
+
"sha256": "2b37686507b5ea701c19a1086352a2f0c84eae46510ef12b3a601c18812610c6"
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1789,10 +1789,20 @@
|
|
|
1789
1789
|
"mode": "0000644",
|
|
1790
1790
|
"sha256": "4e27106f8c9251b788d13bcfc64f29637bb0b009c18102c9b7dd9d70b80400dc"
|
|
1791
1791
|
},
|
|
1792
|
+
{
|
|
1793
|
+
"path": "docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md",
|
|
1794
|
+
"mode": "0000644",
|
|
1795
|
+
"sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
|
|
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
|
+
},
|
|
1792
1802
|
{
|
|
1793
1803
|
"path": "docs/adr/README.md",
|
|
1794
1804
|
"mode": "0000644",
|
|
1795
|
-
"sha256": "
|
|
1805
|
+
"sha256": "52422b934d0b3245605ec0913049be65768a814a6f9aee5f7ad8149c4221a093"
|
|
1796
1806
|
},
|
|
1797
1807
|
{
|
|
1798
1808
|
"path": "docs/api-reference.md",
|
|
@@ -1802,7 +1812,7 @@
|
|
|
1802
1812
|
{
|
|
1803
1813
|
"path": "docs/api/openapi.json",
|
|
1804
1814
|
"mode": "0000644",
|
|
1805
|
-
"sha256": "
|
|
1815
|
+
"sha256": "9148616a367b2f7486a8f14c0f7ecf9ed367bd552075b15c4f2b0f3dd40712db"
|
|
1806
1816
|
},
|
|
1807
1817
|
{
|
|
1808
1818
|
"path": "docs/architecture.md",
|
|
@@ -2667,7 +2677,7 @@
|
|
|
2667
2677
|
{
|
|
2668
2678
|
"path": "docs/file-map.md",
|
|
2669
2679
|
"mode": "0000644",
|
|
2670
|
-
"sha256": "
|
|
2680
|
+
"sha256": "dd40cd29025789ec5a9447aa44ec6c7d3bee6aaae183c65713a6a3ca8ed8275b"
|
|
2671
2681
|
},
|
|
2672
2682
|
{
|
|
2673
2683
|
"path": "docs/handoff-template.md",
|
|
@@ -2677,7 +2687,7 @@
|
|
|
2677
2687
|
{
|
|
2678
2688
|
"path": "docs/module-api-changelog.md",
|
|
2679
2689
|
"mode": "0000644",
|
|
2680
|
-
"sha256": "
|
|
2690
|
+
"sha256": "5942f6c9fae093d6fbb85032ca496e5f0549cc5044d8ffa4ecb90c3c9d2695dd"
|
|
2681
2691
|
},
|
|
2682
2692
|
{
|
|
2683
2693
|
"path": "docs/modules-contract.md",
|
|
@@ -6047,7 +6057,7 @@
|
|
|
6047
6057
|
{
|
|
6048
6058
|
"path": "modules/platform-identity/platform-identity.js",
|
|
6049
6059
|
"mode": "0000644",
|
|
6050
|
-
"sha256": "
|
|
6060
|
+
"sha256": "54f44f6292729e822083671913dc89de28ede760dc88028ce4a18219ce006ab3"
|
|
6051
6061
|
},
|
|
6052
6062
|
{
|
|
6053
6063
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -6072,7 +6082,7 @@
|
|
|
6072
6082
|
{
|
|
6073
6083
|
"path": "modules/platform-identity/projects-feed.js",
|
|
6074
6084
|
"mode": "0000644",
|
|
6075
|
-
"sha256": "
|
|
6085
|
+
"sha256": "9a8a22d21089770ec80f709d27f798eab6101cfaead18942c439f7c8689ef041"
|
|
6076
6086
|
},
|
|
6077
6087
|
{
|
|
6078
6088
|
"path": "modules/platform-identity/publish-gate.js",
|
|
@@ -6102,7 +6112,7 @@
|
|
|
6102
6112
|
{
|
|
6103
6113
|
"path": "modules/platform-identity/routes/projects.js",
|
|
6104
6114
|
"mode": "0000644",
|
|
6105
|
-
"sha256": "
|
|
6115
|
+
"sha256": "7db130e5fe698bc133475d5c7ab90583fbe3683ac1d11b2542c56e76a7da4cfd"
|
|
6106
6116
|
},
|
|
6107
6117
|
{
|
|
6108
6118
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
@@ -7527,12 +7537,12 @@
|
|
|
7527
7537
|
{
|
|
7528
7538
|
"path": "package-lock.json",
|
|
7529
7539
|
"mode": "0000644",
|
|
7530
|
-
"sha256": "
|
|
7540
|
+
"sha256": "e2279fd35b142a93a9037638445d5e6e616c69cd8322d05fad2aa228a5de3515"
|
|
7531
7541
|
},
|
|
7532
7542
|
{
|
|
7533
7543
|
"path": "package.json",
|
|
7534
7544
|
"mode": "0000644",
|
|
7535
|
-
"sha256": "
|
|
7545
|
+
"sha256": "1ba4dccdc1e8d591a783bdac3b0ac4f0d121ad687fef73347ac56e32e69524f7"
|
|
7536
7546
|
},
|
|
7537
7547
|
{
|
|
7538
7548
|
"path": "public-docs/index.html",
|
|
@@ -7972,7 +7982,7 @@
|
|
|
7972
7982
|
{
|
|
7973
7983
|
"path": "scripts/gds/fitness.js",
|
|
7974
7984
|
"mode": "0000644",
|
|
7975
|
-
"sha256": "
|
|
7985
|
+
"sha256": "a93e0d79fe1d557d09711e9b9bb62ffc7687b91ee2981b71eb89176eb0fc2ce6"
|
|
7976
7986
|
},
|
|
7977
7987
|
{
|
|
7978
7988
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -8942,7 +8952,7 @@
|
|
|
8942
8952
|
{
|
|
8943
8953
|
"path": "src/bongos/auth-admission.js",
|
|
8944
8954
|
"mode": "0000644",
|
|
8945
|
-
"sha256": "
|
|
8955
|
+
"sha256": "8dc4cc0ab956d387b8044c22e83fce883f2f0129ed27c03503f7348e9d4f00bb"
|
|
8946
8956
|
},
|
|
8947
8957
|
{
|
|
8948
8958
|
"path": "src/bongos/auth-config.js",
|
|
@@ -9077,7 +9087,7 @@
|
|
|
9077
9087
|
{
|
|
9078
9088
|
"path": "src/bongos/project-door.js",
|
|
9079
9089
|
"mode": "0000644",
|
|
9080
|
-
"sha256": "
|
|
9090
|
+
"sha256": "60a342af5b1d4aaeb781faf5bfea75d69a32b9bba71d0740ccc4ca1a9af2bba4"
|
|
9081
9091
|
},
|
|
9082
9092
|
{
|
|
9083
9093
|
"path": "src/bongos/project-settings.js",
|
|
@@ -9117,7 +9127,7 @@
|
|
|
9117
9127
|
{
|
|
9118
9128
|
"path": "src/bongos/routes/auth.js",
|
|
9119
9129
|
"mode": "0000644",
|
|
9120
|
-
"sha256": "
|
|
9130
|
+
"sha256": "f7e24d83d5fceceb33eacc7d82ee8136a2177bb190e5b912e5857ffc07090009"
|
|
9121
9131
|
},
|
|
9122
9132
|
{
|
|
9123
9133
|
"path": "src/bongos/routes/backup.js",
|
|
@@ -9142,7 +9152,7 @@
|
|
|
9142
9152
|
{
|
|
9143
9153
|
"path": "src/bongos/routes/instance.js",
|
|
9144
9154
|
"mode": "0000644",
|
|
9145
|
-
"sha256": "
|
|
9155
|
+
"sha256": "c3bfdf5f1242fe4bbbb51ea79401dd5ae09cca95adc475e0dc0e5c76055677c4"
|
|
9146
9156
|
},
|
|
9147
9157
|
{
|
|
9148
9158
|
"path": "src/bongos/routes/live.js",
|
|
@@ -9212,7 +9222,7 @@
|
|
|
9212
9222
|
{
|
|
9213
9223
|
"path": "src/branding.js",
|
|
9214
9224
|
"mode": "0000644",
|
|
9215
|
-
"sha256": "
|
|
9225
|
+
"sha256": "2bbb5774c9ae8659b4d5ce9b62cb558d28d036b2aa0075f9329ae85c15734a2d"
|
|
9216
9226
|
},
|
|
9217
9227
|
{
|
|
9218
9228
|
"path": "src/build-info.js",
|
|
@@ -9232,7 +9242,7 @@
|
|
|
9232
9242
|
{
|
|
9233
9243
|
"path": "src/module-api.js",
|
|
9234
9244
|
"mode": "0000644",
|
|
9235
|
-
"sha256": "
|
|
9245
|
+
"sha256": "6c1005562c9950b7cee858382e3cfd643f5c2be27918859de46927c2fbd29c06"
|
|
9236
9246
|
},
|
|
9237
9247
|
{
|
|
9238
9248
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9304,6 +9314,11 @@
|
|
|
9304
9314
|
"mode": "0000644",
|
|
9305
9315
|
"sha256": "f7434d510d656e5081f735d1f9356ecb11e8c3630a86a86137c3290134541c8d"
|
|
9306
9316
|
},
|
|
9317
|
+
{
|
|
9318
|
+
"path": "tests/admission_ceiling_gate.mjs",
|
|
9319
|
+
"mode": "0000644",
|
|
9320
|
+
"sha256": "79c216e3ad3d91388a4a48f4ec5f3af9cf440d82791f05a1eb89860573be8dff"
|
|
9321
|
+
},
|
|
9307
9322
|
{
|
|
9308
9323
|
"path": "tests/adopt_detect.mjs",
|
|
9309
9324
|
"mode": "0000644",
|
|
@@ -11012,7 +11027,7 @@
|
|
|
11012
11027
|
{
|
|
11013
11028
|
"path": "tests/joinability_invite_only.mjs",
|
|
11014
11029
|
"mode": "0000644",
|
|
11015
|
-
"sha256": "
|
|
11030
|
+
"sha256": "0c2c540a794fcdafbdf4c496f2dd3efc508edfb9f30f88a6c0f977fc99bb75cc"
|
|
11016
11031
|
},
|
|
11017
11032
|
{
|
|
11018
11033
|
"path": "tests/joinability_open.mjs",
|
|
@@ -12507,7 +12522,7 @@
|
|
|
12507
12522
|
{
|
|
12508
12523
|
"path": "tests/visible_projects_feed.mjs",
|
|
12509
12524
|
"mode": "0000644",
|
|
12510
|
-
"sha256": "
|
|
12525
|
+
"sha256": "a15bfe5f3b664a9daa4f45b80eb7a5bee2b2c630198439352c170fc44c4b2025"
|
|
12511
12526
|
},
|
|
12512
12527
|
{
|
|
12513
12528
|
"path": "tests/wandering_prefs.mjs",
|
|
@@ -90,7 +90,7 @@ export interface GetMyProjectsResponse { owned: unknown; building: unknown; appl
|
|
|
90
90
|
export interface GetOverrideRequestsResponse { requests: unknown }
|
|
91
91
|
export interface GetProfilesHandleResponse { account: unknown; cross_project: unknown }
|
|
92
92
|
export interface GetProjectSettingsResponse { rows: unknown }
|
|
93
|
-
export interface GetProjectsFeaturedResponse { projects: unknown }
|
|
93
|
+
export interface GetProjectsFeaturedResponse { projects: unknown; page: PageInfo }
|
|
94
94
|
export interface GetProjectsResponse { projects: unknown }
|
|
95
95
|
export interface GetProvisioningFleetResponse { instances: unknown }
|
|
96
96
|
export interface GetProvisioningGithubAppStatusResponse { ok: boolean; status: unknown; ready: unknown; terminal: unknown; domain: unknown; instance_status: unknown; app_slug: unknown }
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# ADR 0255 — A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-06
|
|
5
|
+
- **Task:** [task 1003561](https://cloudbongos.com/builders#/task/1003561) (BONGOS-V1, goal 1000046 — *Project creation*), which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)
|
|
6
|
+
- **Deciders:** Claude, building against ADR 0182 D2a
|
|
7
|
+
- **Related:** [ADR 0182](<redacted>.md) D1/D2a/D3 (the join door, the stealth redaction, and `featured`'s remaining ranking job) · [ADR 0252](<redacted>.md) §5.3 (the per-IP ceiling on this route) · [ADR 0253](<redacted>.md) (`visibility_source` / `visibility_reported_at`, the `DOOR_KNOWN_SQL` predicate this feed joins) · [ADR 0119](0119-api-pagination-contract.md) (the `?limit=&offset=` contract this feed was the last public list to hold out on)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. The leak
|
|
12
|
+
|
|
13
|
+
`modules/platform-identity/projects-feed.js` takes considerable trouble over what a stealth
|
|
14
|
+
project may say. `feedShapeFor()` rebuilds an anonymous row from an allow-list —
|
|
15
|
+
`STEALTH_ANONYMOUS_KEEPS = ['state', 'orb']` — and the constant's own comment states the
|
|
16
|
+
threat model it is defending against:
|
|
17
|
+
|
|
18
|
+
> No stable per-project key appears here on purpose: an id, a slug or a hash of the origin
|
|
19
|
+
> would each let an observer who kept yesterday's public feed name the project that went
|
|
20
|
+
> stealth today. Black holes are interchangeable, which is the whole point of the metaphor.
|
|
21
|
+
|
|
22
|
+
The feed then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer a
|
|
23
|
+
stable per-project key anyway. **Ordering is a covert column.** Every row's position is the
|
|
24
|
+
result of a comparison, and enough comparisons reconstruct the key the comparisons were made
|
|
25
|
+
on. Three consequences followed, and the third is the one that matters:
|
|
26
|
+
|
|
27
|
+
1. **`updated_at` was published despite being deliberately withheld.** The projection
|
|
28
|
+
excludes it on purpose — the same file notes that feature toggles and client
|
|
29
|
+
re-registration bump it, so as a public "last updated" it would lie. Sorting by it
|
|
30
|
+
published it anyway, one comparison at a time.
|
|
31
|
+
2. **A black hole sat between named neighbours**, whose own relative order is visible. That
|
|
32
|
+
brackets the hidden row's `updated_at` between two rows an observer can identify by name,
|
|
33
|
+
which is a far narrower fact about it than "a project exists."
|
|
34
|
+
3. **A black hole MOVED when its hidden project was touched.** This is the activity oracle.
|
|
35
|
+
Two fetches of a public, CORS-open, unauthenticated feed are enough to learn *that the
|
|
36
|
+
project behind this particular black hole was just active* — and correlating that with
|
|
37
|
+
anything happening in the open (a release, a post, a commit) is how an anonymous row gets
|
|
38
|
+
a name. Position was also stable enough between touches to follow one black hole across
|
|
39
|
+
snapshots, which is precisely the "stable per-project key" the payload refuses to carry.
|
|
40
|
+
|
|
41
|
+
The redaction was doing its job. The `ORDER BY` was undoing it.
|
|
42
|
+
|
|
43
|
+
## 2. Decision — the rule
|
|
44
|
+
|
|
45
|
+
**A row may be ordered only by what it publishes.**
|
|
46
|
+
|
|
47
|
+
- An **identified** row (`public` → planet, `private` → star) publishes `created_at` and
|
|
48
|
+
`origin`, so it is ordered by those, beneath `featured` — ADR 0182 D3's one remaining
|
|
49
|
+
job, ranking among rows already on the map. The resulting order is a **pure function of
|
|
50
|
+
the wire payload**: a reader holding nothing but the response can reproduce it. That is
|
|
51
|
+
the strongest available statement that a sort leaks nothing, and it is worth stating as
|
|
52
|
+
the positive form of the rule rather than as an absence of known leaks.
|
|
53
|
+
- An **anonymous** row (`stealth`, and anything unrecognised) publishes nothing, so it is
|
|
54
|
+
ordered by nothing about itself. The black holes are collated into **one trailing block**,
|
|
55
|
+
permuted by a **keyed, rotating hash**.
|
|
56
|
+
|
|
57
|
+
Concretely, four sort terms, of which the first partitions the two blocks so that no later
|
|
58
|
+
term ever compares across them:
|
|
59
|
+
|
|
60
|
+
```sql
|
|
61
|
+
ORDER BY (visibility IN ('public','private')) DESC,
|
|
62
|
+
((visibility IN ('public','private')) AND featured) DESC,
|
|
63
|
+
(CASE WHEN (visibility IN ('public','private')) THEN created_at END) DESC,
|
|
64
|
+
(CASE WHEN (visibility IN ('public','private')) THEN origin
|
|
65
|
+
ELSE md5(origin || $1::text) END) ASC
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Terms 2–4 are **guarded**, and that is the whole trick. Without the guards the trailing
|
|
69
|
+
block would simply be re-sorted by `featured`, then `created_at`, then `origin` — none of
|
|
70
|
+
which a black hole publishes, and `created_at` in particular would place it by its listing
|
|
71
|
+
date, which narrows *which* project it is for anyone who watched the catalog fill up. The
|
|
72
|
+
"anonymous rows go last" half without the "and are blanked" half is not a fix.
|
|
73
|
+
|
|
74
|
+
The identified-state list is **derived from `ORB_BY_STATE`**, never re-typed, and is an
|
|
75
|
+
**allow-list**: a state this file does not know sorts into the anonymous block, the same
|
|
76
|
+
fail-closed direction `orbStateOf()` already takes for rendering. A deny-list would draw a
|
|
77
|
+
future fourth enum value as a black hole and then sort it by its `created_at` — anonymous in
|
|
78
|
+
the payload, identified in the ordering, which is the worst of both.
|
|
79
|
+
|
|
80
|
+
## 3. Why the anonymous block is keyed, and why it rotates
|
|
81
|
+
|
|
82
|
+
Two separate properties, both load-bearing:
|
|
83
|
+
|
|
84
|
+
**Secret.** Origins are guessable — they are domain names. A permutation an observer could
|
|
85
|
+
compute (`md5(origin)`, `md5(origin || today)`, or plain alphabetical order by `origin`)
|
|
86
|
+
would let them hash their candidate origins, match the resulting ranks against the live
|
|
87
|
+
block, and de-anonymise every black hole in one pass. The salt is therefore a random
|
|
88
|
+
per-process key. Nothing persists it, compares it across restarts, or reads it back — it
|
|
89
|
+
only ever *orders* rows — so there is no key to configure, distribute, rotate by hand, or
|
|
90
|
+
leak from disk.
|
|
91
|
+
|
|
92
|
+
**Rotating.** A permanently stable permutation would still make **position** a durable
|
|
93
|
+
per-project handle: an observer could not name a black hole, but could follow one across
|
|
94
|
+
months and watch it appear and disappear. The salt therefore also carries a coarse epoch
|
|
95
|
+
(24h), so the whole block is re-dealt on a fixed period regardless of process lifetime.
|
|
96
|
+
Within one period the order is stable, which is what lets the pages of a single listing agree
|
|
97
|
+
with each other.
|
|
98
|
+
|
|
99
|
+
`md5` is a permutation here, not a security primitive; the unpredictability comes from the
|
|
100
|
+
secret salt, and it is the most portable hash available without pgcrypto.
|
|
101
|
+
|
|
102
|
+
## 4. Pagination is the same decision, not a neighbouring one
|
|
103
|
+
|
|
104
|
+
Task 1003560 asked for a cap on an "unbounded" feed. **That premise was already stale:**
|
|
105
|
+
`<redacted> = 500` shipped with the feed itself, and its own comment named the
|
|
106
|
+
cap "the cheap half of that trade — the honest half is pagination, which is its own task."
|
|
107
|
+
The surviving work was pagination only, and pagination edits the same single SQL statement
|
|
108
|
+
this ADR rewrites — so the two tasks were built together and 1003560 was merged into 1003561.
|
|
109
|
+
|
|
110
|
+
- **`?limit=&offset=`, per ADR 0119**, whose helper (`parsePagination` / `pageMeta`) this
|
|
111
|
+
route was the last public list not to use.
|
|
112
|
+
- **No cursor, and that is a security property as much as a style one.** An opaque cursor
|
|
113
|
+
over this feed would have to encode a sort position, and the obvious encoding — the last
|
|
114
|
+
row's timestamp — is exactly the side-channel §2 exists to close. An offset encodes nothing
|
|
115
|
+
about any row.
|
|
116
|
+
- **The default page size *is* the maximum (500), deliberately.** A caller that sends no
|
|
117
|
+
parameters gets byte-for-byte what it got before. One consumer of this feed is a static
|
|
118
|
+
orbs site in **another repository** that this commit cannot update; a smaller default would
|
|
119
|
+
have silently shrunk somebody else's map. `page` is added *beside* the existing `projects`
|
|
120
|
+
key rather than wrapping it, so the wire change is additive and every consumer that
|
|
121
|
+
destructures `data.projects` keeps working.
|
|
122
|
+
- **Paging cannot aim more fan-out than one request already could.** `?limit=` only ever
|
|
123
|
+
shrinks a page. `?offset=` reaches deeper, and the cost of that is bounded not by the
|
|
124
|
+
request count but by `project-stats.js`'s 10-minute per-origin cache: a second page reaches
|
|
125
|
+
origins the cache would have covered anyway, and re-reading any page is free. The per-IP
|
|
126
|
+
limiter (120 reads/60s, ADR 0252 §5.3) is unchanged and still bounds how often.
|
|
127
|
+
- **No `total`.** Counting is a second pass over the same predicate for a number nothing
|
|
128
|
+
reads today; `pageMeta()` makes it optional for that reason. A caller learns there is more
|
|
129
|
+
the standard way: `projects.length === page.limit` means ask for the next offset.
|
|
130
|
+
|
|
131
|
+
## 5. Alternatives weighed
|
|
132
|
+
|
|
133
|
+
| Option | Why not |
|
|
134
|
+
|---|---|
|
|
135
|
+
| Sort every row by `created_at DESC` | `created_at` is nulled on anonymous rows, so a black hole would be *positioned* by a date its own row refuses to carry — and its listing date is a strong hint at which project it is. Worse than the bug being fixed. |
|
|
136
|
+
| Order the anonymous block alphabetically by `origin` | Deterministic and timing-free, but position then reveals the alphabetical rank of a hidden domain — an oracle against a candidate set. |
|
|
137
|
+
| `ORDER BY random()` for the anonymous block | Kills both the timing signal and the durable handle, maximally. Rejected because it breaks pagination outright: a black hole could appear on two pages or none, and the count of black holes — which *is* public by design — would stop being stable within one listing. |
|
|
138
|
+
| Drop the anonymous rows from the feed entirely | Contradicts ADR 0182 D2a, which exposes existence on purpose. A black hole is a deliberate feature of the map. |
|
|
139
|
+
| Keep `updated_at` but bucket it coarsely (e.g. by week) | Still a timing channel, only quieter, and it would need a defensible bucket width nobody could justify. A sort key that must be blurred is a sort key that should not be there. |
|
|
140
|
+
|
|
141
|
+
## 6. What this does NOT close
|
|
142
|
+
|
|
143
|
+
Stated plainly, because a privacy fix that oversells itself is worse than none:
|
|
144
|
+
|
|
145
|
+
1. **Existence and count are public by design.** The number of black holes on the map is
|
|
146
|
+
visible and always was — that is ADR 0182 D2a, not a defect.
|
|
147
|
+
2. **The identified set still changes when a project goes stealth.** An observer diffing
|
|
148
|
+
yesterday's feed against today's sees a named project leave the identified block while
|
|
149
|
+
the black-hole count rises by one, and can name it that way. This is a **larger** channel
|
|
150
|
+
than the ordering one and it is untouched here: it is inherent in publishing a named list
|
|
151
|
+
at all, and closing it needs a different mechanism (a delay, a decoy, or accepting that a
|
|
152
|
+
project's *departure* from the public list is public). Filed separately rather than
|
|
153
|
+
silently implied to be fixed.
|
|
154
|
+
3. **Position is stable within one epoch.** Two snapshots taken the same day can still tell
|
|
155
|
+
black holes apart, though not name them. The rotation bounds this to the epoch.
|
|
156
|
+
|
|
157
|
+
## 7. Consequences
|
|
158
|
+
|
|
159
|
+
- The map's identified rows now sort by **listing date**, not last-touched. The feed stops
|
|
160
|
+
churning on `updated_at` bumps that meant nothing to a reader anyway.
|
|
161
|
+
- `<redacted>` is renamed `PUBLIC_FEED_MAX_PAGE_SIZE`, which is what it now is:
|
|
162
|
+
a per-request fan-out ceiling that doubles as the default page size.
|
|
163
|
+
- `listPublicProjects({ pool, limit, offset, now })` clamps its own window as well as the
|
|
164
|
+
route clamping it, because it is a module-public function and the ceiling is a fan-out
|
|
165
|
+
bound — it may not depend on every future caller remembering to pass one.
|
|
166
|
+
- Proof lives in `tests/visible_projects_feed.mjs`, which now pins the guards structurally:
|
|
167
|
+
the first sort term partitions the blocks, every identified key is guarded, there is no
|
|
168
|
+
fifth unguarded term, `updated_at` appears nowhere in the query, and the salt is bound as
|
|
169
|
+
a parameter rather than interpolated.
|
|
@@ -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.
|