@bongos/core 1.19.533 → 1.19.535

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.
Files changed (62) hide show
  1. package/.bongos-core.json +121 -56
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  5. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  6. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  7. package/docs/adr/README.md +4 -0
  8. package/docs/api/openapi.json +8 -1
  9. package/docs/api-reference.md +1 -1
  10. package/docs/copy-inventory.md +162 -157
  11. package/docs/copy-registry.json +252 -196
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +16 -0
  14. package/modules/hall-ui/public/hall-kit.js +93 -7
  15. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  16. package/modules/hall-ui/public/roadmap.html +7 -1
  17. package/modules/hall-ui/public/roadmap.js +16 -0
  18. package/modules/hall-ui/public/style.css +4 -2
  19. package/modules/hall-ui/public/watch.html +4 -0
  20. package/modules/hall-ui/public/watch.js +116 -0
  21. package/modules/onboarding/module.json +1 -1
  22. package/modules/onboarding/routes/access-requests.js +21 -6
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-catalog.js +202 -0
  25. package/modules/platform-identity/routes/my-projects.js +7 -2
  26. package/modules/platform-identity/routes/projects.js +6 -0
  27. package/modules/platform-identity/routes/sso.js +10 -0
  28. package/modules/provisioning/catalog-bridge.js +105 -0
  29. package/modules/provisioning/provisioning.js +28 -2
  30. package/modules/provisioning/routes/provisioning.js +45 -11
  31. package/modules/provisioning/tests/provisioning.mjs +11 -8
  32. package/modules/public-landing/public/assets/cosmos.css +15 -0
  33. package/modules/public-landing/public/projects.html +162 -3
  34. package/modules/ui-design/kit/serve.js +1 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness.js +9 -0
  38. package/scripts/gds/provision-net.js +7 -0
  39. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  40. package/src/bongos/project-door.js +138 -0
  41. package/src/branding.js +8 -0
  42. package/src/module-api.js +12 -1
  43. package/tests/access_requests_invite.mjs +2 -2
  44. package/tests/application_lifecycle.mjs +1 -0
  45. package/tests/catalog_only_client.mjs +242 -0
  46. package/tests/currency_label.mjs +8 -5
  47. package/tests/hall_kit_charts.mjs +173 -0
  48. package/tests/hall_oversight_world.mjs +159 -0
  49. package/tests/hall_reading_world.mjs +153 -0
  50. package/tests/module_api.mjs +1 -0
  51. package/tests/project_catalog_bridge.mjs +355 -0
  52. package/tests/project_catalog_routes.mjs +213 -0
  53. package/tests/project_join_door.mjs +166 -0
  54. package/tests/project_join_door_route.mjs +206 -0
  55. package/tests/project_modules_ui.mjs +318 -0
  56. package/tests/projects_hub.mjs +6 -1
  57. package/tests/projects_hub_pre_uat.mjs +2 -2
  58. package/tests/provision_settings_apply.mjs +6 -5
  59. package/tests/provisioning_module_selection.mjs +164 -3
  60. package/tests/provisioning_settings.mjs +10 -7
  61. package/tests/provisioning_settings_apply.mjs +3 -3
  62. package/tests/provisioning_settings_env.mjs +7 -4
@@ -520,3 +520,156 @@ test('the vouched shapes still become links, exactly as before', () => {
520
520
  const degraded = MD.parseMarkdown('[t](../../CLAUDE.md)\n', MD.makeResolveLinkHref({ baseDir: 'docs/onboarding/' })).html;
521
521
  assert.ok(degraded.includes('<a href="../../CLAUDE.md"'), 'and with no bases a repo path still degrades to a link, as §10 pins above');
522
522
  });
523
+
524
+ // ---- the deck draws its dates (task 1003447, `legibility-series-are-drawn`) --
525
+ //
526
+ // /public/versions has always carried started_at and shipped_at per version, and
527
+ // the deck drew NEITHER: both dates reached the browser and came out as "Sep
528
+ // 2026" on a shipped pill. The SERIES lives in roadmap-timeline.js and
529
+ // tests/hall_kit_charts.mjs runs it against the real fixture; what belongs here
530
+ // is the deck's half — that the section is composed, gated and placed the way
531
+ // the reading rooms compose everything else.
532
+
533
+ test('the deck asks the kit to draw its timeline, and hand-rolls nothing', () => {
534
+ const js = read('roadmap.js');
535
+ assert.match(js, /window\.OTBRoadmapTimeline/, 'the series comes from its own module');
536
+ assert.match(js, /timelineCardHtml\(all, now\)/, 'over the merged versions');
537
+ assert.match(js, /html \+= timelineHtml\(all, Date\.now\(\)\)/, 'and lands in the deck');
538
+ // §6b holds this globally; pinned here because a Gantt is the shape most
539
+ // likely to be reached for as "just a few divs with a left offset".
540
+ for (const f of ['roadmap.js', 'roadmap-timeline.js']) {
541
+ assert.doesNotMatch(read(f), /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
542
+ `${f} builds no SVG geometry by hand`);
543
+ assert.doesNotMatch(read(f), /class="[^"]*(?:__|-)(?:fill|bar|track)[^"]*"[^>]*style="width:/,
544
+ `${f} draws no bar by hand`);
545
+ }
546
+ assert.match(read('roadmap-timeline.js'), /k\.barSeriesHtml\(/, 'the spans are the kit\'s bar series');
547
+ });
548
+
549
+ test('a missing timeline module leaves the deck exactly as it was', () => {
550
+ // The section is additive: the module is a <script> that can 404 behind a bad
551
+ // deploy, and when it does the deck must render its hero and its cards rather
552
+ // than an empty band or a thrown boot.
553
+ const js = read('roadmap.js');
554
+ const fn = js.slice(js.indexOf('function timelineHtml'), js.indexOf('function sectionHtml'));
555
+ assert.match(fn, /mod \? mod\.timelineCardHtml/, 'the module is checked before it is called');
556
+ assert.match(fn, /card \? sectionHtml\([\s\S]*?\) : ''/, 'and a null card renders no section at all');
557
+ });
558
+
559
+ // Boot roadmap.js in a stub DOM with the real dom-utils, the real kit and the
560
+ // real timeline module, answer /public/progress + /public/versions with the
561
+ // checked-in fixtures, and hand back what landed in #rm-root.
562
+ //
563
+ // This is the half the source pins and the kit's own RUN test both miss: the
564
+ // GLUE. `mergeVersions()` maps `m.started_at`/`m.shipped_at` onto `start`/`ship`
565
+ // and the timeline reads those names — a rename or a typo on either side leaves
566
+ // every span unparsable, the section renders nothing, and every other test in
567
+ // this file still passes because none of them run the mapping.
568
+ async function bootRoadmap({ progress, versions } = {}) {
569
+ const FIX = path.join(ROOT, 'scripts', 'hall-preview', 'fixtures');
570
+ const fx = (f) => JSON.parse(fs.readFileSync(path.join(FIX, f), 'utf8'));
571
+ const prog = progress || fx('public__progress.json');
572
+ // public__versions.json, not versions.json: the deck calls the PUBLIC route.
573
+ const vers = versions || fx('public__versions.json');
574
+
575
+ const els = new Map();
576
+ const makeEl = () => ({
577
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '',
578
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
579
+ setAttribute() {}, getAttribute() { return null; },
580
+ addEventListener() {}, appendChild() {}, insertAdjacentHTML(_, h) { this._html += h; },
581
+ closest() { return null; }, querySelector() { return null; }, querySelectorAll() { return []; },
582
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
583
+ });
584
+ const documentObj = {
585
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
586
+ querySelector(sel) {
587
+ const s = String(sel);
588
+ return s.startsWith('#') ? this.getElementById(s.slice(1)) : makeEl();
589
+ },
590
+ querySelectorAll() { return []; },
591
+ createElement() { return makeEl(); },
592
+ addEventListener() {},
593
+ };
594
+ const client = {
595
+ request: async (_m, urlPath) => {
596
+ if (urlPath.includes('/public/progress')) return { ok: true, status: 200, data: prog };
597
+ if (urlPath.includes('/public/versions')) return { ok: true, status: 200, data: vers };
598
+ // No goals: goalSummary() swallows the failure by design, and the goal
599
+ // layer is not what this test is about.
600
+ return { ok: false, status: 500, data: {} };
601
+ },
602
+ };
603
+ const windowObj = {
604
+ BongosClient: { createClient: () => client },
605
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
606
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
607
+ location: { hash: '', pathname: '/roadmap', search: '', assign() {} },
608
+ addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }),
609
+ };
610
+ const sandbox = {
611
+ window: windowObj, document: documentObj, location: windowObj.location,
612
+ localStorage: windowObj.localStorage,
613
+ CSS: { escape: (s) => String(s) }, history: { replaceState() {} },
614
+ console: { ...console, error() {}, warn() {} },
615
+ setTimeout, clearTimeout, URLSearchParams, Intl,
616
+ };
617
+ sandbox.globalThis = sandbox;
618
+ vm.createContext(sandbox);
619
+ for (const f of ['dom-utils.js', 'hall-kit.js', 'roadmap-timeline.js', 'roadmap.js']) {
620
+ vm.runInContext(read(f), sandbox, { filename: f });
621
+ }
622
+ for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
623
+ return { html: documentObj.getElementById('rm-root').innerHTML, prog, vers };
624
+ }
625
+
626
+ test('RUN: the deck really draws its timeline — mergeVersions\' field mapping included', async () => {
627
+ const { html, vers } = await bootRoadmap();
628
+ assert.ok(html.length > 0, 'the deck rendered');
629
+ assert.match(html, /class="rm-sec__t">In time</, 'the timeline section is on the page');
630
+ assert.match(html, /class="kit-bars"/, 'and it is the kit drawing it');
631
+ assert.doesNotMatch(html, /NaN|Infinity|undefined/, 'no broken geometry reaches the DOM');
632
+
633
+ // Every version the fixture gives a start date has a bar, labelled by its id.
634
+ // This is the assertion that fails if `start`/`ship` ever drift from
635
+ // `started_at`/`shipped_at`: the mapping breaks, no date parses, and the
636
+ // section quietly disappears.
637
+ const started = vers.versions.filter((v) => v.started_at);
638
+ assert.ok(started.length >= 2, 'the fixture has enough started versions to be a timeline');
639
+ for (const v of started) {
640
+ assert.ok(html.includes(`class="kit-bars__label">${v.id}<`), `${v.id} has a bar`);
641
+ }
642
+ // A version still running says "now" rather than naming a date nothing recorded.
643
+ if (vers.versions.some((v) => v.started_at && !v.shipped_at)) {
644
+ assert.match(html, /→ now</, 'an open span reads "now"');
645
+ }
646
+ // Later versions sit further along the shared axis — the whole point of a
647
+ // timeline, and the thing a plain bar series would NOT show.
648
+ const offsets = [...html.matchAll(/margin-left:(\d+)%/g)].map((m) => Number(m[1]));
649
+ assert.ok(offsets.length >= 1, 'spans are offset, not all flush left');
650
+ assert.deepEqual(offsets, [...offsets].sort((a, b) => a - b), 'offsets grow with start date');
651
+ });
652
+
653
+ test('RUN: a deck whose versions carry no dates renders every other section unharmed', async () => {
654
+ // The degradation that matters: /public/versions is fetched with a
655
+ // `.catch(() => [])`, so a 404 or a stripped payload must cost the timeline
656
+ // and nothing else. Before this task the whole page relied on that silently.
657
+ const { html } = await bootRoadmap({
658
+ versions: { versions: [{ id: 'BONGOS-V1', name: 'x', status: 'building', started_at: null, shipped_at: null }] },
659
+ });
660
+ assert.doesNotMatch(html, /In time</, 'no timeline section');
661
+ assert.match(html, /rm-hero/, 'but the hero still renders');
662
+ assert.doesNotMatch(html, /NaN|undefined/);
663
+ });
664
+
665
+ test('the timeline module is a pure builder, so it is testable and cannot boot itself', () => {
666
+ const src = read('roadmap-timeline.js');
667
+ // The node-require-safe tail (the hall-kit.js / dom-utils.js contract): the
668
+ // ONLY eval-time side effect is the assignment, and only when a window exists.
669
+ assert.match(src, /if \(typeof window !== 'undefined'\) window\.OTBRoadmapTimeline = api;/);
670
+ assert.match(src, /if \(typeof module !== 'undefined' && module\.exports\) module\.exports = api;/);
671
+ // It must not reach for the page: no document, no fetch, no client.
672
+ for (const forbidden of [/\bdocument\./, /\bfetch\(/, /BongosClient/]) {
673
+ assert.doesNotMatch(src, forbidden, `roadmap-timeline.js touches ${forbidden} — it is a builder, not a page`);
674
+ }
675
+ });
@@ -36,6 +36,7 @@ const PUBLISHED_SURFACE = [
36
36
  'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
37
37
  'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
38
38
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
39
+ 'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
39
40
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
40
41
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
41
42
  'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
@@ -0,0 +1,355 @@
1
+ // tests/project_catalog_bridge.mjs — THE CATALOG BRIDGE (task 1003576, ADR 0246).
2
+ //
3
+ // The bug: the owner's publish verdict is computed over `provisioning_instances` and the
4
+ // public map reads `platform_identity_projects`, and the only thing that ever carried the
5
+ // first into the second was federation — co-tenant-only, once, at provision time. A wizard-
6
+ // made standalone project reached `publishable: true` with an empty map (the BV1.R18 proof,
7
+ // task 1002337 §3).
8
+ //
9
+ // What this file pins:
10
+ // 1. THE PROJECTION is blind to hosting shape. A standalone, a co-tenant and a dedicated
11
+ // instance project identically — the thing the old gate got wrong.
12
+ // 2. THE ROW STAYS CURRENT. `description` and `visibility` are ASSIGNED by the upsert, not
13
+ // COALESCEd: a description added later reaches the map and one cleared later leaves it.
14
+ // The COALESCE is literally the bug, so the assertion is on the SQL.
15
+ // 3. THE TWO CLAIM RULES, which are the security half. The bridge writes rows keyed on
16
+ // values an owner supplies (a slug, a bring-your-own domain) and neither table proves
17
+ // who owns an origin, so: a credentialed client is never rewritten, and a catalog row
18
+ // whose owner does not already match is never claimed.
19
+ // 4. THE CHAIN TO THE MAP — the projected fields are exactly what the publish gate and the
20
+ // orb mapping read, so a published project lands on the map in the right orb and a
21
+ // described-nothing one stays dark matter.
22
+ //
23
+ // DB-free: a recording fake pool for the SQL, the real modules for everything else.
24
+ //
25
+ // Run: node --test --test-reporter=tap tests/project_catalog_bridge.mjs
26
+
27
+ import assert from 'node:assert/strict';
28
+ import { test } from 'node:test';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const catalog = require('../modules/platform-identity/project-catalog.js');
33
+ const feed = require('../modules/platform-identity/projects-feed.js');
34
+ const gate = require('../modules/platform-identity/publish-gate.js');
35
+ const bridge = require('../modules/provisioning/catalog-bridge.js');
36
+
37
+ // A recording fake pool: returns queued responses in order, records every call.
38
+ function mockPool(responses = []) {
39
+ const calls = [];
40
+ let i = 0;
41
+ return {
42
+ calls,
43
+ query: async (text, params) => {
44
+ calls.push({ text: String(text), params: params || [] });
45
+ return responses[i++] || { rows: [], rowCount: 0 };
46
+ },
47
+ };
48
+ }
49
+
50
+ // A live standalone instance that has answered the optional detail step — the exact row the
51
+ // R18 proof watched reach `publishable: true` and never reach the map.
52
+ const STANDALONE = Object.freeze({
53
+ id: 18, slug: 'spike-harbor', domain: 'spike-harbor.cloudbongos.test',
54
+ hosting_shape: 'standalone', status: 'active', owner_builder_id: 42,
55
+ description: 'A harbour for spikes.', type: 'research',
56
+ settings: { visibility: 'public', joinability: 'apply' },
57
+ });
58
+
59
+ // ── 1. the projection ────────────────────────────────────────────────────────
60
+
61
+ test('a standalone instance projects a complete catalog entry', () => {
62
+ // Arrange / Act
63
+ const entry = bridge.catalogEntryForInstance(STANDALONE, { ownerGithubId: 9001 });
64
+
65
+ // Assert — the slug is BOTH the project's name on this plane (what publishVerdict feeds
66
+ // the gate) and its client_id on the hub (what federateInstance uses), so a later real
67
+ // federation rotates the SAME row rather than orphaning it.
68
+ assert.equal(entry.clientId, 'spike-harbor');
69
+ assert.equal(entry.origin, 'https://spike-harbor.cloudbongos.test');
70
+ assert.equal(entry.name, 'spike-harbor');
71
+ assert.equal(entry.description, 'A harbour for spikes.');
72
+ assert.equal(entry.type, 'research');
73
+ assert.equal(entry.visibility, 'public');
74
+ assert.equal(entry.status, 'active');
75
+ assert.equal(entry.ownerGithubId, 9001);
76
+ });
77
+
78
+ test('the projection is BLIND to hosting shape — the old gate is what this task deletes', () => {
79
+ // shouldFederate() refused anything that was not 'co-tenant', which made hosting topology
80
+ // decide who is on the map. ADR 0182 D4 says the gate is IDENTITY. Three shapes, one row.
81
+ const shapes = ['standalone', 'co-tenant', 'dedicated'].map((hosting_shape) =>
82
+ bridge.catalogEntryForInstance({ ...STANDALONE, hosting_shape }, { ownerGithubId: 9001 }));
83
+ assert.deepEqual(shapes[0], shapes[1]);
84
+ assert.deepEqual(shapes[1], shapes[2]);
85
+ });
86
+
87
+ test('an instance with NO address is refused, because the catalog is keyed on origin', () => {
88
+ // `no_address: true` is a deliberate opt-out from having a public address, and a project
89
+ // with no address has nothing to key a public map row on. An honest refusal, not a failure.
90
+ assert.equal(bridge.catalogEntryForInstance({ ...STANDALONE, domain: null }, { ownerGithubId: 9001 }), null);
91
+ });
92
+
93
+ test('an instance with NO owner github id is refused rather than written un-refreshable', () => {
94
+ // The hub's claim rule compares owners, so a row stamped with a null owner could never be
95
+ // updated again. Refusing beats stranding.
96
+ for (const owner of [null, undefined, 0, NaN, 'nine']) {
97
+ assert.equal(bridge.catalogEntryForInstance(STANDALONE, { ownerGithubId: owner }), null,
98
+ `ownerGithubId ${String(owner)} must not produce an entry`);
99
+ }
100
+ });
101
+
102
+ test('a reclaimed instance projects a HIDDEN row; every other state is a project that exists', () => {
103
+ // 'requested' / 'provisioning' / 'error' are all "the owner is standing this up" — the map
104
+ // is a directory of projects, not an uptime board.
105
+ const statusOf = (status) => bridge.catalogEntryForInstance({ ...STANDALONE, status }, { ownerGithubId: 9001 }).status;
106
+ assert.equal(statusOf('tearing_down'), 'hidden');
107
+ assert.equal(statusOf('torn_down'), 'hidden');
108
+ for (const live of ['requested', 'provisioning', 'active', 'error']) {
109
+ assert.equal(statusOf(live), 'active', `${live} is a project that exists`);
110
+ }
111
+ });
112
+
113
+ test('the door comes from effectiveSettings, so an off-vocabulary stored value never leaks', () => {
114
+ // Same rule every other provisioning surface applies: an unknown stored value resolves to
115
+ // today's behaviour rather than travelling to a table with a CHECK constraint on it.
116
+ const entry = bridge.catalogEntryForInstance(
117
+ { ...STANDALONE, settings: { visibility: 'members-only' } }, { ownerGithubId: 9001 });
118
+ assert.equal(entry.visibility, 'public');
119
+ const stealth = bridge.catalogEntryForInstance(
120
+ { ...STANDALONE, settings: { visibility: 'stealth' } }, { ownerGithubId: 9001 });
121
+ assert.equal(stealth.visibility, 'stealth');
122
+ });
123
+
124
+ // ── 2. the row stays current ─────────────────────────────────────────────────
125
+
126
+ const ENTRY = Object.freeze({
127
+ clientId: 'spike-harbor', origin: 'https://spike-harbor.cloudbongos.test',
128
+ name: 'spike-harbor', description: 'A harbour for spikes.', type: 'research',
129
+ visibility: 'public', status: 'active', ownerGithubId: 9001,
130
+ });
131
+
132
+ test('the project upsert ASSIGNS description and visibility — the COALESCE was the bug', async () => {
133
+ // Arrange
134
+ const pool = mockPool([{ rows: [{ id: 4 }] }]);
135
+
136
+ // Act
137
+ await catalog.upsertOwnedProject(ENTRY, { pool });
138
+
139
+ // Assert — the SET clause, because that is where the bug lived: registerClient's
140
+ // upsertProject COALESCEs description, so a description written AFTER provisioning could
141
+ // never reach the catalog and the project stayed dark matter forever.
142
+ const set = (pool.calls[0].text.match(/DO UPDATE SET([\s\S]*?)WHERE/i) || [, ''])[1];
143
+ assert.match(set, /description\s*=\s*EXCLUDED\.description/);
144
+ assert.match(set, /visibility\s*=\s*EXCLUDED\.visibility/);
145
+ assert.doesNotMatch(set, /description\s*=\s*COALESCE/,
146
+ 'a COALESCEd description can never be CLEARED, so clearing one could not un-publish');
147
+ assert.doesNotMatch(set, /visibility\s*=\s*COALESCE/);
148
+ });
149
+
150
+ test('a CLEARED description travels as a real null, so the project leaves the map', async () => {
151
+ const pool = mockPool([{ rows: [{ id: 4, description: null }] }]);
152
+ await catalog.upsertOwnedProject({ ...ENTRY, description: null }, { pool });
153
+ assert.equal(pool.calls[0].params[2], null, 'description is the third bound value');
154
+ });
155
+
156
+ test('the upsert never touches CURATION — featured, tagline, art_url, template', async () => {
157
+ // ADR 0182 D3: featuring may rank among projects already on the map, never put one there.
158
+ // The bridge is not curation, so it must not be able to feature anything.
159
+ const pool = mockPool([{ rows: [{ id: 4 }] }]);
160
+ await catalog.upsertOwnedProject(ENTRY, { pool });
161
+ const sql = pool.calls[0].text;
162
+ const set = (sql.match(/DO UPDATE SET([\s\S]*?)WHERE/i) || [, ''])[1];
163
+ for (const col of ['featured', 'tagline', 'art_url', 'template']) {
164
+ assert.doesNotMatch(set, new RegExp(`\\b${col}\\s*=`), `${col} is curation and stays untouched`);
165
+ }
166
+ const insertCols = (sql.match(/platform_identity_projects\s*\(([\s\S]*?)\)/i) || [, ''])[1];
167
+ assert.doesNotMatch(insertCols, /\bfeatured\b/);
168
+ });
169
+
170
+ // ── 3. the two claim rules (the security half) ───────────────────────────────
171
+
172
+ test('the client row carries NO credential — the empty secret and allow-list are LITERALS', async () => {
173
+ // Not bound values: no caller of this function, now or later, can hand it a real secret or
174
+ // a redirect-URI allow-list entry. The row shape itself is what makes every SSO leg refuse.
175
+ const pool = mockPool([{ rows: [{ client_id: 'spike-harbor' }] }]);
176
+ await catalog.upsertCatalogOnlyClient(ENTRY, { pool });
177
+ const { text, params } = pool.calls[0];
178
+ assert.match(text, /VALUES\s*\(\s*\$1\s*,\s*''\s*,\s*\$2\s*,\s*'\{\}'::text\[\]/);
179
+ assert.equal(params.length, 4, 'client_id, name, origin, owner — no secret, no redirect uris');
180
+ assert.ok(!params.includes(''), 'the empty secret is SQL, never a value a caller supplied');
181
+ });
182
+
183
+ test('the client upsert can only ever refresh an UNCREDENTIALED row (claim rule 1)', async () => {
184
+ // A slug that collides with a real federated client must not have its `origin` rewritten —
185
+ // that origin is what binds the CLI device-mint audience (the task 1002108 fix) — and its
186
+ // secret must never be blanked.
187
+ const pool = mockPool([{ rows: [] }]);
188
+ const out = await catalog.upsertCatalogOnlyClient(ENTRY, { pool });
189
+ const text = pool.calls[0].text;
190
+ assert.match(text, /WHERE\s+platform_identity_sso_clients\.secret_hash\s*=\s*''/);
191
+ const set = (text.match(/DO UPDATE SET([\s\S]*?)WHERE/i) || [, ''])[1];
192
+ assert.doesNotMatch(set, /secret_hash/, 'the bridge may never write a secret');
193
+ assert.doesNotMatch(set, /redirect_uris/, 'nor an allow-list entry');
194
+ assert.doesNotMatch(set, /\bstatus\b/, 'a suspended client stays suspended');
195
+ assert.equal(out, null, 'a refused claim answers null, not a row');
196
+ });
197
+
198
+ test('the project upsert can only ever refresh a row this owner already holds (claim rule 2)', async () => {
199
+ // The hand-seeded catalog rows carry owner_github_id NULL, and `NULL = anything` is NULL,
200
+ // i.e. not true — so creating an instance at one of their addresses cannot claim them.
201
+ // First INSERT wins and stamps the owner; every later write must match it.
202
+ const pool = mockPool([{ rows: [] }]);
203
+ const out = await catalog.upsertOwnedProject(ENTRY, { pool });
204
+ const text = pool.calls[0].text;
205
+ assert.match(text, /WHERE\s+platform_identity_projects\.owner_github_id\s*=\s*EXCLUDED\.owner_github_id/);
206
+ const set = (text.match(/DO UPDATE SET([\s\S]*?)WHERE/i) || [, ''])[1];
207
+ assert.doesNotMatch(set, /owner_github_id/, 'ownership is stamped once and never reassigned');
208
+ assert.equal(out, null);
209
+ });
210
+
211
+ test('publish reports a REFUSED claim rather than pretending the project is on the map', async () => {
212
+ const pool = mockPool([{ rows: [] }, { rows: [] }]);
213
+ const out = await catalog.publishControlPlaneProject(ENTRY, { pool });
214
+ assert.equal(out.claimed, false);
215
+ assert.equal(out.project, null);
216
+ });
217
+
218
+ test('an entry without an owner is refused BEFORE any SQL runs', async () => {
219
+ const pool = mockPool([]);
220
+ await assert.rejects(() => catalog.publishControlPlaneProject({ ...ENTRY, ownerGithubId: null }, { pool }),
221
+ /ownerGithubId required/);
222
+ assert.equal(pool.calls.length, 0);
223
+ });
224
+
225
+ test('an unreadable door or lifecycle normalises CLOSED, never open', () => {
226
+ // Both CHECK-constrained columns; both fall to the direction that says less. A caller that
227
+ // sends garbage loses visibility, it never gains it.
228
+ const e = catalog.normalizeEntry({ ...ENTRY, visibility: 'members-only', status: 'featured' });
229
+ assert.equal(e.visibility, feed.FALLBACK_PROJECT_STATE);
230
+ assert.equal(e.visibility, 'stealth');
231
+ assert.equal(e.status, 'hidden');
232
+ });
233
+
234
+ // ── the re-point: the address a project moved OFF leaves the map ─────────────
235
+
236
+ test('a re-point retires the abandoned origin, owner-scoped', async () => {
237
+ const pool = mockPool([{ rows: [{ client_id: 'x' }] }, { rows: [{ id: 4 }] }, { rows: [{ id: 3 }] }]);
238
+ const out = await catalog.publishControlPlaneProject(ENTRY, { pool, retireOrigin: 'https://old.example' });
239
+ assert.equal(out.retired.id, 3);
240
+ const retire = pool.calls[2];
241
+ assert.match(retire.text, /SET status = 'hidden'/);
242
+ assert.match(retire.text, /WHERE origin = \$1 AND owner_github_id = \$2/);
243
+ assert.deepEqual(retire.params, ['https://old.example', 9001]);
244
+ });
245
+
246
+ test('re-publishing at the SAME origin retires nothing', async () => {
247
+ const pool = mockPool([{ rows: [{ client_id: 'x' }] }, { rows: [{ id: 4 }] }]);
248
+ const out = await catalog.publishControlPlaneProject(ENTRY, { pool, retireOrigin: ENTRY.origin });
249
+ assert.equal(out.retired, null);
250
+ assert.equal(pool.calls.length, 2, 'no third statement — the project did not move');
251
+ });
252
+
253
+ // ── 4. the chain to the map ──────────────────────────────────────────────────
254
+
255
+ // The row the upsert writes, built from ITS OWN RETURNING list so this test cannot drift
256
+ // from the statement: whatever the bridge stores is what the map then reads.
257
+ function catalogRowFor(entry) {
258
+ return { origin: entry.origin, name: entry.name, description: entry.description,
259
+ type: entry.type, visibility: entry.visibility, status: entry.status };
260
+ }
261
+
262
+ test('a described standalone project is PUBLISHABLE and draws as its door\'s orb', () => {
263
+ // The end of the R18 proof's failing case: publish verdict true, and now the map agrees.
264
+ const entry = bridge.catalogEntryForInstance(STANDALONE, { ownerGithubId: 9001 });
265
+ const row = catalogRowFor(catalog.normalizeEntry(entry));
266
+ assert.deepEqual(gate.projectPublishability(row), { publishable: true, missing: [] });
267
+ assert.equal(feed.feedShapeFor(row).orb, 'planet');
268
+ assert.equal(feed.feedShapeFor({ ...row, visibility: 'private' }).orb, 'star');
269
+ assert.equal(feed.feedShapeFor({ ...row, visibility: 'stealth' }).orb, 'black-hole');
270
+ });
271
+
272
+ test('a STEALTH standalone project is on the map ANONYMOUSLY — the row names nothing', () => {
273
+ // D2a, re-asserted at the end of the bridge rather than only at the feed: the thing the
274
+ // bridge newly puts on the map must be redactable, and it is because it is an ordinary
275
+ // catalog row that goes through feedShapeFor like every other.
276
+ const entry = bridge.catalogEntryForInstance(
277
+ { ...STANDALONE, settings: { visibility: 'stealth' } }, { ownerGithubId: 9001 });
278
+ const wire = feed.feedShapeFor(catalogRowFor(catalog.normalizeEntry(entry)));
279
+ for (const field of ['origin', 'name', 'description', 'type']) {
280
+ assert.equal(wire[field], null, `${field} must not leave the server for a black hole`);
281
+ }
282
+ assert.deepEqual(Object.keys(wire).filter((k) => wire[k] !== null).sort(), ['orb', 'state']);
283
+ });
284
+
285
+ test('a project that SKIPPED the detail step stays dark matter — the bridge publishes no husk', () => {
286
+ // The bridge puts the ROW on the map's table; the publish gate alone decides whether the
287
+ // map lists it. Catalogue-at-create must not smuggle an unnamed project onto the map.
288
+ const entry = bridge.catalogEntryForInstance({ ...STANDALONE, description: null }, { ownerGithubId: 9001 });
289
+ const row = catalogRowFor(catalog.normalizeEntry(entry));
290
+ assert.equal(row.description, null);
291
+ assert.deepEqual(gate.projectPublishability(row), { publishable: false, missing: ['description'] });
292
+ });
293
+
294
+ test('a reclaimed project is off the map even when it is fully described', () => {
295
+ // `status = 'active'` is the feed's other half of the predicate, and the projection is
296
+ // what supplies it.
297
+ const entry = bridge.catalogEntryForInstance({ ...STANDALONE, status: 'torn_down' }, { ownerGithubId: 9001 });
298
+ assert.equal(entry.status, 'hidden');
299
+ assert.equal(gate.projectPublishability(catalogRowFor(entry)).publishable, true,
300
+ 'still publishable by IDENTITY — it is `status` that takes it off, which is the right axis');
301
+ });
302
+
303
+ // ── 5. the seam: provisioning reaches the catalog through the kernel, or not at all ──
304
+ // Declared LAST on purpose: the port registry is process-global and single-provider, so the
305
+ // "no provider" case has to be observed before anything registers one.
306
+
307
+ const api = require('../src/module-api.js');
308
+
309
+ test('with NO catalog provider the bridge reports "not published" — it is not an error', async () => {
310
+ // platform-identity is hub-only (`default: false`). An instance running provisioning
311
+ // without it has no map to be on, and the honest answer is a reason, never a throw.
312
+ assert.equal(api.hasProvider('project.catalog'), false, 'nothing has registered one yet');
313
+ const out = await bridge.publishInstanceToCatalog(mockPool(), STANDALONE);
314
+ assert.deepEqual(out, { published: false, reason: 'no_catalog' });
315
+ });
316
+
317
+ test('the bridge resolves the port and hands it the projection, owner id and all', async () => {
318
+ // Arrange — a recording provider standing in for platform-identity's own port.
319
+ const seen = [];
320
+ api.registerProvider('project.catalog', {
321
+ publish: async (entry, opts) => { seen.push({ entry, opts }); return { claimed: true }; },
322
+ });
323
+ const pool = mockPool([{ rows: [{ github_id: 9001 }] }]);
324
+
325
+ // Act
326
+ const out = await bridge.publishInstanceToCatalog(pool, STANDALONE, { retireOrigin: 'old.example' });
327
+
328
+ // Assert
329
+ assert.equal(out.published, true);
330
+ assert.equal(seen[0].entry.origin, 'https://spike-harbor.cloudbongos.test');
331
+ assert.equal(seen[0].entry.ownerGithubId, 9001);
332
+ assert.equal(seen[0].opts.retireOrigin, 'https://old.example', 'a bare hostname is made an origin here');
333
+ assert.match(pool.calls[0].text, /SELECT github_id FROM builders WHERE id = \$1/);
334
+ assert.deepEqual(pool.calls[0].params, [42]);
335
+ });
336
+
337
+ test('an owner with no github identity is refused before the port is called', async () => {
338
+ const pool = mockPool([{ rows: [] }]);
339
+ const out = await bridge.publishInstanceToCatalog(pool, STANDALONE);
340
+ assert.deepEqual(out, { published: false, reason: 'no_owner_identity' });
341
+ });
342
+
343
+ test('an instance with no address reports no_address, not a failure', async () => {
344
+ const pool = mockPool([{ rows: [{ github_id: 9001 }] }]);
345
+ const out = await bridge.publishInstanceToCatalog(pool, { ...STANDALONE, domain: null });
346
+ assert.deepEqual(out, { published: false, reason: 'no_address' });
347
+ });
348
+
349
+ test('a hub that throws is SWALLOWED — the durable write it follows must never be undone', async () => {
350
+ // Every call site runs after the route's real work is already committed. A catalog hiccup
351
+ // that surfaced as a 500 would tell an owner their description was lost when it was not.
352
+ const pool = { query: async () => { throw new Error('hub unreachable'); } };
353
+ const out = await bridge.publishInstanceToCatalog(pool, STANDALONE);
354
+ assert.deepEqual(out, { published: false, reason: 'error' });
355
+ });