@bongos/core 1.19.513 → 1.19.514
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 +28 -18
- package/docs/adr/0182-project-visibility-two-axes.md +2 -0
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/platform-identity.js +24 -20
- package/modules/platform-identity/projects-feed.js +142 -0
- package/modules/platform-identity/routes/projects.js +27 -8
- package/modules/platform-identity/tests/platform-identity.mjs +9 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/projects_hub.mjs +5 -1
- package/tests/visible_projects_feed.mjs +320 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.514",
|
|
6
|
+
"core_contract": "1.19.514",
|
|
7
|
+
"source_commit": "b092f2a3e4ee99d6f856ae4d1440ed723f1dcba8",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-03T19:
|
|
9
|
+
"built_at": "2026-09-03T19:32:24.004Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 430,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1958,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2412,
|
|
20
|
+
"tree_sha256": "4ea8c8583161c137a8be9e3d96b1db2589a64bd504a47f7e75ebeb15232753d2",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1407,7 +1407,7 @@
|
|
|
1407
1407
|
{
|
|
1408
1408
|
"path": "docs/adr/0182-project-visibility-two-axes.md",
|
|
1409
1409
|
"mode": "0000644",
|
|
1410
|
-
"sha256": "
|
|
1410
|
+
"sha256": "273133f1ce79a6e67b3beaa6d993c7aa3dda23daba0beeb2e9aedf9250152e6f"
|
|
1411
1411
|
},
|
|
1412
1412
|
{
|
|
1413
1413
|
"path": "docs/adr/0183-criteria-close-themselves.md",
|
|
@@ -1712,12 +1712,12 @@
|
|
|
1712
1712
|
{
|
|
1713
1713
|
"path": "docs/api-reference.md",
|
|
1714
1714
|
"mode": "0000644",
|
|
1715
|
-
"sha256": "
|
|
1715
|
+
"sha256": "36baedec0a98a17dd013bf264cdb5502624de9529ed62e69aa88590574b1b236"
|
|
1716
1716
|
},
|
|
1717
1717
|
{
|
|
1718
1718
|
"path": "docs/api/openapi.json",
|
|
1719
1719
|
"mode": "0000644",
|
|
1720
|
-
"sha256": "
|
|
1720
|
+
"sha256": "718e7ab37e024cce51fb82da0b3a535aff961d04fb0dc5e25b8ec31a61e64851"
|
|
1721
1721
|
},
|
|
1722
1722
|
{
|
|
1723
1723
|
"path": "docs/architecture.md",
|
|
@@ -2592,7 +2592,7 @@
|
|
|
2592
2592
|
{
|
|
2593
2593
|
"path": "docs/module-api-changelog.md",
|
|
2594
2594
|
"mode": "0000644",
|
|
2595
|
-
"sha256": "
|
|
2595
|
+
"sha256": "decebcba9ee02ff15412e119309d9a37203ee82159922157b37c7cd7ddc21926"
|
|
2596
2596
|
},
|
|
2597
2597
|
{
|
|
2598
2598
|
"path": "docs/modules-contract.md",
|
|
@@ -5817,7 +5817,7 @@
|
|
|
5817
5817
|
{
|
|
5818
5818
|
"path": "modules/platform-identity/platform-identity.js",
|
|
5819
5819
|
"mode": "0000644",
|
|
5820
|
-
"sha256": "
|
|
5820
|
+
"sha256": "c58dad2f4aee4030bfc2e93904760f70185c799b388a20d715ebe70812b942ed"
|
|
5821
5821
|
},
|
|
5822
5822
|
{
|
|
5823
5823
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -5829,6 +5829,11 @@
|
|
|
5829
5829
|
"mode": "0000644",
|
|
5830
5830
|
"sha256": "2e898799632a2b5c2ead9cb44fa0b8029fcb6d217f7d5f26c8a39b34c75bbea7"
|
|
5831
5831
|
},
|
|
5832
|
+
{
|
|
5833
|
+
"path": "modules/platform-identity/projects-feed.js",
|
|
5834
|
+
"mode": "0000644",
|
|
5835
|
+
"sha256": "7ba4e7f2a375a1197524106605ff5dd6e4e597b2926bf6498f784ede5099a447"
|
|
5836
|
+
},
|
|
5832
5837
|
{
|
|
5833
5838
|
"path": "modules/platform-identity/publish-gate.js",
|
|
5834
5839
|
"mode": "0000644",
|
|
@@ -5857,7 +5862,7 @@
|
|
|
5857
5862
|
{
|
|
5858
5863
|
"path": "modules/platform-identity/routes/projects.js",
|
|
5859
5864
|
"mode": "0000644",
|
|
5860
|
-
"sha256": "
|
|
5865
|
+
"sha256": "ec297f39a3d7f23b05b24ea6837bb9bfd07a12f99734788e9332769c59acc7b7"
|
|
5861
5866
|
},
|
|
5862
5867
|
{
|
|
5863
5868
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
@@ -5887,7 +5892,7 @@
|
|
|
5887
5892
|
{
|
|
5888
5893
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
5889
5894
|
"mode": "0000644",
|
|
5890
|
-
"sha256": "
|
|
5895
|
+
"sha256": "a0ed65254f629cc29f4f1093c54d5e78a148d5bc7f072fb98819562a96f7dd37"
|
|
5891
5896
|
},
|
|
5892
5897
|
{
|
|
5893
5898
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -7257,12 +7262,12 @@
|
|
|
7257
7262
|
{
|
|
7258
7263
|
"path": "package-lock.json",
|
|
7259
7264
|
"mode": "0000644",
|
|
7260
|
-
"sha256": "
|
|
7265
|
+
"sha256": "9c126ed4b632afd9452b01fa9e08eb97295699e99efc9c7d4040a761cac7f58d"
|
|
7261
7266
|
},
|
|
7262
7267
|
{
|
|
7263
7268
|
"path": "package.json",
|
|
7264
7269
|
"mode": "0000644",
|
|
7265
|
-
"sha256": "
|
|
7270
|
+
"sha256": "7cc213de070341042dae24c32b6fc458ae6912399a715d50b18dd92c52548fd8"
|
|
7266
7271
|
},
|
|
7267
7272
|
{
|
|
7268
7273
|
"path": "public-docs/index.html",
|
|
@@ -8932,7 +8937,7 @@
|
|
|
8932
8937
|
{
|
|
8933
8938
|
"path": "src/module-api.js",
|
|
8934
8939
|
"mode": "0000644",
|
|
8935
|
-
"sha256": "
|
|
8940
|
+
"sha256": "ace7f9365df0b8019d1fbc1758832db878c9b00ff59583a5ddcd839bbb69b1eb"
|
|
8936
8941
|
},
|
|
8937
8942
|
{
|
|
8938
8943
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11137,7 +11142,7 @@
|
|
|
11137
11142
|
{
|
|
11138
11143
|
"path": "tests/projects_hub.mjs",
|
|
11139
11144
|
"mode": "0000644",
|
|
11140
|
-
"sha256": "
|
|
11145
|
+
"sha256": "22461d4febf616202af96aa37fc940a686841bbf68155094526455ae85ab9886"
|
|
11141
11146
|
},
|
|
11142
11147
|
{
|
|
11143
11148
|
"path": "tests/projects_hub_pre_uat.mjs",
|
|
@@ -12019,6 +12024,11 @@
|
|
|
12019
12024
|
"mode": "0000644",
|
|
12020
12025
|
"sha256": "2a381bab380342e4fc556211d4821c85fdde1f676a6c5c6aa2421a89a573cd27"
|
|
12021
12026
|
},
|
|
12027
|
+
{
|
|
12028
|
+
"path": "tests/visible_projects_feed.mjs",
|
|
12029
|
+
"mode": "0000644",
|
|
12030
|
+
"sha256": "4bf47b6aee92c54099c583726d05421a433ac5b5e82792a848d11c80d7ee7bd1"
|
|
12031
|
+
},
|
|
12022
12032
|
{
|
|
12023
12033
|
"path": "tests/wandering_prefs.mjs",
|
|
12024
12034
|
"mode": "0000644",
|
|
@@ -161,6 +161,8 @@ Until then: `stealth` unqualified in product code and copy means the project sta
|
|
|
161
161
|
- Anything reading `featured = true AND status = 'active'` as "is this public" is now wrong and must be found. `modules/platform-identity/routes/projects.js` is the known site.
|
|
162
162
|
- **R16 makes D3's bound real, and D4's gate reach a second module.** Shipping R16 (task 1002335) surfaced one thing this ADR left implicit. D3 says `featured` may never put an unpublishable project on the map, so `PATCH /projects/:id/featured` now refuses a dark-matter row (409 `not_publishable`, `missing` in the details); un-featuring stays unconditional, since an already-featured husk must remain removable. And D4's "name what is missing" is owed to the owner *before* their project is ever catalogued on the hub — which is the `provisioning` plane, a different module that ADR 0083 forbids from importing `platform-identity`. Rather than let a two-field rule exist twice, the gate is published as a kernel seam port, `project.publishGate` (`modules/platform-identity/publish-gate.js`). `provisioning` resolves it **optionally** and declares no `consumes`: `platform-identity` is hub-only, the public map is a hub concept, and a platform without one must answer "no verdict" rather than invent a local rule — which is the same drift-avoidance argument that produced `publishableSql` in the first place.
|
|
163
163
|
|
|
164
|
+
- **R06 landed the predicate swap and the redaction, and settled three things this ADR left to the implementer** (task 1002325, `modules/platform-identity/projects-feed.js`). (a) **A black hole carries no stable per-project key.** D2a lists `name`/`tagline`/`art_url`/`origin`, and an id looks harmless beside them — but any identifier that survives a state change lets an observer who kept yesterday's public feed diff it against today's and *name* the project that went stealth. So stealth rows are byte-identical to one another and the feed grew no public id at all; the orbs page keys by position. (b) **The redaction is an ALLOW-list, not a deny-list** — every key nulled except `state` and `orb` — so a column added to the feed later is anonymous without anyone remembering to come back to it, and it NULLS rather than deletes, keeping the feed's "never drop a key" contract. (c) **An unrecognised `visibility` draws as a black hole, not a planet**: this mapping is a privacy boundary, so a fourth enum value added without mapping it must fail closed. Two smaller notes: the route PATH keeps the word `featured` (it is rank:public and CORS-open, already fetched by a static site and other origins — renaming it would break them for nothing, and `ORDER BY featured DESC` is where the flag's D3 ranking job now lives); and `getRollupForBuilder`'s `is_public` was deliberately left on the OLD `featured AND active` predicate rather than widened with the feed, because a stealth project is on the map *anonymously* and naming it in a builder's profile would be the exact leak D2a forbids — how much of a cross-project record may be NAMED is ADR 0171 D2's question, and under-naming is the safe direction meanwhile.
|
|
165
|
+
|
|
164
166
|
## Alternatives rejected
|
|
165
167
|
|
|
166
168
|
- **A stored four-valued enum including `unlisted`.** Rejected: it makes "unlisted" a choice competing with `private`, and it lets a project be simultaneously "publishable" and "unlisted" — two sources of truth for one question, which is the bug the derived gate prevents by construction.
|
package/docs/api/openapi.json
CHANGED
|
@@ -10786,7 +10786,7 @@
|
|
|
10786
10786
|
"projects"
|
|
10787
10787
|
],
|
|
10788
10788
|
"summary": "GET /projects/featured",
|
|
10789
|
-
"description": "
|
|
10789
|
+
"description": "the public map of projects; no auth by design.\n\n**Rank:** `public` — No authentication — any caller.",
|
|
10790
10790
|
"x-rank": "public",
|
|
10791
10791
|
"x-source": "modules/platform-identity/routes/projects.js",
|
|
10792
10792
|
"responses": {
|
package/docs/api-reference.md
CHANGED
|
@@ -439,7 +439,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
439
439
|
| GET | `/api/bongos/projects` | `metic+archon` | — | the full curation list (includes unfeatured + owner attribution). |
|
|
440
440
|
| POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `description`, `art_url`, `type`, `template` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
|
|
441
441
|
| PATCH | `/api/bongos/projects/:id/featured` | `metic+archon` | `featured` | the feature / unfeature control (the ADR 0141 §5 curation gate), BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). |
|
|
442
|
-
| GET | `/api/bongos/projects/featured` | `public` | — |
|
|
442
|
+
| GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
|
|
443
443
|
| POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
|
|
444
444
|
|
|
445
445
|
## `provisioning` (21)
|
|
@@ -1463,5 +1463,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1463
1463
|
landed since 1.19.511 with no explicit bump. run 33793208383. (task 1002620)
|
|
1464
1464
|
1.19.513 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1465
1465
|
landed since 1.19.512 with no explicit bump. run 33795977371. (task 1002620)
|
|
1466
|
+
1.19.514 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1467
|
+
landed since 1.19.513 with no explicit bump. run 33796959752. (task 1002620)
|
|
1466
1468
|
---------------------------------------------------------------------------
|
|
1467
1469
|
```
|
|
@@ -313,6 +313,13 @@ const {
|
|
|
313
313
|
publishGatePort,
|
|
314
314
|
} = require('./publish-gate');
|
|
315
315
|
|
|
316
|
+
// The public projects feed — who is on the map, and what each row may say (ADR 0182
|
|
317
|
+
// D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
|
|
318
|
+
// file is at the size ratchet. Re-exported below, so the surface is unchanged.
|
|
319
|
+
const {
|
|
320
|
+
ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects,
|
|
321
|
+
} = require('./projects-feed');
|
|
322
|
+
|
|
316
323
|
// Upsert a catalog row (keyed by origin). Called when a client registers (so every
|
|
317
324
|
// federated project is catalogued, unfeatured) and by the Archon create/curate route.
|
|
318
325
|
// Never touches `featured` on update — that is Archon-only via setProjectFeatured.
|
|
@@ -360,21 +367,9 @@ async function getProject(id, { pool = defaultPool } = {}) {
|
|
|
360
367
|
return { ...rows[0], ...projectPublishability(rows[0]) };
|
|
361
368
|
}
|
|
362
369
|
|
|
363
|
-
// The PUBLIC
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// card labels it "Listed" for exactly that reason (task 1002778). updated_at may
|
|
367
|
-
// sort but is never projected: feature toggles and client re-registration bump
|
|
368
|
-
// it, so as a public "last updated" it would lie.
|
|
369
|
-
async function listFeaturedProjects({ pool = defaultPool } = {}) {
|
|
370
|
-
const { rows } = await pool.query(
|
|
371
|
-
`SELECT origin, name, tagline, art_url, type, template, created_at
|
|
372
|
-
FROM platform_identity_projects
|
|
373
|
-
WHERE featured = true AND status = 'active'
|
|
374
|
-
ORDER BY updated_at DESC`,
|
|
375
|
-
);
|
|
376
|
-
return rows;
|
|
377
|
-
}
|
|
370
|
+
// The PUBLIC projects feed lives in projects-feed.js (R06, task 1002325) — the
|
|
371
|
+
// predicate, the orb mapping and the stealth redaction are one concern, and each is
|
|
372
|
+
// only safe given the other two.
|
|
378
373
|
|
|
379
374
|
// The curation list — every active project, its featured flag, its stored door, and the
|
|
380
375
|
// DERIVED publish verdict. `publishable` is computed in SQL from publishableSql() so this
|
|
@@ -779,10 +774,15 @@ function isNumericGithubId(githubId) {
|
|
|
779
774
|
// table, the disciplines union computed separately (never a pre-GROUP-BY unnest
|
|
780
775
|
// fan-out — bug task 1002911). Per-project rows join the module-internal
|
|
781
776
|
// client_id → sso_clients.origin → projects.origin two-hop; a project is NAMED
|
|
782
|
-
// only when its catalog row is publicly listed today (featured + active
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
777
|
+
// only when its catalog row is publicly listed today (featured + active).
|
|
778
|
+
// Everything else is counted in more_projects, never named ("the aggregate is
|
|
779
|
+
// not the leak, the name is").
|
|
780
|
+
//
|
|
781
|
+
// Deliberately NARROWER than the public feed since R06 (task 1002325), and left so
|
|
782
|
+
// rather than widened here: a STEALTH project is on the map ANONYMOUSLY (ADR 0182
|
|
783
|
+
// D2a), so naming it in a builder's profile would be the leak the feed exists to
|
|
784
|
+
// prevent, and how much of a record may be NAMED is ADR 0171 D2's question, not
|
|
785
|
+
// R06's. Narrower under-names, which is safe. The widening is its own task.
|
|
786
786
|
// client_id never appears in the returned shape.
|
|
787
787
|
async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
788
788
|
if (!isNumericGithubId(githubId)) return null;
|
|
@@ -1436,9 +1436,13 @@ module.exports = {
|
|
|
1436
1436
|
PROJECT_MATERIALS,
|
|
1437
1437
|
PROJECT_SHAPES,
|
|
1438
1438
|
getProject,
|
|
1439
|
-
|
|
1439
|
+
listPublicProjects,
|
|
1440
1440
|
listAllProjects,
|
|
1441
1441
|
setProjectFeatured,
|
|
1442
|
+
ORB_BY_STATE,
|
|
1443
|
+
orbForState,
|
|
1444
|
+
isStealthProject,
|
|
1445
|
+
feedShapeFor,
|
|
1442
1446
|
// memberships
|
|
1443
1447
|
MEMBERSHIP_KINDS,
|
|
1444
1448
|
membershipKindFromLegacy,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// modules/platform-identity/projects-feed.js — THE public projects feed: WHO is on
|
|
2
|
+
// the map, and WHAT each row is allowed to say (R06, task 1002325; ADR 0182 D1/D2a/D3).
|
|
3
|
+
// The in-repo lever the orbs page reads. The publish-gate.js precedent — one question,
|
|
4
|
+
// one file, so no two surfaces can answer it differently.
|
|
5
|
+
//
|
|
6
|
+
// THREE ANSWERS, and they belong together because each is only safe given the others:
|
|
7
|
+
//
|
|
8
|
+
// • WHO — `listPublicProjects`. A project is on the map iff `status = 'active' AND
|
|
9
|
+
// publishable`, the derived identity gate, taken from publishableSql() so this feed
|
|
10
|
+
// and the curation list read ONE definition. It used to be `featured = true`, which
|
|
11
|
+
// made Archon curation load-bearing for visibility at all: an owner could set their
|
|
12
|
+
// project public and see nothing happen (ADR 0182 D3). `featured` keeps the only job
|
|
13
|
+
// D3 leaves it — RANKING among rows already on the map. Dark matter is excluded HERE,
|
|
14
|
+
// in SQL, because a client-side filter ships the husk to anyone who opens a network tab.
|
|
15
|
+
//
|
|
16
|
+
// • WHAT IT DRAWS AS — `orbForState`, the ADR 0182 D1 mapping:
|
|
17
|
+
// public → planet (identified, anyone may join)
|
|
18
|
+
// private → star (identified, no self-join, applications accepted)
|
|
19
|
+
// stealth → black hole (ANONYMOUS, no self-join, applications refused)
|
|
20
|
+
// Three surfaces render it — this feed, Explore (R07, task 1002326) and the static
|
|
21
|
+
// orbs site (R09, task 1002328) — and the last is a different repo. A mapping copied
|
|
22
|
+
// into each is a mapping that drifts.
|
|
23
|
+
//
|
|
24
|
+
// • WHAT IT MAY SAY — `feedShapeFor`, the D2a privacy boundary in code: a stealth
|
|
25
|
+
// project exposes EXISTENCE, never IDENTITY. Redaction is server-side because the
|
|
26
|
+
// naive version sends the row and lets the orbs page hide it, which leaks the whole
|
|
27
|
+
// project to anyone who opens the network tab — and that page is a static site
|
|
28
|
+
// outside this repo, so we could not enforce it there even in principle.
|
|
29
|
+
//
|
|
30
|
+
// TWO DELIBERATE FAIL-CLOSED CHOICES, because this is a privacy boundary, not a
|
|
31
|
+
// rendering detail:
|
|
32
|
+
// 1. AN UNKNOWN STATE DRAWS AS A BLACK HOLE. A fourth enum value added later and not
|
|
33
|
+
// mapped here would otherwise ship its project's name as a planet.
|
|
34
|
+
// 2. THE STEALTH SHAPE IS AN ALLOW-LIST, NOT A DENY-LIST — everything is nulled except
|
|
35
|
+
// the two keys a black hole may carry, so a column added to the feed next year is
|
|
36
|
+
// anonymous without anyone remembering to come back here. A deny-list of "the
|
|
37
|
+
// identifying fields" is correct exactly until the feed grows.
|
|
38
|
+
//
|
|
39
|
+
// WHY ITS OWN FILE. The account-visibility.js / publish-gate.js reason: platform-identity.js
|
|
40
|
+
// sits at the file-size ratchet. The caller-facing symbols (listPublicProjects, feedShapeFor,
|
|
41
|
+
// orbForState, isStealthProject, ORB_BY_STATE) are re-exported there so the module's public
|
|
42
|
+
// surface is unchanged; orbStateOf, FALLBACK_PROJECT_STATE and STEALTH_ANONYMOUS_KEEPS are
|
|
43
|
+
// this file's own internals and are reached only from here and its test.
|
|
44
|
+
// Proof: ../../tests/visible_projects_feed.mjs
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const { pool: defaultPool } = require('../../src/module-api');
|
|
48
|
+
const { publishableSql } = require('./publish-gate');
|
|
49
|
+
|
|
50
|
+
// ADR 0182 D1's mapping, and the only copy of it in the repo. The three words are locked
|
|
51
|
+
// by goal 1000046's `proj-visibility-states` criterion, not chosen here.
|
|
52
|
+
const ORB_BY_STATE = Object.freeze({
|
|
53
|
+
public: 'planet',
|
|
54
|
+
private: 'star',
|
|
55
|
+
stealth: 'black-hole',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// What an unrecognised (or absent) stored value is treated as — the ANONYMOUS state,
|
|
59
|
+
// deliberately. A project whose door we cannot read is one whose name we must not publish.
|
|
60
|
+
const FALLBACK_PROJECT_STATE = 'stealth';
|
|
61
|
+
|
|
62
|
+
// The ONLY keys a black hole may carry; everything else on the row is nulled. No stable
|
|
63
|
+
// per-project key appears here on purpose: an id, a slug or a hash of the origin would
|
|
64
|
+
// each let an observer who kept yesterday's public feed name the project that went stealth
|
|
65
|
+
// today. Black holes are interchangeable, which is the whole point of the metaphor.
|
|
66
|
+
const STEALTH_ANONYMOUS_KEEPS = Object.freeze(['state', 'orb']);
|
|
67
|
+
|
|
68
|
+
// The stored join door, normalised to a state this file knows. Never echoes an
|
|
69
|
+
// unvalidated value onto the wire.
|
|
70
|
+
function orbStateOf(visibility) {
|
|
71
|
+
const v = String(visibility ?? '').trim();
|
|
72
|
+
return Object.prototype.hasOwnProperty.call(ORB_BY_STATE, v) ? v : FALLBACK_PROJECT_STATE;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The render type for a state — the one mapping R07 and R09 both read. PURE.
|
|
76
|
+
function orbForState(state) {
|
|
77
|
+
return ORB_BY_STATE[orbStateOf(state)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Is this row anonymous on the map? Exported so the feed route can keep a stealth
|
|
81
|
+
// project's origin out of the per-project enrichment fetches entirely — asking its own
|
|
82
|
+
// public API for its builders' avatars would both put identity on the wire and tell that
|
|
83
|
+
// project it was being enumerated.
|
|
84
|
+
function isStealthProject(project) {
|
|
85
|
+
return orbStateOf(project?.visibility) === 'stealth';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ONE catalog row → the public wire row. Spends `visibility` deriving `state` + `orb` (it
|
|
89
|
+
// never doubles it on the wire), then either passes the identified row through or rebuilds
|
|
90
|
+
// it from the allow-list above. The redaction NULLS rather than DELETES: the feed's
|
|
91
|
+
// standing contract is that an unanswerable value is null and never absent, because
|
|
92
|
+
// JSON.stringify drops the absent one and a client would read `undefined` on some rows and
|
|
93
|
+
// null on others. A redacted row is a full-width row that says nothing. PURE.
|
|
94
|
+
function feedShapeFor(project) {
|
|
95
|
+
const { visibility, ...rest } = project || {};
|
|
96
|
+
const state = orbStateOf(visibility);
|
|
97
|
+
const shaped = { ...rest, state, orb: ORB_BY_STATE[state] };
|
|
98
|
+
if (state !== 'stealth') return shaped;
|
|
99
|
+
const anonymous = {};
|
|
100
|
+
for (const key of Object.keys(shaped)) {
|
|
101
|
+
anonymous[key] = STEALTH_ANONYMOUS_KEEPS.includes(key) ? shaped[key] : null;
|
|
102
|
+
}
|
|
103
|
+
return anonymous;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The feed's hard ceiling. Until R06 the listing was bounded by an Archon choosing to
|
|
107
|
+
// feature a project; the swap to `publishable` replaced that human bound with self-service,
|
|
108
|
+
// on a route that is public, CORS-open, unauthenticated AND drives one outbound stats fetch
|
|
109
|
+
// per row. A cap is the cheap half of that trade — the honest half is pagination, which is
|
|
110
|
+
// its own task; this constant only stops the tail from being unbounded meanwhile. Set far
|
|
111
|
+
// above the live catalog, so it truncates nothing today.
|
|
112
|
+
const PUBLIC_FEED_MAX_PROJECTS = 500;
|
|
113
|
+
|
|
114
|
+
// Every project ON THE MAP, in the public projection (no owner_github_id, no status).
|
|
115
|
+
// `visibility` is projected because feedShapeFor() derives each row's state + orb from it;
|
|
116
|
+
// it is spent there and never reaches the wire. created_at is the LISTED date — when the
|
|
117
|
+
// hub first catalogued the row, not when the project began (task 1002778). updated_at may
|
|
118
|
+
// sort but never projects: feature toggles and client re-registration bump it, so as a
|
|
119
|
+
// public "last updated" it would lie. `description` is the publish BAR, spent in the
|
|
120
|
+
// predicate, never a card field.
|
|
121
|
+
async function listPublicProjects({ pool = defaultPool } = {}) {
|
|
122
|
+
const { rows } = await pool.query(
|
|
123
|
+
`SELECT origin, name, tagline, art_url, type, template, created_at, visibility
|
|
124
|
+
FROM platform_identity_projects
|
|
125
|
+
WHERE status = 'active' AND ${publishableSql()}
|
|
126
|
+
ORDER BY featured DESC, updated_at DESC
|
|
127
|
+
LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
|
|
128
|
+
);
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
ORB_BY_STATE,
|
|
134
|
+
FALLBACK_PROJECT_STATE,
|
|
135
|
+
STEALTH_ANONYMOUS_KEEPS,
|
|
136
|
+
orbStateOf,
|
|
137
|
+
orbForState,
|
|
138
|
+
isStealthProject,
|
|
139
|
+
feedShapeFor,
|
|
140
|
+
PUBLIC_FEED_MAX_PROJECTS,
|
|
141
|
+
listPublicProjects,
|
|
142
|
+
};
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// modules/platform-identity/routes/projects.js — the hub projects catalog + the
|
|
2
2
|
// curated public Explore showcase (ADR 0141 §5, task #1002110).
|
|
3
3
|
//
|
|
4
|
-
// GET /projects/featured — PUBLIC
|
|
4
|
+
// GET /projects/featured — PUBLIC projects feed: every project on the map
|
|
5
|
+
// (active + publishable), each carrying its `state`
|
|
6
|
+
// and `orb` render type, stealth rows anonymised
|
|
7
|
+
// server-side, dark matter excluded (ADR 0182 D2a/D3).
|
|
8
|
+
// The PATH keeps the old word: it is a rank:public,
|
|
9
|
+
// CORS-open URL that a static site and other origins
|
|
10
|
+
// already fetch, and renaming it would break them for
|
|
11
|
+
// nothing. `featured` now only RANKS the feed.
|
|
5
12
|
// GET /projects — Archon: full curation list (+ featured flags, the stored
|
|
6
13
|
// join door, and the DERIVED publish verdict — ADR 0182)
|
|
7
14
|
// POST /projects — Archon: create / update a catalog entry
|
|
@@ -141,13 +148,20 @@ module.exports = function projectsRoutes() {
|
|
|
141
148
|
}
|
|
142
149
|
});
|
|
143
150
|
|
|
144
|
-
// The PUBLIC
|
|
145
|
-
// CORS-enabled cross-origin read (a status page / the apex
|
|
151
|
+
// The PUBLIC projects feed — every project ON THE MAP (active + publishable),
|
|
152
|
+
// public projection. CORS-enabled cross-origin read (a status page / the apex /
|
|
153
|
+
// the static orbs site fetches it pre-auth).
|
|
146
154
|
router.use('/projects/featured', corsPublicGet('/projects/featured'));
|
|
147
|
-
// rank: public —
|
|
155
|
+
// rank: public — the public map of projects; no auth by design.
|
|
148
156
|
router.get('/projects/featured', async (_req, res) => {
|
|
149
157
|
try {
|
|
150
|
-
const projects = await pi.
|
|
158
|
+
const projects = await pi.listPublicProjects({ pool });
|
|
159
|
+
// A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
|
|
160
|
+
// is kept out of both enrichments below: asking its own public API for its
|
|
161
|
+
// builders and their avatars would put identity on the wire, and tell that
|
|
162
|
+
// project it was being enumerated. feedShapeFor() redacts the row either
|
|
163
|
+
// way — this is the half that never makes the request at all.
|
|
164
|
+
const identified = projects.filter((p) => !pi.isStealthProject(p));
|
|
151
165
|
// Builders + tags come from each project's OWN public API, because the
|
|
152
166
|
// apex catalog does not know them and the membership tables would report
|
|
153
167
|
// zero for every hand-wired project (task 1002771; see project-stats.js).
|
|
@@ -156,7 +170,7 @@ module.exports = function projectsRoutes() {
|
|
|
156
170
|
// listing — a broken stats fetch cannot take the catalog down with it.
|
|
157
171
|
let stats = {};
|
|
158
172
|
try {
|
|
159
|
-
stats = await projectStats.getProjectStats(
|
|
173
|
+
stats = await projectStats.getProjectStats(identified.map((p) => p.origin));
|
|
160
174
|
} catch (err) {
|
|
161
175
|
console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
|
|
162
176
|
}
|
|
@@ -168,7 +182,7 @@ module.exports = function projectsRoutes() {
|
|
|
168
182
|
// a failed read leaves every project on the platform's own pick.
|
|
169
183
|
let planets = {};
|
|
170
184
|
try {
|
|
171
|
-
planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(
|
|
185
|
+
planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(identified.map((p) => hostOfOrigin(p.origin)));
|
|
172
186
|
} catch (err) {
|
|
173
187
|
log.warn('project planets enrichment failed (non-fatal):', err.message);
|
|
174
188
|
}
|
|
@@ -183,8 +197,13 @@ module.exports = function projectsRoutes() {
|
|
|
183
197
|
// catalog's; a project that did not answer carries the ONE shared null
|
|
184
198
|
// shape, so the wire never drops a key. art_url is catalog text an Archon
|
|
185
199
|
// typed — scheme-checked here, and again on the client, before it is a src.
|
|
200
|
+
// feedShapeFor() is the LAST thing each row passes through, deliberately: it
|
|
201
|
+
// derives `state` + `orb` from the stored door and, for a stealth project,
|
|
202
|
+
// rebuilds the row from an allow-list of the two keys a black hole may carry
|
|
203
|
+
// (ADR 0182 D2a). Redacting any earlier would be silently undone by the
|
|
204
|
+
// spreads above.
|
|
186
205
|
res.json({
|
|
187
|
-
projects: projects.map((p) => ({
|
|
206
|
+
projects: projects.map((p) => pi.feedShapeFor({
|
|
188
207
|
...p,
|
|
189
208
|
art_url: projectStats.httpUrl(p.art_url, 2048),
|
|
190
209
|
...(stats[p.origin] || projectStats.NULL_STATS),
|
|
@@ -261,17 +261,21 @@ await ta('upsertProject carries the planet template, COALESCE-preserved; an unkn
|
|
|
261
261
|
for (const t of P.PROJECT_TEMPLATES.slice(0, 9)) assert.ok(mig12.includes(`'${t}'`), `migration 012 named ${t}`);
|
|
262
262
|
// and the public projection carries it, so the sky can read a curated choice
|
|
263
263
|
const pool3 = mockPool([{ rows: [] }]);
|
|
264
|
-
await P.
|
|
264
|
+
await P.listPublicProjects({ pool: pool3 });
|
|
265
265
|
const sel = (pool3.calls[0].text.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
|
|
266
|
-
assert.match(sel, /\btemplate\b/, 'the
|
|
266
|
+
assert.match(sel, /\btemplate\b/, 'the public projection carries the template');
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
-
await ta('
|
|
269
|
+
await ta('listPublicProjects returns a PUBLIC projection (on-the-map only, no owner/status)', async () => {
|
|
270
270
|
const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null, created_at: '2026-08-01T00:00:00Z' }] }]);
|
|
271
|
-
const out = await P.
|
|
271
|
+
const out = await P.listPublicProjects({ pool });
|
|
272
272
|
assert.equal(out.length, 1);
|
|
273
273
|
const c = pool.calls[0];
|
|
274
|
-
|
|
274
|
+
// R06 (task 1002325) replaced the `featured = true` gate with the derived publish
|
|
275
|
+
// gate: a project is on the map iff it is active AND publishable (ADR 0182 D3).
|
|
276
|
+
// The full predicate + the stealth redaction are pinned by tests/visible_projects_feed.mjs.
|
|
277
|
+
assert.match(c.text, /WHERE status = 'active' AND /);
|
|
278
|
+
assert.ok(c.text.includes(P.publishableSql()), 'the map predicate is the shared gate');
|
|
275
279
|
// Pin the SELECT LIST itself, not the whole query — an ORDER BY mention must
|
|
276
280
|
// not satisfy a projection guard, and SELECT * must never sneak owner/status
|
|
277
281
|
// onto a CORS-open rank:public route.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.514",
|
|
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.514",
|
|
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.514",
|
|
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/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.514'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1858,7 +1858,11 @@ test('the kit proves the picker: a state renders it on the owner\'s feed-carried
|
|
|
1858
1858
|
|
|
1859
1859
|
test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads every platform-drawn key on every project (tasks 1003347, 1003352)', () => {
|
|
1860
1860
|
const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
|
|
1861
|
-
|
|
1861
|
+
// R06 (task 1002325) narrowed the hosts asked about from `projects` to `identified`:
|
|
1862
|
+
// a stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its host is kept out
|
|
1863
|
+
// of this enrichment entirely rather than resolved and then redacted. The seam itself
|
|
1864
|
+
// is unchanged — still optional, still host-matched, still spread on every project.
|
|
1865
|
+
assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(identified\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
|
|
1862
1866
|
assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null,\r?\n\s*card_backdrop: c\.card_backdrop \|\| null \};/, 'the wire always carries every key — null = nothing chosen');
|
|
1863
1867
|
assert.match(route, /\.\.\.planetOf\(p\),/, 'spread beside the stats');
|
|
1864
1868
|
assert.doesNotMatch(route, /require\(['"][^'"]*provisioning/, 'never an import across modules');
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// tests/visible_projects_feed.mjs — the VISIBLE-PROJECTS FEED (task 1002325, BV1.R06;
|
|
2
|
+
// ADR 0182 D2a / D3).
|
|
3
|
+
//
|
|
4
|
+
// The feed is the in-repo lever the orbs page reads, and this file pins the three
|
|
5
|
+
// things R06 changed about it:
|
|
6
|
+
//
|
|
7
|
+
// 1. WHO IS ON THE MAP. The predicate stops being `featured = true` and becomes
|
|
8
|
+
// `status = 'active' AND publishable` — the publish gate R16 (task 1002335)
|
|
9
|
+
// already published as SQL. Dark matter (no name, or no description) is excluded
|
|
10
|
+
// SERVER-SIDE, so it never reaches a client that could choose to draw it.
|
|
11
|
+
// `featured` keeps a job — it RANKS among rows already on the map (D3) — and
|
|
12
|
+
// loses the one it should never have had, gating presence at all.
|
|
13
|
+
// 2. WHAT EACH ORB IS. Every row carries `state` (public|private|stealth) and `orb`
|
|
14
|
+
// (planet|star|black-hole), derived from ONE mapping so the feed, the Explore
|
|
15
|
+
// view (R07) and the static orbs site (R09) cannot each invent their own.
|
|
16
|
+
// 3. WHAT A BLACK HOLE MAY SAY. Stealth exposes EXISTENCE, never IDENTITY (D2a), and
|
|
17
|
+
// the redaction is at the boundary because the orbs page is a static site outside
|
|
18
|
+
// this repo and could not be trusted to redact even in principle.
|
|
19
|
+
//
|
|
20
|
+
// The stealth assertions are the security spec: they assert ABSENT FIELDS, not absent
|
|
21
|
+
// pixels, which is what R10 (task 1002329) then demonstrates live.
|
|
22
|
+
//
|
|
23
|
+
// DB-free: a recording fake pool for the SQL, and the REAL router driven with a fake
|
|
24
|
+
// req/res for the route composition.
|
|
25
|
+
//
|
|
26
|
+
// Run: node --test --test-reporter=tap tests/visible_projects_feed.mjs
|
|
27
|
+
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { test } from 'node:test';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
34
|
+
const feed = require('../modules/platform-identity/projects-feed.js');
|
|
35
|
+
const projectStats = require('../modules/platform-identity/project-stats.js');
|
|
36
|
+
const projectsRoutes = require('../modules/platform-identity/routes/projects.js');
|
|
37
|
+
|
|
38
|
+
// A recording fake pool: returns queued responses in order, records every call.
|
|
39
|
+
function mockPool(responses = []) {
|
|
40
|
+
const calls = [];
|
|
41
|
+
let i = 0;
|
|
42
|
+
return {
|
|
43
|
+
calls,
|
|
44
|
+
query: async (text, params) => {
|
|
45
|
+
calls.push({ text: String(text), params: params || [] });
|
|
46
|
+
return responses[i++] || { rows: [], rowCount: 0 };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── the mapping: one state, one orb (ADR 0182 D1) ────────────────────────────
|
|
52
|
+
|
|
53
|
+
test('orbForState maps each stored state to the orb ADR 0182 D1 names for it', () => {
|
|
54
|
+
// Arrange / Act / Assert — the mapping IS the assertion, and the criterion
|
|
55
|
+
// (`proj-visibility-states`) locks these three words.
|
|
56
|
+
assert.equal(feed.orbForState('public'), 'planet');
|
|
57
|
+
assert.equal(feed.orbForState('private'), 'star');
|
|
58
|
+
assert.equal(feed.orbForState('stealth'), 'black-hole');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the orb mapping is reached through the module surface, so no caller keeps a copy', () => {
|
|
62
|
+
// R07 (Explore) and R09 (the static orbs site) both render these; a second copy of
|
|
63
|
+
// the mapping is how two surfaces end up drawing one project two ways.
|
|
64
|
+
assert.equal(pi.orbForState, feed.orbForState);
|
|
65
|
+
assert.deepEqual({ ...pi.ORB_BY_STATE }, { public: 'planet', private: 'star', stealth: 'black-hole' });
|
|
66
|
+
assert.ok(Object.isFrozen(pi.ORB_BY_STATE), 'a caller must not be able to re-map an orb at runtime');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an unrecognised state falls back to the ANONYMOUS orb, never the identified one', () => {
|
|
70
|
+
// Fail-closed, because this mapping is a privacy boundary. A fourth enum value added
|
|
71
|
+
// later and not mapped here would otherwise ship its project's name as a planet; the
|
|
72
|
+
// safe degradation is to draw it as a black hole and say nothing about it.
|
|
73
|
+
assert.equal(feed.orbForState('members-only'), 'black-hole');
|
|
74
|
+
assert.equal(feed.orbForState(undefined), 'black-hole');
|
|
75
|
+
assert.equal(feed.orbForState(null), 'black-hole');
|
|
76
|
+
assert.equal(feed.orbForState(''), 'black-hole');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── the wire shape: identified for two states, anonymous for the third ───────
|
|
80
|
+
|
|
81
|
+
const PUBLIC_ROW = Object.freeze({
|
|
82
|
+
origin: 'https://mercury.example', name: 'Mercury', tagline: 'A weather sim.',
|
|
83
|
+
art_url: 'https://mercury.example/art.png', type: 'game', template: 'blob-tint',
|
|
84
|
+
created_at: '2026-08-01T00:00:00Z', visibility: 'public',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('feedShapeFor keeps a public project fully identified and marks it a planet', () => {
|
|
88
|
+
// Arrange
|
|
89
|
+
const row = { ...PUBLIC_ROW };
|
|
90
|
+
|
|
91
|
+
// Act
|
|
92
|
+
const wire = feed.feedShapeFor(row);
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
95
|
+
assert.equal(wire.state, 'public');
|
|
96
|
+
assert.equal(wire.orb, 'planet');
|
|
97
|
+
assert.equal(wire.name, 'Mercury');
|
|
98
|
+
assert.equal(wire.origin, 'https://mercury.example');
|
|
99
|
+
assert.equal(wire.tagline, 'A weather sim.');
|
|
100
|
+
assert.equal(wire.art_url, 'https://mercury.example/art.png');
|
|
101
|
+
assert.equal(wire.created_at, '2026-08-01T00:00:00Z');
|
|
102
|
+
assert.ok(!('visibility' in wire), 'the stored column is spent deriving `state`; it never doubles it on the wire');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a private project stays IDENTIFIED — a star is visible, only its door is closed', () => {
|
|
106
|
+
// ADR 0182 D2, inherited unchanged from ADR 0112's private goals: private means
|
|
107
|
+
// visible-but-closed. Redacting a star would be the bug, not the safe choice.
|
|
108
|
+
const wire = feed.feedShapeFor({ ...PUBLIC_ROW, visibility: 'private' });
|
|
109
|
+
assert.equal(wire.state, 'private');
|
|
110
|
+
assert.equal(wire.orb, 'star');
|
|
111
|
+
assert.equal(wire.name, 'Mercury');
|
|
112
|
+
assert.equal(wire.origin, 'https://mercury.example');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a stealth project ships NO identifying field — every one of them is null', () => {
|
|
116
|
+
// Arrange — a fully populated row, so nothing is absent by accident.
|
|
117
|
+
const row = { ...PUBLIC_ROW, visibility: 'stealth' };
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const wire = feed.feedShapeFor(row);
|
|
121
|
+
|
|
122
|
+
// Assert — ADR 0182 D2a names these four explicitly as fields that must not leave
|
|
123
|
+
// the server. Asserted BY NAME so the ADR's own list is executable.
|
|
124
|
+
for (const field of ['origin', 'name', 'tagline', 'art_url']) {
|
|
125
|
+
assert.equal(wire[field], null, `D2a: a stealth project's ${field} must not leave the server`);
|
|
126
|
+
}
|
|
127
|
+
// …and the general rule the four are only examples of: existence, never identity.
|
|
128
|
+
for (const [key, value] of Object.entries(wire)) {
|
|
129
|
+
if (key === 'state' || key === 'orb') continue;
|
|
130
|
+
assert.equal(value, null, `a black hole says nothing but that it is there — ${key} leaked`);
|
|
131
|
+
}
|
|
132
|
+
assert.equal(wire.state, 'stealth');
|
|
133
|
+
assert.equal(wire.orb, 'black-hole');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the wire never DROPS a key for a stealth row — it nulls it', () => {
|
|
137
|
+
// The feed's standing contract (the NULL_STATS shape): a value that could not be
|
|
138
|
+
// answered is null, never absent, because JSON.stringify drops the absent one and a
|
|
139
|
+
// client reading `p.builders` would then see `undefined` on some rows and null on
|
|
140
|
+
// others. Redaction must not become the one exception to that.
|
|
141
|
+
const wire = feed.feedShapeFor({ ...PUBLIC_ROW, visibility: 'stealth' });
|
|
142
|
+
for (const key of Object.keys(PUBLIC_ROW)) {
|
|
143
|
+
if (key === 'visibility') continue;
|
|
144
|
+
assert.ok(key in wire, `${key} must still be a KEY on the wire, carrying null`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a field the redactor has never seen is anonymous by DEFAULT, not by being listed', () => {
|
|
149
|
+
// The property that keeps this correct as the feed grows: the stealth shape is an
|
|
150
|
+
// ALLOW-list of what may be said (state, orb), not a deny-list of what must be
|
|
151
|
+
// hidden. A column added to the feed next year is redacted without anyone
|
|
152
|
+
// remembering to add it here — which is the only version of this that stays true.
|
|
153
|
+
const wire = feed.feedShapeFor({
|
|
154
|
+
...PUBLIC_ROW, visibility: 'stealth', owner_avatar: 'https://x/a.png', faces: ['https://x/b.png'],
|
|
155
|
+
some_field_invented_after_this_test: 'leak',
|
|
156
|
+
});
|
|
157
|
+
assert.equal(wire.owner_avatar, null);
|
|
158
|
+
assert.deepEqual(wire.faces, null);
|
|
159
|
+
assert.equal(wire.some_field_invented_after_this_test, null);
|
|
160
|
+
assert.deepEqual(Object.keys(wire).filter((k) => wire[k] !== null).sort(), ['orb', 'state']);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a black hole carries no stable key that yesterday\'s feed could be diffed against', () => {
|
|
164
|
+
// Deliberate: any per-project identifier that survives a state change (a row id, a
|
|
165
|
+
// slug, a hash of the origin) lets an observer who kept yesterday's public feed name
|
|
166
|
+
// the project that went stealth today. Black holes are interchangeable on purpose.
|
|
167
|
+
const a = feed.feedShapeFor({ ...PUBLIC_ROW, id: '7', visibility: 'stealth' });
|
|
168
|
+
const b = feed.feedShapeFor({ ...PUBLIC_ROW, id: '9', origin: 'https://venus.example', visibility: 'stealth' });
|
|
169
|
+
assert.deepEqual(a, b, 'two stealth projects must be indistinguishable on the wire');
|
|
170
|
+
assert.equal(a.id, null);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ── who is on the map: the predicate (ADR 0182 D3) ───────────────────────────
|
|
174
|
+
|
|
175
|
+
test('listPublicProjects excludes dark matter using the SHARED publish gate, not a copy', async () => {
|
|
176
|
+
// Arrange
|
|
177
|
+
const pool = mockPool([{ rows: [] }]);
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
await pi.listPublicProjects({ pool });
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
const sql = pool.calls[0].text;
|
|
184
|
+
assert.ok(sql.includes(pi.publishableSql()),
|
|
185
|
+
'the gate rides in the WHERE, from the one SQL definition R16 published for this feed');
|
|
186
|
+
assert.match(sql, /status = 'active'/, 'a deprovisioned project is off the map too (D3)');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('featuring no longer GATES presence — it only ranks among projects already on the map', async () => {
|
|
190
|
+
// ADR 0182 D3, and the behaviour change the whole goal turns on: an owner who sets
|
|
191
|
+
// their project public used to see nothing happen until an Archon featured them.
|
|
192
|
+
const pool = mockPool([{ rows: [] }]);
|
|
193
|
+
await pi.listPublicProjects({ pool });
|
|
194
|
+
const sql = pool.calls[0].text;
|
|
195
|
+
const where = (sql.match(/WHERE([\s\S]*?)ORDER BY/i) || [, ''])[1];
|
|
196
|
+
assert.doesNotMatch(where, /featured/, 'featured must not appear in the predicate at all');
|
|
197
|
+
assert.match(sql, /ORDER BY featured DESC/, 'it keeps the job D3 leaves it: ranking');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the public feed is CAPPED — the swap to self-service removed the human bound on its size', async () => {
|
|
201
|
+
// Until R06 the listing was bounded by an Archon choosing to feature a project. Making
|
|
202
|
+
// it `publishable` replaced that bound with self-service, on a route that is public,
|
|
203
|
+
// CORS-open, unauthenticated AND fans out one outbound stats fetch per row. The cap is
|
|
204
|
+
// the cheap half of that trade; pagination is its own task.
|
|
205
|
+
const pool = mockPool([{ rows: [] }]);
|
|
206
|
+
await pi.listPublicProjects({ pool });
|
|
207
|
+
assert.match(pool.calls[0].text, new RegExp(`LIMIT ${feed.PUBLIC_FEED_MAX_PROJECTS}\\b`));
|
|
208
|
+
assert.equal(Number.isInteger(feed.PUBLIC_FEED_MAX_PROJECTS), true,
|
|
209
|
+
'the cap is interpolated into SQL, so it must be a literal integer and never a caller value');
|
|
210
|
+
assert.ok(feed.PUBLIC_FEED_MAX_PROJECTS > 0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('listPublicProjects projects `visibility`, the column the orb is derived from', async () => {
|
|
214
|
+
// Without it every row would fall to the anonymous default and the whole map would
|
|
215
|
+
// go dark — silently, and in the safe direction, which is exactly why it needs a test.
|
|
216
|
+
const pool = mockPool([{ rows: [] }]);
|
|
217
|
+
await pi.listPublicProjects({ pool });
|
|
218
|
+
const sel = (pool.calls[0].text.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
|
|
219
|
+
assert.match(sel, /\bvisibility\b/);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('listPublicProjects stays a PUBLIC projection — no owner, no status, no bookkeeping', async () => {
|
|
223
|
+
// Inherited from the feed this replaced, and re-asserted rather than assumed: the
|
|
224
|
+
// route is rank:public and CORS-open, so SELECT * must never sneak in behind it.
|
|
225
|
+
const pool = mockPool([{ rows: [] }]);
|
|
226
|
+
await pi.listPublicProjects({ pool });
|
|
227
|
+
const sql = pool.calls[0].text;
|
|
228
|
+
const sel = (sql.match(/SELECT([\s\S]*?)FROM/i) || [, ''])[1];
|
|
229
|
+
assert.ok(sel.trim(), 'the projection is an explicit SELECT list');
|
|
230
|
+
assert.doesNotMatch(sel, /\*/, 'no star projection on a public route');
|
|
231
|
+
assert.doesNotMatch(sel, /owner_github_id/, 'public projection must not leak owner id');
|
|
232
|
+
assert.doesNotMatch(sel, /updated_at/, 'curation noise — it may sort, it may never project');
|
|
233
|
+
assert.doesNotMatch(sel, /\bdescription\b/,
|
|
234
|
+
'description is the publish BAR, not a card field — it is spent in the predicate');
|
|
235
|
+
assert.match(sel, /created_at/, 'the LISTED date stays public');
|
|
236
|
+
assert.match(sel, /\btype\b/, 'the declared type stays public (task 1002772)');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── the route: the composition, executed ─────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
// The real router, driven with a fake req/res. `pi.listPublicProjects` and the stats
|
|
242
|
+
// enrichment are stubbed so the test needs neither Postgres nor the network — what is
|
|
243
|
+
// under test is the ROUTE's composition, not either dependency.
|
|
244
|
+
async function callFeaturedFeed(rows) {
|
|
245
|
+
const router = projectsRoutes();
|
|
246
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/projects/featured'
|
|
247
|
+
&& l.route.methods.get);
|
|
248
|
+
assert.ok(layer, 'GET /projects/featured must still be a route on this router');
|
|
249
|
+
|
|
250
|
+
const askedFor = [];
|
|
251
|
+
const savedList = pi.listPublicProjects;
|
|
252
|
+
const savedStats = projectStats.getProjectStats;
|
|
253
|
+
pi.listPublicProjects = async () => rows;
|
|
254
|
+
projectStats.getProjectStats = async (origins) => { askedFor.push(...origins); return {}; };
|
|
255
|
+
try {
|
|
256
|
+
let body = null;
|
|
257
|
+
const res = {
|
|
258
|
+
json: (b) => { body = b; },
|
|
259
|
+
fail: (code, detail) => { throw new Error(`route failed: ${code} ${JSON.stringify(detail)}`); },
|
|
260
|
+
};
|
|
261
|
+
await layer.route.stack[0].handle({}, res, () => {});
|
|
262
|
+
return { body, askedFor };
|
|
263
|
+
} finally {
|
|
264
|
+
pi.listPublicProjects = savedList;
|
|
265
|
+
projectStats.getProjectStats = savedStats;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
test('the feed renders all three visible states, each with its orb', async () => {
|
|
270
|
+
// Arrange
|
|
271
|
+
const rows = [
|
|
272
|
+
{ ...PUBLIC_ROW, visibility: 'public' },
|
|
273
|
+
{ ...PUBLIC_ROW, origin: 'https://venus.example', name: 'Venus', visibility: 'private' },
|
|
274
|
+
{ ...PUBLIC_ROW, origin: 'https://pluto.example', name: 'Pluto', visibility: 'stealth' },
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
// Act
|
|
278
|
+
const { body } = await callFeaturedFeed(rows);
|
|
279
|
+
|
|
280
|
+
// Assert
|
|
281
|
+
assert.equal(body.projects.length, 3);
|
|
282
|
+
assert.deepEqual(body.projects.map((p) => p.state), ['public', 'private', 'stealth']);
|
|
283
|
+
assert.deepEqual(body.projects.map((p) => p.orb), ['planet', 'star', 'black-hole']);
|
|
284
|
+
assert.equal(body.projects[0].name, 'Mercury');
|
|
285
|
+
assert.equal(body.projects[1].name, 'Venus');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('the stealth row leaves the ROUTE anonymous — redaction survives the enrichment', async () => {
|
|
289
|
+
// The one that matters: the route spreads stats, planet choices and a scheme-checked
|
|
290
|
+
// art_url onto each row AFTER the domain read, so a redaction applied any earlier
|
|
291
|
+
// would be silently undone here.
|
|
292
|
+
const { body } = await callFeaturedFeed([{ ...PUBLIC_ROW, visibility: 'stealth' }]);
|
|
293
|
+
const wire = body.projects[0];
|
|
294
|
+
for (const field of ['origin', 'name', 'tagline', 'art_url']) {
|
|
295
|
+
assert.equal(wire[field], null, `D2a: ${field} must not leave the server`);
|
|
296
|
+
}
|
|
297
|
+
const said = Object.keys(wire).filter((k) => wire[k] !== null);
|
|
298
|
+
assert.deepEqual(said.sort(), ['orb', 'state']);
|
|
299
|
+
assert.ok(Object.keys(wire).length > 5, 'and it is a full-width row, not a stub — no key was dropped');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('a stealth project\'s own API is never asked about its builders', async () => {
|
|
303
|
+
// The enrichment reads each project's public leaderboard for builder counts, owner
|
|
304
|
+
// and up to five AVATARS. Handing a stealth origin to it would both leak identity
|
|
305
|
+
// onto the wire and tell that project it was being enumerated.
|
|
306
|
+
const { askedFor } = await callFeaturedFeed([
|
|
307
|
+
{ ...PUBLIC_ROW, visibility: 'public' },
|
|
308
|
+
{ ...PUBLIC_ROW, origin: 'https://pluto.example', visibility: 'stealth' },
|
|
309
|
+
]);
|
|
310
|
+
assert.deepEqual(askedFor, ['https://mercury.example']);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('dark matter never reaches the route at all — the exclusion is in SQL', async () => {
|
|
314
|
+
// Stated as a structural fact rather than a behaviour, because that IS the decision:
|
|
315
|
+
// a client-side filter would ship the husk to anyone who opened the network tab.
|
|
316
|
+
const src = require('node:fs').readFileSync(
|
|
317
|
+
new URL('../modules/platform-identity/projects-feed.js', import.meta.url), 'utf8');
|
|
318
|
+
const fn = (src.match(/async function listPublicProjects[\s\S]*?\n\}/) || [''])[0];
|
|
319
|
+
assert.ok(fn.includes('publishableSql()'), 'the gate is applied in the query, not after it');
|
|
320
|
+
});
|