@bongos/core 1.19.513 → 1.19.515

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.
@@ -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 Explore listing (featured + active only)
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 Explore showcase — featured + active projects only, public projection.
145
- // CORS-enabled cross-origin read (a status page / the apex fetches it pre-auth).
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 — a curated public showcase; no auth by design.
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.listFeaturedProjects({ pool });
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(projects.map((p) => p.origin));
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 () => ({}))(projects.map((p) => hostOfOrigin(p.origin)));
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.listFeaturedProjects({ pool: pool3 });
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 featured projection carries the template');
266
+ assert.match(sel, /\btemplate\b/, 'the public projection carries the template');
267
267
  });
268
268
 
269
- await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only, no owner/status)', async () => {
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.listFeaturedProjects({ pool });
271
+ const out = await P.listPublicProjects({ pool });
272
272
  assert.equal(out.length, 1);
273
273
  const c = pool.calls[0];
274
- assert.match(c.text, /WHERE featured = true AND status = 'active'/);
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.513",
3
+ "version": "1.19.515",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.513",
9
+ "version": "1.19.515",
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.513",
3
+ "version": "1.19.515",
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.513'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.515'; // 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');
@@ -182,3 +182,38 @@ test('migration objects are all governance_-namespaced (ADR 0083 §5)', () => {
182
182
  }
183
183
  }
184
184
  });
185
+
186
+ // THE GAP THE DRIFT GUARD ABOVE CANNOT SEE (found by the task 1003177 grader).
187
+ //
188
+ // The union model is a regex over `SELECT '<rank>', unnest(ARRAY[…])`. It reads
189
+ // the KEYS and never looks at the table those keys are inserted INTO — so a new
190
+ // grant migration copied from a `governance_*` sibling passes it while writing to
191
+ // `governance_role_permissions (role_key, …)`, the PRE-rename names.
192
+ //
193
+ // government_001_rename_from_governance renamed that table to
194
+ // `government_rank_permissions` and its `role_key` column to `rank_key`, with no
195
+ // compatibility shim. Migrations apply in filename order, so on any database —
196
+ // a fresh install included — a `government_*`-prefixed file writes AFTER the
197
+ // rename and must use the new names or error at apply time. The permission would
198
+ // then never resolve, and its routes would 403 for every rank, Archon included.
199
+ //
200
+ // Scoped to the `government_`-prefixed files on purpose: the historical
201
+ // `governance_*` migrations legitimately name the pre-rename tables (they ran
202
+ // before the rename, and renaming an applied migration re-runs it — the standing
203
+ // rule in modules/government/CLAUDE.md).
204
+ test('a post-rename grant migration writes to the RENAMED table, not the old one', () => {
205
+ const postRename = grantSqlByFile.filter(([f]) => f.startsWith('government_'));
206
+ assert.ok(postRename.length > 0, 'there is at least one government_-prefixed grant migration');
207
+
208
+ for (const [file, sql] of postRename) {
209
+ const ddl = sql.replace(/^\s*--.*$/gm, ''); // a comment may CITE the old name
210
+ assert.ok(!/governance_role_permissions/.test(ddl),
211
+ `${file}: writes to governance_role_permissions — renamed to government_rank_permissions by government_001`);
212
+ assert.ok(!/\brole_key\b/.test(ddl),
213
+ `${file}: names the role_key column — renamed to rank_key by government_001`);
214
+ if (/INSERT INTO/.test(ddl)) {
215
+ assert.match(ddl, /INSERT INTO government_rank_permissions \(rank_key, permission_key\)/,
216
+ `${file}: a grant insert must target government_rank_permissions (rank_key, permission_key)`);
217
+ }
218
+ }
219
+ });