@bongos/core 1.19.392 → 1.19.394

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,57 @@
1
+ -- platform_identity_011_project_visibility.sql — the project join-door enum + the
2
+ -- missing identity field the publish gate reads (BV1.R04, task 1002323; ADR 0182
3
+ -- Decision 1 + Decision 4). Precedent: `goals.visibility` (core_188, ADR 0112).
4
+ --
5
+ -- ADR 0182 splits what looked like ONE four-valued state into TWO axes:
6
+ --
7
+ -- publishability DERIVED from the identity gate (name + description) → on the map at all
8
+ -- the join door STORED `visibility ∈ (public, private, stealth)` → how it renders, who gets in
9
+ --
10
+ -- so "dark matter" is the ABSENCE of a met bar, never a stored fourth value. There
11
+ -- is deliberately no way to choose it: a stored `unlisted` would compete with
12
+ -- `private` and give one question two sources of truth that can drift apart.
13
+ --
14
+ -- THE BACKFILL is the DEFAULT clause, not a separate UPDATE: every existing row
15
+ -- takes `visibility = 'public'` atomically at ADD COLUMN time, which is today's
16
+ -- behaviour unchanged. `description` is deliberately left NULL on existing rows —
17
+ -- ADR 0182 D4 refuses to seed it from `tagline` ("a description is the publish bar;
18
+ -- collapsing them means editing your card can unpublish your project"), so an
19
+ -- existing row is publishable only once its owner writes a real description.
20
+ --
21
+ -- THIS MIGRATION CHANGES NO LIVE BEHAVIOUR. The public feed predicate is still
22
+ -- `featured = true AND status = 'active'` until R06 (task 1002325) replaces it, so
23
+ -- nothing leaves or joins the map here. When R06 lands, rows with no description
24
+ -- become dark matter — that is the designed consequence, and R16 (task 1002335) is
25
+ -- the surface that tells an owner exactly which field is missing.
26
+ --
27
+ -- Additive + `platform_identity_`-namespaced (ADR 0083 §5). Forward-safe: ADD
28
+ -- COLUMN only, so the previous code release still runs against this schema.
29
+ -- Idempotent (IF NOT EXISTS + the drop-then-add CHECK pattern from migration 006).
30
+
31
+ BEGIN;
32
+
33
+ -- 1. The join door. Three values, all of them VISIBLE (ADR 0182 D2) — `private` is
34
+ -- visible-but-closed exactly as it already is for goals, and `stealth` exposes
35
+ -- EXISTENCE without IDENTITY (redacted server-side in the feed by R06).
36
+ ALTER TABLE platform_identity_projects
37
+ ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'public';
38
+
39
+ ALTER TABLE platform_identity_projects DROP CONSTRAINT IF EXISTS platform_identity_projects_visibility_check;
40
+ ALTER TABLE platform_identity_projects ADD CONSTRAINT platform_identity_projects_visibility_check
41
+ CHECK (visibility IN ('public', 'private', 'stealth'));
42
+
43
+ -- 2. The second half of the identity gate. `name` already exists NOT NULL; this is
44
+ -- the column ADR 0182 D4 found missing. NOT `tagline` — see the header.
45
+ ALTER TABLE platform_identity_projects
46
+ ADD COLUMN IF NOT EXISTS description text;
47
+
48
+ COMMENT ON COLUMN platform_identity_projects.visibility IS
49
+ 'The join door, set by the project owner: public|private|stealth (ADR 0182 D1). The name is a mild lie kept for one word across the goal and project planes: this column does NOT decide visibility — publishability does. Anyone reading visibility = ''public'' and expecting the orb on the map is reading it wrong; check the publish gate (name + description both present).';
50
+
51
+ COMMENT ON COLUMN platform_identity_projects.description IS
52
+ 'The second half of the publish gate (ADR 0182 D4): a project is publishable iff name AND description are both present. Distinct from tagline, which is the Explore card one-liner — collapsing them would mean editing your card can unpublish your project.';
53
+
54
+ COMMENT ON COLUMN platform_identity_projects.status IS
55
+ 'Operational lifecycle, owned by the system/Archon — a deprovisioned project is hidden. One of the three independent mechanisms in ADR 0182 D3 (status | visibility | featured); a project is on the map iff status = ''active'' AND publishable, and featured may only rank among rows already there.';
56
+
57
+ COMMIT;
@@ -233,37 +233,90 @@ async function getClientByOrigin(origin, { pool = defaultPool } = {}) {
233
233
  // provisioning's copy on its own instances table).
234
234
  const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure'];
235
235
 
236
+ // The stored JOIN DOOR (ADR 0182 D1, task 1002323). Three values, and ALL THREE ARE
237
+ // VISIBLE: `private` is visible-but-closed (the `goals.visibility` meaning, ADR 0112),
238
+ // `stealth` publishes EXISTENCE without IDENTITY. Mirrored by the CHECK in migration 011.
239
+ //
240
+ // There is no fourth value. "Dark matter" — off the map entirely — is DERIVED from the
241
+ // publish gate below, never stored, because a stored `unlisted` would compete with
242
+ // `private` and let two sources of truth for one question drift apart.
243
+ const PROJECT_VISIBILITIES = ['public', 'private', 'stealth'];
244
+
245
+ // The publish gate is IDENTITY ONLY (ADR 0182 D4, owner interview on R16): a name and a
246
+ // description. Not tagline, art, modules, governance, or a completed interview — the bar
247
+ // exists to keep empty husks off the map, not to enforce completeness.
248
+ const PUBLISH_GATE_FIELDS = ['name', 'description'];
249
+
250
+ // Is this project on the map at all? Returns the verdict AND what is missing, because
251
+ // ADR 0182 D4 requires the gate to NAME what is missing rather than merely refuse: a
252
+ // project that cannot be published and is not told why is indistinguishable from a
253
+ // broken one. R16 (task 1002335) renders `missing`; R06 (task 1002325) reads `publishable`.
254
+ function projectPublishability(project) {
255
+ const missing = PUBLISH_GATE_FIELDS.filter((f) => !String(project?.[f] ?? '').trim());
256
+ return { publishable: missing.length === 0, missing };
257
+ }
258
+
259
+ // The boolean alone, for predicates that do not care which field is absent.
260
+ function projectIsPublishable(project) {
261
+ return projectPublishability(project).publishable;
262
+ }
263
+
264
+ // The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
265
+ // refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
266
+ // qualifies the columns for a joined query (e.g. publishableSql('p')).
267
+ function publishableSql(alias) {
268
+ // The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
269
+ // pass a literal, but a helper that builds SQL should refuse anything that is not a
270
+ // bare identifier rather than trust every future caller to remember that.
271
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
272
+ throw new Error('publishableSql: alias must be a bare SQL identifier');
273
+ }
274
+ const q = alias ? `${alias}.` : '';
275
+ return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
276
+ }
277
+
236
278
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
237
279
  // federated project is catalogued, unfeatured) and by the Archon create/curate route.
238
280
  // Never touches `featured` on update — that is Archon-only via setProjectFeatured.
239
- async function upsertProject({ origin, name, tagline, artUrl, ownerGithubId, type }, { pool = defaultPool } = {}) {
281
+ //
282
+ // `visibility` is likewise absent from the column list, and deliberately: ADR 0182 D5
283
+ // makes the hub row a CACHE, so the hub never AUTHORIZES a door change — it accepts a
284
+ // report from the authenticated instance (R08, task 1002327). Leaving the column out of
285
+ // the upsert is what stops a client re-registration from silently resetting an owner's
286
+ // door back to the 'public' default.
287
+ async function upsertProject({ origin, name, tagline, description, artUrl, ownerGithubId, type }, { pool = defaultPool } = {}) {
240
288
  const norm = String(origin || '').replace(/\/+$/, '');
241
289
  if (!norm) throw new Error('project origin required');
242
290
  // off-vocabulary → null (the DB CHECK is the backstop; a declaration is never invented)
243
291
  const declaredType = PROJECT_TYPES.includes(type) ? type : null;
244
292
  const { rows } = await pool.query(
245
- `INSERT INTO platform_identity_projects (origin, name, tagline, art_url, owner_github_id, type)
246
- VALUES ($1, $2, $3, $4, $5, $6)
293
+ `INSERT INTO platform_identity_projects (origin, name, tagline, description, art_url, owner_github_id, type)
294
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
247
295
  ON CONFLICT (origin) DO UPDATE SET
248
296
  name = EXCLUDED.name,
249
297
  tagline = COALESCE(EXCLUDED.tagline, platform_identity_projects.tagline),
298
+ description = COALESCE(EXCLUDED.description, platform_identity_projects.description),
250
299
  art_url = COALESCE(EXCLUDED.art_url, platform_identity_projects.art_url),
251
300
  owner_github_id = COALESCE(EXCLUDED.owner_github_id, platform_identity_projects.owner_github_id),
252
301
  type = COALESCE(EXCLUDED.type, platform_identity_projects.type),
253
302
  updated_at = now()
254
- RETURNING id, origin, name, tagline, art_url, owner_github_id, type, status, featured`,
255
- [norm, name || norm, tagline ?? null, artUrl ?? null, ownerGithubId ?? null, declaredType],
303
+ RETURNING id, origin, name, tagline, description, art_url, owner_github_id, type, status, featured, visibility`,
304
+ [norm, name || norm, tagline ?? null, description ?? null, artUrl ?? null, ownerGithubId ?? null, declaredType],
256
305
  );
257
306
  return rows[0];
258
307
  }
259
308
 
309
+ // A single catalog row, with the DERIVED publish verdict attached (ADR 0182 D1) — the
310
+ // row alone cannot answer "is this on the map", so every read that returns one answers
311
+ // it here rather than leaving each caller to re-derive the gate.
260
312
  async function getProject(id, { pool = defaultPool } = {}) {
261
313
  const { rows } = await pool.query(
262
- `SELECT id, origin, name, tagline, art_url, owner_github_id, type, status, featured
314
+ `SELECT id, origin, name, tagline, description, art_url, owner_github_id, type, status, featured, visibility
263
315
  FROM platform_identity_projects WHERE id = $1`,
264
316
  [id],
265
317
  );
266
- return rows[0] || null;
318
+ if (!rows[0]) return null;
319
+ return { ...rows[0], ...projectPublishability(rows[0]) };
267
320
  }
268
321
 
269
322
  // The PUBLIC Explore listing — featured + active projects only, public projection
@@ -282,15 +335,20 @@ async function listFeaturedProjects({ pool = defaultPool } = {}) {
282
335
  return rows;
283
336
  }
284
337
 
285
- // The Archon curation list — every active project + its featured flag.
338
+ // The curation list — every active project, its featured flag, its stored door, and the
339
+ // DERIVED publish verdict. `publishable` is computed in SQL from publishableSql() so this
340
+ // list and the public feed (R06) read one definition; `missing` names the absent identity
341
+ // field so a curator can see WHY a row is dark matter instead of guessing (ADR 0182 D4).
286
342
  async function listAllProjects({ pool = defaultPool } = {}) {
287
343
  const { rows } = await pool.query(
288
- `SELECT id, origin, name, tagline, art_url, owner_github_id, type, status, featured, updated_at
344
+ `SELECT id, origin, name, tagline, description, art_url, owner_github_id, type, status,
345
+ featured, visibility, updated_at,
346
+ ${publishableSql()} AS publishable
289
347
  FROM platform_identity_projects
290
348
  WHERE status = 'active'
291
349
  ORDER BY featured DESC, updated_at DESC`,
292
350
  );
293
- return rows;
351
+ return rows.map((r) => ({ ...r, missing: projectPublishability(r).missing }));
294
352
  }
295
353
 
296
354
  // Archon-only: flip the Explore feature flag on a catalog row.
@@ -1115,6 +1173,11 @@ module.exports = {
1115
1173
  getClientByOrigin,
1116
1174
  setClientStatus,
1117
1175
  // projects catalog + Explore
1176
+ PROJECT_VISIBILITIES,
1177
+ PUBLISH_GATE_FIELDS,
1178
+ projectPublishability,
1179
+ projectIsPublishable,
1180
+ publishableSql,
1118
1181
  upsertProject,
1119
1182
  PROJECT_TYPES,
1120
1183
  getProject,
@@ -2,7 +2,8 @@
2
2
  // curated public Explore showcase (ADR 0141 §5, task #1002110).
3
3
  //
4
4
  // GET /projects/featured — PUBLIC Explore listing (featured + active only)
5
- // GET /projects — Archon: full curation list (+ featured flags)
5
+ // GET /projects — Archon: full curation list (+ featured flags, the stored
6
+ // join door, and the DERIVED publish verdict — ADR 0182)
6
7
  // POST /projects — Archon: create / update a catalog entry
7
8
  // PATCH /projects/:id/featured — Archon: the feature / unfeature control
8
9
  //
@@ -41,6 +42,10 @@ module.exports = function projectsRoutes() {
41
42
  origin: { required: true, type: 'string', maxLength: 512 },
42
43
  name: { required: true, type: 'string', maxLength: 200 },
43
44
  tagline: { type: 'string', maxLength: 400 },
45
+ // The second half of the publish gate (ADR 0182 D4, task 1002323) — NOT tagline,
46
+ // which is the Explore card one-liner. Longer cap than the tagline's on purpose:
47
+ // this is the thing that says what the project IS.
48
+ description: { type: 'string', maxLength: 2000 },
44
49
  art_url: { type: 'string', maxLength: 2048 },
45
50
  type: { type: 'string', enum: [...pi.PROJECT_TYPES] },
46
51
  })) return;
@@ -49,6 +54,7 @@ module.exports = function projectsRoutes() {
49
54
  origin: req.body.origin,
50
55
  name: req.body.name,
51
56
  tagline: req.body.tagline,
57
+ description: req.body.description,
52
58
  artUrl: req.body.art_url,
53
59
  type: req.body.type,
54
60
  }, { pool });
@@ -14,6 +14,7 @@
14
14
  import { strict as assert } from 'node:assert';
15
15
  import { createRequire } from 'node:module';
16
16
  import crypto from 'node:crypto';
17
+ import { readFileSync } from 'node:fs';
17
18
 
18
19
  const require = createRequire(import.meta.url);
19
20
  const P = require('../platform-identity.js');
@@ -253,6 +254,111 @@ await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
253
254
  assert.deepEqual(pool.calls[0].params, [1, true]);
254
255
  });
255
256
 
257
+ console.log('\nproject visibility: the stored door + the DERIVED publish gate (BV1.R04, ADR 0182):');
258
+
259
+ t('the stored enum has exactly THREE values — dark matter is derived, never stored', () => {
260
+ assert.deepEqual([...P.PROJECT_VISIBILITIES].sort(), ['private', 'public', 'stealth']);
261
+ // ADR 0182 D2 / "Alternatives rejected": a stored fourth value would compete with
262
+ // `private` and give one question two sources of truth that can drift apart.
263
+ for (const forbidden of ['unlisted', 'dark-matter', 'dark_matter', 'hidden', 'published']) {
264
+ assert.ok(!P.PROJECT_VISIBILITIES.includes(forbidden), `${forbidden} must never be a stored door value`);
265
+ }
266
+ });
267
+
268
+ t('the publish gate is IDENTITY ONLY — name + description, and it NAMES what is missing', () => {
269
+ assert.deepEqual([...P.PUBLISH_GATE_FIELDS].sort(), ['description', 'name']);
270
+ const full = { name: 'Mercury', description: 'A weather sim.', tagline: 't', art_url: 'a', type: 'research' };
271
+ assert.deepEqual(P.projectPublishability(full), { publishable: true, missing: [] });
272
+ assert.equal(P.projectIsPublishable(full), true);
273
+ // ADR 0182 D4: the gate must name what is missing, never merely refuse.
274
+ assert.deepEqual(P.projectPublishability({ name: 'Mercury' }), { publishable: false, missing: ['description'] });
275
+ assert.deepEqual(P.projectPublishability({}), { publishable: false, missing: ['name', 'description'] });
276
+ // Whitespace is not an identity — a husk stays off the map.
277
+ assert.deepEqual(P.projectPublishability({ name: 'M', description: ' ' }).missing, ['description']);
278
+ assert.equal(P.projectIsPublishable(null), false);
279
+ });
280
+
281
+ t('a tagline is NOT a description — the card one-liner can never publish a project', () => {
282
+ // ADR 0182 D4: collapsing them means editing your card can unpublish your project.
283
+ const cardOnly = { name: 'Mercury', tagline: 'a weather sim' };
284
+ assert.equal(P.projectIsPublishable(cardOnly), false);
285
+ assert.deepEqual(P.projectPublishability(cardOnly).missing, ['description']);
286
+ // The bar is identity only — art, type, modules and governance are NOT on it.
287
+ assert.equal(P.projectIsPublishable({ name: 'M', description: 'd' }), true);
288
+ });
289
+
290
+ t('publishableSql is the SAME gate in SQL — one definition for the feed (R06) and the refusal (R16)', () => {
291
+ const bare = P.publishableSql();
292
+ const aliased = P.publishableSql('p');
293
+ for (const sql of [bare, aliased]) {
294
+ assert.match(sql, /\bname\b/);
295
+ assert.match(sql, /\bdescription\b/);
296
+ assert.doesNotMatch(sql, /\btagline\b/, 'the card one-liner is not on the bar');
297
+ assert.doesNotMatch(sql, /\bfeatured\b/, 'editorial promotion is not the publish gate (ADR 0182 D3)');
298
+ assert.doesNotMatch(sql, /\bvisibility\b/, 'the stored door does not decide publishability');
299
+ }
300
+ assert.match(aliased, /p\.name/, 'an alias qualifies both columns');
301
+ assert.match(aliased, /p\.description/);
302
+ assert.doesNotMatch(bare, /\w\.name/, 'no alias when none is asked for');
303
+ assert.equal(P.publishableSql(''), bare, 'an empty alias is the same as none');
304
+ // The alias is the one interpolated thing here — a helper that BUILDS SQL refuses
305
+ // anything that is not a bare identifier rather than trust every future caller.
306
+ assert.throws(() => P.publishableSql('p; DROP TABLE platform_identity_projects --'), /bare SQL identifier/);
307
+ });
308
+
309
+ await ta('upsertProject carries description (COALESCE-preserved) and NEVER writes visibility', async () => {
310
+ const pool = mockPool([{ rows: [{ id: '1', origin: 'https://p', description: 'd', visibility: 'public' }] }]);
311
+ await P.upsertProject({ origin: 'https://p', name: 'Proj', description: 'A weather sim.' }, { pool });
312
+ const c = pool.calls[0];
313
+ assert.match(c.text, /\bdescription\b/, 'the publish-gate field joins the upsert');
314
+ assert.match(c.text, /description\s*=\s*COALESCE/, 'a later descriptionless upsert must not unpublish the project');
315
+ assert.ok(c.params.includes('A weather sim.'), 'the description is parameterized');
316
+ // ADR 0182 D5: the hub row is a CACHE — it never AUTHORIZES a door change. If a client
317
+ // re-registration wrote visibility, it would silently reset an owner's door to 'public'.
318
+ assert.doesNotMatch(c.text, /visibility\s*=/, 'the door is reported (R08), never set by an upsert');
319
+ });
320
+
321
+ await ta('listAllProjects reports the stored door + the DERIVED verdict, computed in SQL', async () => {
322
+ const pool = mockPool([{ rows: [
323
+ { id: '1', name: 'Full', description: 'd', visibility: 'stealth', publishable: true },
324
+ { id: '2', name: 'Husk', description: null, visibility: 'public', publishable: false },
325
+ ] }]);
326
+ const out = await P.listAllProjects({ pool });
327
+ assert.match(pool.calls[0].text, /\bvisibility\b/, 'the stored door is projected');
328
+ assert.match(pool.calls[0].text, /AS publishable/, 'the verdict is derived in SQL, not re-derived per caller');
329
+ // A curator must be able to see WHY a row is dark matter, not just that it is.
330
+ assert.deepEqual(out[0].missing, []);
331
+ assert.deepEqual(out[1].missing, ['description']);
332
+ // All three doors are VISIBLE (D2) — a stealth row is a normal curation row here.
333
+ assert.equal(out[0].visibility, 'stealth');
334
+ });
335
+
336
+ await ta('getProject attaches the derived verdict — the row alone cannot answer "is this on the map"', async () => {
337
+ const pool = mockPool([{ rows: [{ id: '1', name: 'Husk', description: null, visibility: 'public', featured: true, status: 'active' }] }]);
338
+ const out = await P.getProject(1, { pool });
339
+ assert.match(pool.calls[0].text, /\bdescription\b/);
340
+ // featured + active + visibility='public' and STILL off the map: publishability is
341
+ // the axis that decides, exactly as the column comment warns.
342
+ assert.equal(out.publishable, false);
343
+ assert.deepEqual(out.missing, ['description']);
344
+ assert.equal(await P.getProject(2, { pool: mockPool([{ rows: [] }]) }), null);
345
+ });
346
+
347
+ t('migration 011 pins the schema half: three-value CHECK, additive, no tagline seed', () => {
348
+ const sql = readFileSync(new URL('../migrations/platform_identity_011_project_visibility.sql', import.meta.url), 'utf8');
349
+ const stripped = sql.replace(/--[^\n]*/g, ' ');
350
+ assert.match(stripped, /ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'public'/);
351
+ assert.match(stripped, /ADD COLUMN IF NOT EXISTS description text/);
352
+ const check = (stripped.match(/CHECK \(visibility IN \(([^)]*)\)\)/) || [, ''])[1];
353
+ const values = check.split(',').map((v) => v.trim().replace(/'/g, '')).filter(Boolean).sort();
354
+ assert.deepEqual(values, ['private', 'public', 'stealth'], 'the DB CHECK mirrors PROJECT_VISIBILITIES exactly');
355
+ assert.deepEqual(values, [...P.PROJECT_VISIBILITIES].sort(), 'CHECK and constant cannot drift');
356
+ // The backfill IS the DEFAULT clause — and ADR 0182 D4 refuses a tagline seed, so an
357
+ // existing row stays dark matter until its owner writes a real description.
358
+ assert.doesNotMatch(stripped, /SET\s+description\s*=\s*tagline/i, 'description is never seeded from tagline');
359
+ assert.doesNotMatch(stripped, /\bDROP COLUMN\b|\bSET NOT NULL\b/i, 'additive + forward-safe only');
360
+ });
361
+
256
362
  await ta('registerClient auto-catalogues the project (2nd query) when an origin is given', async () => {
257
363
  const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
258
364
  await P.registerClient({ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p', ownerGithubId: 7 }, { pool });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.392",
3
+ "version": "1.19.394",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.392",
9
+ "version": "1.19.394",
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.392",
3
+ "version": "1.19.394",
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",