@bongos/core 1.19.392 → 1.19.393
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 +27 -17
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/modules/platform-identity/migrations/platform_identity_011_project_visibility.sql +57 -0
- package/modules/platform-identity/platform-identity.js +73 -10
- package/modules/platform-identity/routes/projects.js +7 -1
- package/modules/platform-identity/tests/platform-identity.mjs +106 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +3 -1
- package/tests/project_visibility_db.mjs +175 -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.393",
|
|
6
|
+
"core_contract": "1.19.393",
|
|
7
|
+
"source_commit": "e4673d8af6d2b24d57be0c3a9d31c15627ef07db",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-28T13:
|
|
9
|
+
"built_at": "2026-08-28T13:49:26.259Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 340,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1630,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1992,
|
|
20
|
+
"tree_sha256": "f327722f7c80662fb8f132891464528b071371bd874c73487b6f3a55b6424113",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "e2924c48715177477a8f828e2118f832acad6b3f63ae7b4fe5f124e80a4e7c17"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1487,12 +1487,12 @@
|
|
|
1487
1487
|
{
|
|
1488
1488
|
"path": "docs/api-reference.md",
|
|
1489
1489
|
"mode": "0000644",
|
|
1490
|
-
"sha256": "
|
|
1490
|
+
"sha256": "3e488311a87f4db6ffbb85b26c7a61fba0204ff6cc1cc8ab6c09b2ae21cea24b"
|
|
1491
1491
|
},
|
|
1492
1492
|
{
|
|
1493
1493
|
"path": "docs/api/openapi.json",
|
|
1494
1494
|
"mode": "0000644",
|
|
1495
|
-
"sha256": "
|
|
1495
|
+
"sha256": "6e93e2f283103158bc46062459b79b5528424ffb57f4730eb29a1cdba1b4976b"
|
|
1496
1496
|
},
|
|
1497
1497
|
{
|
|
1498
1498
|
"path": "docs/architecture.md",
|
|
@@ -4704,6 +4704,11 @@
|
|
|
4704
4704
|
"mode": "0000644",
|
|
4705
4705
|
"sha256": "8f03dc84d8fe3864aa5f933512df71259c266e8a16214a926cf89eaf01cc5668"
|
|
4706
4706
|
},
|
|
4707
|
+
{
|
|
4708
|
+
"path": "modules/platform-identity/migrations/platform_identity_011_project_visibility.sql",
|
|
4709
|
+
"mode": "0000644",
|
|
4710
|
+
"sha256": "18e3641beba26d8a515616ba9bdb1bcc83151e620d6e038243d7d67850da3be8"
|
|
4711
|
+
},
|
|
4707
4712
|
{
|
|
4708
4713
|
"path": "modules/platform-identity/module.json",
|
|
4709
4714
|
"mode": "0000644",
|
|
@@ -4712,7 +4717,7 @@
|
|
|
4712
4717
|
{
|
|
4713
4718
|
"path": "modules/platform-identity/platform-identity.js",
|
|
4714
4719
|
"mode": "0000644",
|
|
4715
|
-
"sha256": "
|
|
4720
|
+
"sha256": "89672916aa04ee2246df97e008ff1b42cd2945321760452676b80b26301a4783"
|
|
4716
4721
|
},
|
|
4717
4722
|
{
|
|
4718
4723
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -4737,7 +4742,7 @@
|
|
|
4737
4742
|
{
|
|
4738
4743
|
"path": "modules/platform-identity/routes/projects.js",
|
|
4739
4744
|
"mode": "0000644",
|
|
4740
|
-
"sha256": "
|
|
4745
|
+
"sha256": "fc7be0e85b029ae4cd252ad9978bf64d481b743d1fc99143cf8c6bb250636bda"
|
|
4741
4746
|
},
|
|
4742
4747
|
{
|
|
4743
4748
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
@@ -4757,7 +4762,7 @@
|
|
|
4757
4762
|
{
|
|
4758
4763
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
4759
4764
|
"mode": "0000644",
|
|
4760
|
-
"sha256": "
|
|
4765
|
+
"sha256": "dfb9a451afa52143f9d68aef371432e38b318d94fa83c8322f1dfd831780cd9c"
|
|
4761
4766
|
},
|
|
4762
4767
|
{
|
|
4763
4768
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -5147,12 +5152,12 @@
|
|
|
5147
5152
|
{
|
|
5148
5153
|
"path": "package-lock.json",
|
|
5149
5154
|
"mode": "0000644",
|
|
5150
|
-
"sha256": "
|
|
5155
|
+
"sha256": "e07dabb4292a7652cbd2f4bf6092eb6e4037693fcd55ee4964cb6b091749228d"
|
|
5151
5156
|
},
|
|
5152
5157
|
{
|
|
5153
5158
|
"path": "package.json",
|
|
5154
5159
|
"mode": "0000644",
|
|
5155
|
-
"sha256": "
|
|
5160
|
+
"sha256": "f8304e8d1f9338bccfae2cac3ea289dd58262eeccf5820857145a5490660a2e8"
|
|
5156
5161
|
},
|
|
5157
5162
|
{
|
|
5158
5163
|
"path": "public-docs/index.html",
|
|
@@ -6362,7 +6367,7 @@
|
|
|
6362
6367
|
{
|
|
6363
6368
|
"path": "scripts/gds/run-unit-tests.js",
|
|
6364
6369
|
"mode": "0000644",
|
|
6365
|
-
"sha256": "
|
|
6370
|
+
"sha256": "29ee6bd76bdfb65522170808b6e636032079a145df4f995570901fb4f05b6035"
|
|
6366
6371
|
},
|
|
6367
6372
|
{
|
|
6368
6373
|
"path": "scripts/gds/sandbox-stage.js",
|
|
@@ -7142,7 +7147,7 @@
|
|
|
7142
7147
|
{
|
|
7143
7148
|
"path": "src/module-api.js",
|
|
7144
7149
|
"mode": "0000644",
|
|
7145
|
-
"sha256": "
|
|
7150
|
+
"sha256": "14825991cbc68b5a14edfedbcb6ed78739254bc02ea966ce02516f65c9d5abc0"
|
|
7146
7151
|
},
|
|
7147
7152
|
{
|
|
7148
7153
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9149,6 +9154,11 @@
|
|
|
9149
9154
|
"mode": "0000644",
|
|
9150
9155
|
"sha256": "6bd78c47b6dbbdd81f862df8afdc0b514e27f0ffb71d14161ac8feb19f9fbe19"
|
|
9151
9156
|
},
|
|
9157
|
+
{
|
|
9158
|
+
"path": "tests/project_visibility_db.mjs",
|
|
9159
|
+
"mode": "0000644",
|
|
9160
|
+
"sha256": "cf233daef30554217cfcde137ad0718e2bcef3f3504198769dea6448a90af3b0"
|
|
9161
|
+
},
|
|
9152
9162
|
{
|
|
9153
9163
|
"path": "tests/projects_hub.mjs",
|
|
9154
9164
|
"mode": "0000644",
|
|
@@ -296,7 +296,7 @@ export interface PostMyProjectsJoinRequest { origin: string }
|
|
|
296
296
|
export interface PostMyProjectsJoinResponse { ok: boolean; status: unknown; project: unknown; message: unknown }
|
|
297
297
|
export interface PostOverrideRequestsIdDecideRequest { decision: string; note?: string }
|
|
298
298
|
export interface PostProjectsInviteRequest { client_id: string; github_login: string }
|
|
299
|
-
export interface PostProjectsRequest { origin: string; name: string; tagline?: string; art_url?: string; type?: string }
|
|
299
|
+
export interface PostProjectsRequest { origin: string; name: string; tagline?: string; description?: string; art_url?: string; type?: string }
|
|
300
300
|
export interface PostProjectsResponse { project: unknown }
|
|
301
301
|
export interface PostProvisioningInstancesIdDomainRequest { domain: string }
|
|
302
302
|
export interface PostProvisioningInstancesIdDomainResponse { ok: boolean; changed: boolean; queued: unknown; domain: unknown; message: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -19040,6 +19040,10 @@
|
|
|
19040
19040
|
"type": "string",
|
|
19041
19041
|
"maxLength": 400
|
|
19042
19042
|
},
|
|
19043
|
+
"description": {
|
|
19044
|
+
"type": "string",
|
|
19045
|
+
"maxLength": 2000
|
|
19046
|
+
},
|
|
19043
19047
|
"art_url": {
|
|
19044
19048
|
"type": "string",
|
|
19045
19049
|
"maxLength": 2048
|
package/docs/api-reference.md
CHANGED
|
@@ -415,7 +415,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
415
415
|
| Method | Path | Rank | Body | Description |
|
|
416
416
|
|---|---|---|---|---|
|
|
417
417
|
| GET | `/api/bongos/projects` | `metic+archon` | — | the full curation list (includes unfeatured + owner attribution). |
|
|
418
|
-
| POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `art_url`, `type` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
|
|
418
|
+
| POST | `/api/bongos/projects` | `metic+archon` | `origin`, `name`, `tagline`, `description`, `art_url`, `type` | create / update a catalog entry (manual seed for hand-wired instances; federated projects auto-catalogue at registration). |
|
|
419
419
|
| PATCH | `/api/bongos/projects/:id/featured` | `metic+archon` | `featured` | the feature / unfeature control (the ADR 0141 §5 curation gate). |
|
|
420
420
|
| GET | `/api/bongos/projects/featured` | `public` | — | a curated public showcase; no auth by design. |
|
|
421
421
|
| POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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.
|
|
3
|
+
"version": "1.19.393",
|
|
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.393",
|
|
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.393",
|
|
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",
|
|
@@ -115,6 +115,11 @@ const INTEGRATION = new Set([
|
|
|
115
115
|
// and an unverified claim about exactly that is the comment this task fixed.
|
|
116
116
|
'recall_freshness_plan_db',
|
|
117
117
|
'reward_mode_cost_plus_only',
|
|
118
|
+
// task 1002323 (BV1.R04): the project visibility half against real SQL. The DB-free
|
|
119
|
+
// sibling in the module pins the JS gate and the migration text; only a real engine can
|
|
120
|
+
// prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
|
|
121
|
+
// pre-migration row, and that publishableSql and projectPublishability agree.
|
|
122
|
+
'project_visibility_db',
|
|
118
123
|
'scouting_rollup_fanout', 'session_records',
|
|
119
124
|
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
120
125
|
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
package/src/module-api.js
CHANGED
|
@@ -1224,8 +1224,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1224
1224
|
// landed since 1.19.390 with no explicit bump. run 33134514193. (task 1002620)
|
|
1225
1225
|
// 1.19.392 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1226
1226
|
// landed since 1.19.391 with no explicit bump. run 33176032603. (task 1002620)
|
|
1227
|
+
// 1.19.393 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1228
|
+
// landed since 1.19.392 with no explicit bump. run 33177136260. (task 1002620)
|
|
1227
1229
|
// ---------------------------------------------------------------------------
|
|
1228
|
-
const CORE_VERSION = '1.19.
|
|
1230
|
+
const CORE_VERSION = '1.19.393'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1229
1231
|
|
|
1230
1232
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1231
1233
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// tests/project_visibility_db.mjs — BV1.R04, task 1002323 (goal 1000046; real DB).
|
|
2
|
+
//
|
|
3
|
+
// The DB-free sibling (modules/platform-identity/tests/platform-identity.mjs) pins the
|
|
4
|
+
// JS gate and the migration's TEXT. This proves what only a real transaction engine can:
|
|
5
|
+
//
|
|
6
|
+
// 1. THE BACKFILL — a row that existed BEFORE migration 011 comes out `visibility =
|
|
7
|
+
// 'public'` (today's behaviour, unchanged) with `description` still NULL, because
|
|
8
|
+
// ADR 0182 D4 refuses to seed the publish bar from `tagline`.
|
|
9
|
+
// 2. THE CHECK actually refuses a fourth stored state — 'unlisted' / 'dark-matter'
|
|
10
|
+
// cannot be written, which is the constraint the whole 3+1 split rests on.
|
|
11
|
+
// 3. THE DERIVED GATE agrees in SQL and in JS — publishableSql() and
|
|
12
|
+
// projectPublishability() return the same verdict for the same row, so the feed
|
|
13
|
+
// (R06) and the owner-facing refusal (R16) cannot disagree about who is on the map.
|
|
14
|
+
// 4. `featured = true AND status = 'active'` is NO LONGER "is this public" — a
|
|
15
|
+
// featured, active, visibility='public' husk with no description is dark matter.
|
|
16
|
+
// 5. A client re-registration (registerClient → upsertProject) never resets an owner's
|
|
17
|
+
// door: the hub row is a CACHE and the door arrives by report (ADR 0182 D5 / R08).
|
|
18
|
+
// 6. The migration is IDEMPOTENT — replaying it over populated rows changes nothing.
|
|
19
|
+
//
|
|
20
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in run-unit-tests.js's
|
|
21
|
+
// INTEGRATION set so the default DB-free lane never runs it.
|
|
22
|
+
//
|
|
23
|
+
// Run: DATABASE_URL=... node tests/project_visibility_db.mjs
|
|
24
|
+
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Pool } = require('pg');
|
|
32
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
36
|
+
const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
|
|
37
|
+
|
|
38
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
39
|
+
try {
|
|
40
|
+
await pool.query('SELECT 1');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
console.log(`project_visibility_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
43
|
+
await pool.end().catch(() => {});
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fixture origins carry the task id so a shared DB can never collide with real rows.
|
|
48
|
+
const LEGACY = 'https://legacy-1002323.test';
|
|
49
|
+
const HUSK = 'https://husk-1002323.test';
|
|
50
|
+
const FULL = 'https://full-1002323.test';
|
|
51
|
+
const ALL = [LEGACY, HUSK, FULL];
|
|
52
|
+
|
|
53
|
+
const q = (sql, params) => pool.query(sql, params);
|
|
54
|
+
const cleanup = async () => {
|
|
55
|
+
await q(`DELETE FROM platform_identity_projects WHERE origin = ANY($1::text[])`, [ALL]).catch(() => {});
|
|
56
|
+
await q(`DELETE FROM platform_identity_sso_clients WHERE origin = ANY($1::text[])`, [ALL]).catch(() => {});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let pass = 0;
|
|
60
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
61
|
+
const rowOf = async (origin) => (await q(
|
|
62
|
+
`SELECT id, origin, name, tagline, description, visibility, status, featured
|
|
63
|
+
FROM platform_identity_projects WHERE origin = $1`, [origin],
|
|
64
|
+
)).rows[0];
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// The table as it stood BEFORE this task, so the backfill has something to act on.
|
|
68
|
+
await q(migrationSql('platform_identity_001_tables.sql'));
|
|
69
|
+
await q(migrationSql('platform_identity_004_projects.sql'));
|
|
70
|
+
await q(migrationSql('platform_identity_006_project_type.sql'));
|
|
71
|
+
await cleanup();
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// 1. The backfill: a pre-migration row keeps today's behaviour.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
await q(
|
|
77
|
+
`INSERT INTO platform_identity_projects (origin, name, tagline, featured)
|
|
78
|
+
VALUES ($1, 'Legacy', 'a card one-liner', true)`, [LEGACY],
|
|
79
|
+
);
|
|
80
|
+
await q(migrationSql('platform_identity_011_project_visibility.sql'));
|
|
81
|
+
|
|
82
|
+
const legacy = await rowOf(LEGACY);
|
|
83
|
+
ok(legacy.visibility === 'public', 'the backfill IS the DEFAULT: a pre-migration row lands visibility=public');
|
|
84
|
+
ok(legacy.description === null, 'description is NOT seeded from tagline (ADR 0182 D4) — the card cannot publish the project');
|
|
85
|
+
ok(legacy.tagline === 'a card one-liner', 'the existing tagline is untouched');
|
|
86
|
+
ok(legacy.featured === true && legacy.status === 'active', 'nothing else about the row moved');
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// 2. The CHECK refuses a stored fourth state.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
for (const forbidden of ['unlisted', 'dark-matter', 'published', '']) {
|
|
92
|
+
let refused = false;
|
|
93
|
+
try {
|
|
94
|
+
await q(`UPDATE platform_identity_projects SET visibility = $2 WHERE origin = $1`, [LEGACY, forbidden]);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
refused = e.code === '23514'; // check_violation
|
|
97
|
+
}
|
|
98
|
+
ok(refused, `the DB CHECK refuses visibility='${forbidden}' — there is no stored fourth state`);
|
|
99
|
+
}
|
|
100
|
+
for (const allowed of ['public', 'private', 'stealth']) {
|
|
101
|
+
await q(`UPDATE platform_identity_projects SET visibility = $2 WHERE origin = $1`, [LEGACY, allowed]);
|
|
102
|
+
ok((await rowOf(LEGACY)).visibility === allowed, `the DB accepts the door value '${allowed}'`);
|
|
103
|
+
}
|
|
104
|
+
await q(`UPDATE platform_identity_projects SET visibility = 'public' WHERE origin = $1`, [LEGACY]);
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// 3 + 4. The derived gate: SQL and JS agree, and `featured` no longer answers it.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
await pi.upsertProject({ origin: HUSK, name: 'Husk' }, { pool });
|
|
110
|
+
await pi.upsertProject({ origin: FULL, name: 'Full', description: 'A weather simulation.' }, { pool });
|
|
111
|
+
await q(`UPDATE platform_identity_projects SET featured = true WHERE origin = ANY($1::text[])`, [[HUSK, FULL]]);
|
|
112
|
+
|
|
113
|
+
const verdicts = (await q(
|
|
114
|
+
`SELECT origin, name, description, ${pi.publishableSql()} AS publishable
|
|
115
|
+
FROM platform_identity_projects WHERE origin = ANY($1::text[]) ORDER BY origin`,
|
|
116
|
+
[[HUSK, FULL, LEGACY]],
|
|
117
|
+
)).rows;
|
|
118
|
+
for (const row of verdicts) {
|
|
119
|
+
const js = pi.projectPublishability(row);
|
|
120
|
+
ok(row.publishable === js.publishable,
|
|
121
|
+
`SQL and JS agree on ${row.origin} (publishable=${row.publishable}) — the feed and the refusal cannot drift`);
|
|
122
|
+
}
|
|
123
|
+
const husk = verdicts.find((r) => r.origin === HUSK);
|
|
124
|
+
ok(husk.publishable === false, 'a featured, active, visibility=public project with no description is DARK MATTER');
|
|
125
|
+
ok(pi.projectPublishability(husk).missing.join() === 'description', 'and the gate NAMES the missing field, never merely refuses');
|
|
126
|
+
ok(verdicts.find((r) => r.origin === FULL).publishable === true, 'name + description alone clears the bar — the gate is identity only');
|
|
127
|
+
|
|
128
|
+
// A whitespace-only description is not an identity, in SQL too (btrim, not NULL-checks).
|
|
129
|
+
await q(`UPDATE platform_identity_projects SET description = ' ' WHERE origin = $1`, [FULL]);
|
|
130
|
+
const blank = (await q(
|
|
131
|
+
`SELECT ${pi.publishableSql()} AS publishable FROM platform_identity_projects WHERE origin = $1`, [FULL],
|
|
132
|
+
)).rows[0];
|
|
133
|
+
ok(blank.publishable === false, 'a whitespace-only description does not clear the bar in SQL either');
|
|
134
|
+
await q(`UPDATE platform_identity_projects SET description = 'A weather simulation.' WHERE origin = $1`, [FULL]);
|
|
135
|
+
|
|
136
|
+
// The aliased form is the one R06 will use across a join — prove it parses and agrees.
|
|
137
|
+
const joined = (await q(
|
|
138
|
+
`SELECT p.origin, ${pi.publishableSql('p')} AS publishable
|
|
139
|
+
FROM platform_identity_projects p WHERE p.origin = $1`, [FULL],
|
|
140
|
+
)).rows[0];
|
|
141
|
+
ok(joined.publishable === true, 'publishableSql(alias) is valid SQL in a joined query and agrees');
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// 5. The hub row is a CACHE — no write path here resets an owner's door.
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
await q(`UPDATE platform_identity_projects SET visibility = 'stealth' WHERE origin = $1`, [FULL]);
|
|
147
|
+
await pi.registerClient(
|
|
148
|
+
{ clientId: `c-1002323-${Date.now()}`, secret: 'z'.repeat(64), name: 'Full', redirectUris: [], origin: FULL },
|
|
149
|
+
{ pool },
|
|
150
|
+
);
|
|
151
|
+
ok((await rowOf(FULL)).visibility === 'stealth',
|
|
152
|
+
'a client re-registration does not reset the door to public (ADR 0182 D5 — the door arrives by report, R08)');
|
|
153
|
+
await pi.upsertProject({ origin: FULL, name: 'Full renamed' }, { pool });
|
|
154
|
+
const afterUpsert = await rowOf(FULL);
|
|
155
|
+
ok(afterUpsert.visibility === 'stealth', 'nor does a descriptionless catalog upsert');
|
|
156
|
+
ok(afterUpsert.description === 'A weather simulation.', 'nor does it erase the description (COALESCE-preserved)');
|
|
157
|
+
|
|
158
|
+
// A stealth project is a NORMAL row in the curation list — all three doors are visible.
|
|
159
|
+
const listed = (await pi.listAllProjects({ pool })).find((p) => p.origin === FULL);
|
|
160
|
+
ok(listed && listed.visibility === 'stealth' && listed.publishable === true,
|
|
161
|
+
'listAllProjects carries the stored door and the derived verdict together');
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// 6. Idempotent replay over populated rows.
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
await q(migrationSql('platform_identity_011_project_visibility.sql'));
|
|
167
|
+
const replayed = await rowOf(FULL);
|
|
168
|
+
ok(replayed.visibility === 'stealth' && replayed.description === 'A weather simulation.',
|
|
169
|
+
'replaying migration 011 over populated rows changes nothing');
|
|
170
|
+
|
|
171
|
+
console.log(`\nproject_visibility_db: ${pass} assertions passed`);
|
|
172
|
+
} finally {
|
|
173
|
+
await cleanup();
|
|
174
|
+
await pool.end().catch(() => {});
|
|
175
|
+
}
|