@bongos/core 1.19.773 → 1.19.774

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 (90) hide show
  1. package/.bongos-core.json +189 -74
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +2 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/ship-card.js +49 -13
  30. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  31. package/modules/platform-identity/platform-identity.js +8 -19
  32. package/modules/platform-identity/project-catalog.js +24 -0
  33. package/modules/platform-identity/routes/sso.js +11 -14
  34. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  35. package/modules/provisioning/capacity-gate.js +54 -0
  36. package/modules/provisioning/catalog-bridge.js +2 -0
  37. package/modules/provisioning/disconnect.js +35 -0
  38. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  39. package/modules/provisioning/module.json +1 -1
  40. package/modules/provisioning/provisioning.js +3 -3
  41. package/modules/provisioning/routes/disconnect.js +63 -0
  42. package/modules/provisioning/routes/provisioning.js +6 -6
  43. package/modules/public-landing/public/projects.html +116 -11
  44. package/modules/public-landing/public/projects.probes.json +3 -0
  45. package/modules/ui-design/kit/serve.js +3 -2
  46. package/modules/ui-design/module.json +1 -0
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/scripts/gds/address-park-sweep.sh +78 -0
  50. package/scripts/gds/cli-lib.js +37 -0
  51. package/scripts/gds/fitness-migration-contraction.js +50 -0
  52. package/scripts/gds/fitness.js +18 -0
  53. package/scripts/gds/land-watch.js +8 -2
  54. package/scripts/gds/provision-config.js +4 -4
  55. package/scripts/gds/provision-disconnect.js +90 -0
  56. package/scripts/gds/provision-teardown.js +108 -0
  57. package/scripts/gds/provision.js +6 -65
  58. package/scripts/gds/seam-wiring-guard.js +273 -0
  59. package/scripts/gds/ship-finish.js +17 -10
  60. package/scripts/gds/ship-flow.js +5 -5
  61. package/scripts/gds/start.js +13 -7
  62. package/src/bongos/auth-admission.js +7 -8
  63. package/src/bongos/auth-config.js +3 -2
  64. package/src/bongos/goal-scope-check.js +23 -8
  65. package/src/bongos/module-scope-map.js +40 -0
  66. package/src/module-api.js +1 -1
  67. package/src/module-loader/manifest-schema.js +79 -1
  68. package/tests/catalog_only_client.mjs +8 -5
  69. package/tests/fitness.mjs +22 -0
  70. package/tests/goal_scope_check.mjs +63 -1
  71. package/tests/hall_settings_world.mjs +57 -1
  72. package/tests/hub_disconnect_ui.mjs +105 -0
  73. package/tests/land_watch.mjs +44 -0
  74. package/tests/membership_kind_lockstep.mjs +86 -27
  75. package/tests/module-scope-map.mjs +39 -0
  76. package/tests/module_exports_unique_keys.mjs +70 -0
  77. package/tests/module_manifest.mjs +85 -0
  78. package/tests/project_catalog_forget.mjs +83 -0
  79. package/tests/projects_hub.mjs +10 -4
  80. package/tests/projects_hub_pre_uat.mjs +5 -3
  81. package/tests/provision_disconnect.mjs +231 -0
  82. package/tests/provision_teardown_address.mjs +230 -0
  83. package/tests/provisioning_capacity_gate.mjs +192 -0
  84. package/tests/provisioning_disconnect.mjs +231 -0
  85. package/tests/provisioning_restart.mjs +5 -5
  86. package/tests/public_landing_projects.mjs +4 -3
  87. package/tests/render_prefs.mjs +152 -0
  88. package/tests/seam_wiring_guard.mjs +234 -0
  89. package/tests/ship_card.mjs +67 -1
  90. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -0,0 +1,105 @@
1
+ // tests/hub_disconnect_ui.mjs
2
+ //
3
+ // The manage page's "Disconnect from Bongos entirely" card (task 1003249, ADR 0290).
4
+ //
5
+ // "Take it offline" is reversible and says so; this is the owner's TRUE delete, so it lives in
6
+ // its own card after the ender — never folded into it, where the reversible control could read
7
+ // as the destructive one. What these pins hold:
8
+ // • the card is real markup, after "Take it offline", with its fold a SIBLING of the volatile
9
+ // slot (the #mDelWhat shape), itemising what is erased and what is kept, and saying it
10
+ // cannot be undone — with no wall-clock promise
11
+ // • the ask is the name typed back; the act re-checks it at click time and SENDS it, because
12
+ // the route checks it again (confirm_slug)
13
+ // • the minute tick never repaints over a half-typed name (a project-scoped guard), and an open
14
+ // run is named rather than offering a button the server would refuse
15
+ // • the copy that said nothing on this page erases a project was true until this card: it now
16
+ // scopes that promise to "Take it offline", and the fold points at the card
17
+ // • the design kit's stub answers the ask, so the probe drives it end to end
18
+ //
19
+ // Run: node tests/hub_disconnect_ui.mjs
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
29
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
30
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
31
+ const fn = (name) => (SCRIPT.match(new RegExp(`function ${name}\\([^)]*\\)\\s*\\{([\\s\\S]*?)\\n \\}`)) || [, ''])[1];
32
+ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
33
+
34
+ test('the card is real markup, after "Take it offline", with the fold outside the volatile slot', () => {
35
+ const del = HUB.indexOf('id="mDelete"');
36
+ const card = (HUB.match(/<div class="card mSec" id="mDisconnect">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
37
+ assert.ok(card, '#mDisconnect must be extractable');
38
+ assert.ok(del > 0 && HUB.indexOf('id="mDisconnect"') > del, 'the true delete comes after the reversible ender');
39
+ assert.match(card, /<h2>Disconnect from Bongos entirely<\/h2>/);
40
+ const slot = card.indexOf('id="mDisc"'), fold = card.indexOf('id="mDiscWhat"');
41
+ assert.ok(slot > 0 && fold > slot, 'the fold is a sibling after the slot, so no re-render can drop it');
42
+ });
43
+
44
+ test('the fold itemises what is erased and what is kept, says it cannot be undone, and promises no time', () => {
45
+ const fold = (HUB.match(/<details class="finePrint" id="mDiscWhat">[\s\S]*?<\/details>/) || [''])[0];
46
+ assert.ok(fold);
47
+ assert.match(fold, /<summary>What this erases — and what it keeps<\/summary>/);
48
+ for (const erased of [/database/, /nightly backup/, /place on the map/, /members/, /name and web address are released/]) {
49
+ assert.match(fold, erased, `names what is erased: ${erased}`);
50
+ }
51
+ assert.match(fold, /<b>Kept:<\/b> your repository on GitHub/);
52
+ assert.match(fold, /GitHub sign-in app/, 'the one thing GitHub will not let the platform remove is named, with where to remove it');
53
+ assert.match(fold, /cannot be undone/);
54
+ assert.match(fold, /<b>Take it offline<\/b> above/, 'the reversible alternative is pointed at');
55
+ assert.doesNotMatch(fold, PROMISE);
56
+ });
57
+
58
+ test('the ask is the name typed back, and the act re-checks it and SENDS it for the server to check again', () => {
59
+ const ask = fn('askDisconnect');
60
+ assert.ok(ask, 'askDisconnect() must be extractable');
61
+ assert.match(ask, /mDiscAsking = inst\.slug;/);
62
+ assert.match(ask, /Type the project’s name to confirm/);
63
+ assert.match(ask, /id="mDiscName"/);
64
+ assert.match(ask, /data-disc-yes disabled/);
65
+ assert.match(ask, /data-disc-no/);
66
+ assert.match(ask, /cannot be undone/);
67
+ const act = fn('doDisconnect');
68
+ assert.match(act, /if \(!inst \|\| !discTypedMatches\(\)\) return;/, 'an enabled-LOOKING button cannot act');
69
+ assert.match(act, /'\/disconnect'/);
70
+ assert.match(act, /JSON\.stringify\(\{ confirm_slug: /, 'the typed name travels as confirm_slug');
71
+ assert.match(act, /loadManage\(true\)/, 'the page re-reads the project so the queued run shows');
72
+ });
73
+
74
+ test('the render guards a half-typed name per project, names an open run, and offers the button otherwise', () => {
75
+ const render = fn('renderDisconnect');
76
+ assert.ok(render, 'renderDisconnect() must be extractable');
77
+ assert.match(render, /if \(mDiscAsking && mDiscAsking === inst\.slug\) return;/);
78
+ assert.match(render, /open\.action === 'disconnect'/);
79
+ assert.match(render, /This project is being disconnected\./);
80
+ assert.match(render, /Disconnecting waits for the current /, 'another open run is named, no button the server would refuse');
81
+ assert.match(render, /data-disc-ask/);
82
+ assert.doesNotMatch(render, PROMISE);
83
+ assert.match(SCRIPT, /renderDelete\(inst\);\n[\s\S]{0,200}renderDisconnect\(inst\);/, 'rendered on the same fetch-free tick as the ender');
84
+ });
85
+
86
+ test('the ender no longer claims nothing on the page erases — it scopes that to itself and points at the card', () => {
87
+ const truth = fn('teardownTruth');
88
+ assert.equal((truth.match(/until you bring it back\. Taking it offline erases nothing\.'/g) || []).length, 2, 'both shapes');
89
+ assert.doesNotMatch(HUB, /Nothing here erases a project/);
90
+ const delFold = (HUB.match(/<details class="finePrint" id="mDelWhat">[\s\S]*?<\/details>/) || [''])[0];
91
+ assert.doesNotMatch(delFold, /no permanent-delete button/i, 'the capability exists now');
92
+ assert.match(delFold, /<b>Disconnect from Bongos entirely<\/b> below/);
93
+ assert.match(fn('renderDelete'), /open_intent\.action === 'disconnect'/, 'a queued disconnect is not offered a teardown the server would refuse');
94
+ });
95
+
96
+ test('the design kit stub answers the ask, and a probe drives it end to end', () => {
97
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
98
+ assert.match(stub, /\(teardown\|restart\|domain\|disconnect\)/);
99
+ assert.match(stub, /disconnect: \{ queued: true, message: /);
100
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
101
+ const p = probes.find((x) => /disconnect/.test(x.name));
102
+ assert.ok(p, 'a disconnect probe exists');
103
+ assert.ok(p.steps.some((s) => s[0] === 'fill' && s[1] === '#mDiscName'));
104
+ assert.ok(p.steps.some((s) => s[0] === 'click' && s[1] === '[data-disc-yes]'));
105
+ });
@@ -89,6 +89,50 @@ t('falls back to the task row for title/value when the crumb lacks them', () =>
89
89
  assert.ok(/From task row/.test(env.html), 'title should come from the fresh task row');
90
90
  });
91
91
 
92
+ // ── the card-richness rung survives the async land (task 1002518) ────────────────────
93
+ //
94
+ // This is the path the ladder is easiest to lose on. The card a CI land renders is
95
+ // built minutes later, in another process, from the breadcrumb alone — so if the
96
+ // crumb does not carry the rung the shipping session resolved, the shipped card
97
+ // silently reverts to the default rendering and the builder's choice is honoured
98
+ // everywhere EXCEPT the one card that says the work actually landed.
99
+
100
+ t('a crumb carrying cardFlags renders at that rung — rich opens the summary', () => {
101
+ const flags = { level: 'rich', showCard: true, showWidget: true, expanded: true };
102
+ const env = buildShippedEnvelope({ ...CRUMB, cardFlags: flags }, { status: 'shipped' });
103
+ assert.equal(env.widgets_enabled, true);
104
+ assert.match(env.html, /<div style="display:block;margin-top:0\.6rem/, 'the rich rung opens the summary on arrival');
105
+ });
106
+
107
+ t('cardFlags at a non-widget rung writes no widget, whatever the legacy boolean says', () => {
108
+ // widgetsEnabled:true is left on the crumb deliberately — cardFlags must WIN.
109
+ // A crumb written by this CLI carries both, so the precedence is what decides
110
+ // the render, not which field happens to be present.
111
+ const flags = { level: 'text', showCard: true, showWidget: false, expanded: false };
112
+ const env = buildShippedEnvelope({ ...CRUMB, widgetsEnabled: true, cardFlags: flags }, { status: 'shipped' });
113
+ assert.equal(env.widgets_enabled, false, 'the rung decides, not the legacy boolean');
114
+ assert.ok(env.markdown && /Shipped/.test(env.markdown), 'the monospace card still carries the land');
115
+ });
116
+
117
+ t('the standard rung leaves the summary folded — expanded is not assumed from showWidget', () => {
118
+ const flags = { level: 'standard', showCard: true, showWidget: true, expanded: false };
119
+ const env = buildShippedEnvelope({ ...CRUMB, cardFlags: flags }, { status: 'shipped' });
120
+ assert.equal(env.widgets_enabled, true);
121
+ assert.match(env.html, /<div style="display:none;margin-top:0\.6rem/, 'folded, as the standard rung means');
122
+ });
123
+
124
+ t('a crumb WITHOUT cardFlags still reads the legacy boolean — older crumbs on disk', () => {
125
+ // A breadcrumb written before this shipped is sitting on someone's machine right
126
+ // now, and land-watch will read it. It must render exactly as it always did.
127
+ for (const widgetsEnabled of [true, false]) {
128
+ const env = buildShippedEnvelope({ ...CRUMB, widgetsEnabled, cardFlags: undefined }, { status: 'shipped' });
129
+ assert.equal(env.widgets_enabled, widgetsEnabled, `legacy crumb (${widgetsEnabled}) keeps its meaning`);
130
+ if (widgetsEnabled) {
131
+ assert.match(env.html, /<div style="display:none;margin-top:0\.6rem/, 'a legacy crumb never renders expanded');
132
+ }
133
+ }
134
+ });
135
+
92
136
  // ── watch() orchestration (injected deps — no disk, no network) ──────────────────────
93
137
  await ta('emits the NEWEST shipped card, keeps confirmed, drops the rest — one card per run', async () => {
94
138
  const removed = [];
@@ -91,45 +91,104 @@ for (const m of EXPAND_MIGRATIONS) {
91
91
  });
92
92
  }
93
93
 
94
- test('the value mapping is one shared function, not repeated inline', () => {
95
- // Route, sender and trigger must agree that 'builder' ⇄ 'member'; three copies of a
96
- // ternary is how they would stop agreeing.
97
- const pi = read('modules/platform-identity/platform-identity.js');
98
- assert.match(pi, /function membershipKindFromLegacy\(/);
99
- assert.match(pi, /function legacyRoleFromKind\(/);
100
- const sso = read('modules/platform-identity/routes/sso.js');
101
- assert.match(sso, /pi\.membershipKindFromLegacy\(role\)/,
102
- 'the check-in translates the legacy field through the shared mapping');
94
+ test('the expand-phase trigger mapped the value pair in BOTH directions (history, still readable)', () => {
95
+ // 010 is history now — 021 dropped the trigger — but the expand migration stays
96
+ // in the tree (a from-scratch replay must still match), so its shape stays pinned:
97
+ // this is the trigger the no-DEFAULT rule above exists for.
103
98
  const sql = read('modules/platform-identity/migrations/platform_identity_010_membership_kind.sql');
104
- assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the same pair');
105
- assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/);
99
+ assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the legacy value in');
100
+ assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/, 'and the new value out');
106
101
  });
107
102
 
108
- test('the sender writes BOTH spellings, computing the kind once (the cross-version window)', () => {
103
+ test('the sender writes ONE spelling, computing the kind once (the window is closed)', () => {
109
104
  // task 1003206: the auth flows are auth.js + auth-*.js now — read the family.
105
+ // During the window (task 1003090) this body carried BOTH spellings; task 1003092
106
+ // closed it. The legacy field must not come back: the hub schema is strict, so
107
+ // re-adding it would turn every federated check-in into a 400.
110
108
  const auth = authSource();
111
109
  const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
112
- assert.match(body, /membership_kind: kind/, 'the new field goes on the wire');
113
- assert.match(body, /role: kind === 'member' \? 'builder' : kind/,
114
- 'and the legacy field too — an un-upgraded hub would otherwise drop the owner flag');
110
+ assert.match(body, /membership_kind: kind/, 'the field goes on the wire');
111
+ assert.ok(!/\brole:/.test(body),
112
+ 'the legacy field is gone from the wire — the hub no longer accepts it (task 1003092)');
115
113
  assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
116
- 'the kind is computed ONCE per check-in, not per field');
114
+ 'the kind is computed ONCE per check-in');
115
+ });
116
+
117
+ test('the translator functions are gone from the domain module (nothing left to translate)', () => {
118
+ const pi = read('modules/platform-identity/platform-identity.js');
119
+ assert.ok(!/membershipKindFromLegacy/.test(pi), 'membershipKindFromLegacy was the window; the window is closed');
120
+ assert.ok(!/legacyRoleFromKind/.test(pi), 'legacyRoleFromKind likewise');
121
+ const sso = read('modules/platform-identity/routes/sso.js');
122
+ assert.ok(!/membershipKindFromLegacy/.test(sso), 'the route no longer translates anything');
117
123
  });
118
124
 
119
- // ── THE CONTRACT PHASE — the test of the shim's own removal (task 1003089) ─────
125
+ // ── THE CONTRACT PHASES — the tests of the shims' own removal ──────────────────
120
126
  //
121
127
  // ADR 0174's Lineage lesson: a time-boxed shim outlives its release unless its
122
- // removal ships with the test that says it is gone. core_240 closed the GOAL
123
- // membership window — the trigger, the function and `goal_members.role` are all
124
- // dropped, and `membership_kind` carries NOT NULL + DEFAULT itself rather than
125
- // resting on a trigger surviving.
126
- //
127
- // The PROJECT membership half (platform_identity_010) is deliberately NOT here:
128
- // it is still inside its cross-version federation window, its contract phase is
129
- // task 1003092, and the expand-phase block above is what still guards it. Do not
130
- // merge the two — the whole point of the pair is that they close on different
131
- // schedules.
128
+ // removal ships with the test that says it is gone. The two windows closed on
129
+ // DIFFERENT schedules, which is the whole point of the pair:
130
+ // • core_240 (task 1003089) closed the GOAL membership window one release later;
131
+ // • platform_identity_021 (task 1003092) closed the PROJECT membership window
132
+ // only once every federated instance that can check in was past core 1.19.241,
133
+ // because its writer is another instance (ADR 0177 §"the window closed").
134
+ // In both, the trigger, the function and the legacy column are dropped, and
135
+ // `membership_kind` carries NOT NULL + DEFAULT itself rather than resting on a
136
+ // trigger surviving.
132
137
  const CONTRACT = 'migrations/core_240_goal_members_drop_legacy_role.sql';
138
+ const PI_CONTRACT = 'modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql';
139
+
140
+ test('platform_identity_021: the contract phase drops the trigger, the function AND the column', () => {
141
+ const sql = read(PI_CONTRACT);
142
+ assert.match(sql, /DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep\s+ON platform_identity_project_memberships/);
143
+ assert.match(sql, /DROP FUNCTION IF EXISTS platform_identity_membership_kind_lockstep\(\)/,
144
+ 'the FUNCTION outlives the trigger unless it is dropped by name');
145
+ assert.match(sql, /ALTER TABLE platform_identity_project_memberships DROP COLUMN IF EXISTS role/);
146
+ assert.match(sql, /ALTER TABLE platform_identity_project_memberships ALTER COLUMN membership_kind SET NOT NULL/,
147
+ 'the guarantee 010 deferred to step 2 arrives here, or the column never gets one');
148
+ assert.match(sql, /ALTER TABLE platform_identity_project_memberships ALTER COLUMN membership_kind SET DEFAULT 'member'/,
149
+ 'safe only NOW: a default while the legacy column was still written is the task-1003090 bug');
150
+ // The last backfill must carry the VALUE rename too — a stray legacy 'builder'
151
+ // must never land in membership_kind, whose CHECK does not admit it.
152
+ assert.match(sql, /CASE WHEN role = ''builder'' THEN ''member'' ELSE role END/,
153
+ 'the guarded backfill maps the craft name, not just copies the column');
154
+ const trigDrop = sql.indexOf('DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep');
155
+ const colDrop = sql.indexOf('DROP COLUMN IF EXISTS role');
156
+ assert.ok(trigDrop > -1 && trigDrop < colDrop,
157
+ 'the trigger goes BEFORE the column: plpgsql bodies are not dependency-tracked, so a survivor fails at the next write');
158
+ });
159
+
160
+ test('platform_identity_021: the forward-unsafe pragma is present, and its FIRST line carries the gating fact', () => {
161
+ const scan = lint.scanMigrationSql(read(PI_CONTRACT));
162
+ assert.deepEqual([...scan.unsafe].sort(), ['drop-column', 'set-not-null'],
163
+ 'the linter must still SEE both operations — an acknowledged migration is not an exempt one');
164
+ assert.ok(scan.acknowledged, 'a drop-column with no pragma is unacknowledged forward-unsafety');
165
+ // The one fact that makes THIS drop safe is not a hub release alone — it is that
166
+ // every federated sender is past the release carrying the expand phase.
167
+ assert.match(scan.ackReason, /1\.19\.241/,
168
+ 'the pragma must name the release the senders had to be past, not merely assert safety');
169
+ assert.match(scan.ackReason, /federated instance/i,
170
+ 'and say the condition was about the SENDERS — the cross-version window is what made this one different');
171
+ });
172
+
173
+ test('no platform-identity migration outside the three that own its lifecycle still names project_memberships.role', () => {
174
+ // Comments are stripped first: the history may DISCUSS the old column; this rule
175
+ // is about statements. The exemptions are the migrations that created the column,
176
+ // expanded away from it, and dropped it.
177
+ const OWNS_ITS_LIFECYCLE = new Set([
178
+ 'platform_identity_003_project_memberships.sql', // created it
179
+ 'platform_identity_010_membership_kind.sql', // expand: backfilled from it; the trigger kept it in step
180
+ 'platform_identity_021_drop_legacy_role.sql', // contract: dropped it
181
+ ]);
182
+ const dir = path.join(ROOT, 'modules', 'platform-identity', 'migrations');
183
+ const offenders = [];
184
+ for (const f of fs.readdirSync(dir)) {
185
+ if (!f.endsWith('.sql') || OWNS_ITS_LIFECYCLE.has(f)) continue;
186
+ const code = lint.stripSqlComments(fs.readFileSync(path.join(dir, f), 'utf8'));
187
+ if (/project_memberships/.test(code) && /\brole\b/.test(code)) offenders.push(f);
188
+ }
189
+ assert.deepEqual(offenders, [],
190
+ `these migrations touch project_memberships and still name the dropped column: ${offenders.join(', ')}`);
191
+ });
133
192
 
134
193
  test('core_240: the contract phase drops the trigger, the function AND the column', () => {
135
194
  const sql = read(CONTRACT);
@@ -252,3 +252,42 @@ test('methodology joined the roster (task 1002989) — the instruction surfaces
252
252
  assert.equal(covers('scripts/gds/fitness.js'), false, 'the fitness tool stayed with devsecops (task 1003124)');
253
253
  assert.equal(covers('scripts/gds/ship.js'), false, 'the ship pipeline belongs to lifecycle');
254
254
  });
255
+
256
+ test('SHARED_GLOBS: the ownerless floor every goal wall includes (task 1003655, ADR 0288)', () => {
257
+ // `tests/` is the whole floor and the bar for joining it is high: no module can
258
+ // honestly own the path AND every task legitimately writes it.
259
+ assert.deepEqual([...scope.SHARED_GLOBS], ['tests/']);
260
+ // It is SHARED, not OWNED — no module key may claim it, or the floor would be a
261
+ // second list drifting against the roster.
262
+ for (const k of scope.moduleKeys()) {
263
+ const globs = scope.scopeForModule(k).globs;
264
+ assert.ok(!globs.includes('tests/'), `module "${k}" must not own the shared root tests/`);
265
+ }
266
+ // Module-owned suites keep their own module's key — they are not part of the floor.
267
+ assert.equal(gsc.scopeSubset(['lifecycle'], ['modules/lifecycle/tests/ship.mjs']).ok, true);
268
+ assert.equal(gsc.scopeSubset(['art-pipeline'], ['modules/lifecycle/tests/ship.mjs']).ok, false);
269
+ });
270
+
271
+ test('SHARED_GLOBS is DERIVED against PROTECTED_GLOBS, never a trusted literal', () => {
272
+ // Same contract as `protected` (ADR 0086 §7): a protected path listed here would
273
+ // widen EVERY goal's wall into the trust core in one line, so the export filters
274
+ // its own literal rather than trusting it.
275
+ for (const g of scope.SHARED_GLOBS) {
276
+ assert.equal(scope.isProtected([g]), false, `shared glob ${g} overlaps the protected core`);
277
+ }
278
+ assert.deepEqual(
279
+ [...scope.SHARED_GLOBS],
280
+ scope.SHARED_GLOBS_RAW.filter((g) => !scope.isProtected([g])),
281
+ 'the exported floor must be the faithful filter of its literal',
282
+ );
283
+ // Prove the filter actually filters, rather than passing because the literal is clean.
284
+ assert.deepEqual(['tests/', 'migrations/'].filter((g) => !scope.isProtected([g])), ['tests/']);
285
+ });
286
+
287
+ test('the gap this closed: an honest task declaring its own test is now authorizable', () => {
288
+ // Before task 1003655 this was refused out_of_scope under EVERY goal, because
289
+ // tests/ resolved to no module — so the wall was only passable by under-declaring
290
+ // touches[] (idea 1000816). Asserted through the real consumer.
291
+ const r = gsc.check(['ideas'], ['modules/ideas/inbox.js', 'tests/idea_routing.mjs'], 'thetes');
292
+ assert.equal(r.ok, true, r.reason);
293
+ });
@@ -0,0 +1,70 @@
1
+ // tests/module_exports_unique_keys.mjs
2
+ //
3
+ // No `module.exports = { … }` shorthand literal may name the same key twice (task 1003922).
4
+ //
5
+ // modules/provisioning/provisioning.js carried two adjacent export lines that repeated
6
+ // createInstance, setInstanceStatus, updateInstanceDomain and updateInstanceSettings, a
7
+ // merge residue. JavaScript accepts it silently, which is the hazard: a later edit that
8
+ // removes or renames a key on ONE line leaves the other copy exporting it, so the edit
9
+ // looks applied and is not. This scans every server-side .js file's trailing shorthand
10
+ // export literal, so the next one fails here instead of in review.
11
+ //
12
+ // Run: node tests/module_exports_unique_keys.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const SKIP_DIRS = new Set(['node_modules', 'vendor', '.git', 'public']);
21
+
22
+ function jsFiles(dir, out = []) {
23
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
24
+ if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) jsFiles(path.join(dir, entry.name), out); }
25
+ else if (entry.name.endsWith('.js')) out.push(path.join(dir, entry.name));
26
+ }
27
+ return out;
28
+ }
29
+
30
+ // The trailing `module.exports = { … };` — only when it is pure shorthand (no `key: value`,
31
+ // calls, spreads or strings), since that is the form a duplicated line hides in.
32
+ function duplicateExportKeys(src) {
33
+ const at = src.lastIndexOf('module.exports = {');
34
+ if (at < 0) return [];
35
+ const end = src.indexOf('\n};', at);
36
+ if (end < 0) return [];
37
+ const body = src.slice(at + 'module.exports = {'.length, end)
38
+ .replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
39
+ if (/[:(){}[\]'"`.]/.test(body)) return [];
40
+ const counts = new Map();
41
+ for (const key of body.split(',').map((s) => s.trim()).filter(Boolean)) counts.set(key, (counts.get(key) || 0) + 1);
42
+ return [...counts].filter(([, n]) => n > 1).map(([key]) => key);
43
+ }
44
+
45
+ let passed = 0, failed = 0;
46
+ function t(name, fn) {
47
+ try { fn(); passed++; console.log(` PASS ${name}`); }
48
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
49
+ }
50
+
51
+ t('the detector finds a repeated key across two export lines, and ignores a clean or non-shorthand literal', () => {
52
+ assert.deepEqual(duplicateExportKeys('module.exports = {\n a, b,\n a, c,\n};\n'), ['a']);
53
+ assert.deepEqual(duplicateExportKeys('module.exports = {\n a, b, // a\n c,\n};\n'), []);
54
+ assert.deepEqual(duplicateExportKeys('module.exports = {\n a: 1, a: 2,\n};\n'), []);
55
+ });
56
+
57
+ t('no server-side module.exports shorthand literal repeats a key', () => {
58
+ const files = ['src', 'modules', 'scripts'].flatMap((d) => jsFiles(path.join(ROOT, d)));
59
+ files.push(path.join(ROOT, 'server.js'));
60
+ assert.ok(files.length > 100, `scanned too few files (${files.length}) — the walk is broken`);
61
+ const offenders = files
62
+ .filter((f) => fs.existsSync(f))
63
+ .map((f) => [path.relative(ROOT, f).replace(/\\/g, '/'), duplicateExportKeys(fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n'))])
64
+ .filter(([, keys]) => keys.length)
65
+ .map(([f, keys]) => `${f}: ${keys.join(', ')}`);
66
+ assert.deepEqual(offenders, [], `a key exported twice is a merge hazard — merge the lines:\n${offenders.join('\n')}`);
67
+ });
68
+
69
+ console.log(`\nmodule_exports_unique_keys: ${passed} passed, ${failed} failed`);
70
+ process.exit(failed ? 1 : 0);
@@ -276,3 +276,88 @@ test('every bundled module declares a maintenance posture (the catalog has no un
276
276
  });
277
277
  assert.deepEqual(undeclared, [], `these modules declare no maintenance.status: ${undeclared.join(', ')}`);
278
278
  });
279
+
280
+ // --- declarativeSeams: the seam-wiring guard's exemption list (task 1003819) ---
281
+ //
282
+ // The exemption is validated AGAINST `contributes` because this is the one place
283
+ // holding both. A typo'd exemption that validated clean would exempt nothing while
284
+ // reading as though it exempted something — the failure mode in the direction that
285
+ // HIDES a defect, which is exactly what the guard exists to catch.
286
+
287
+ test('declarativeSeams: a valid exemption naming a declared seam + entry passes', () => {
288
+ const r = validateManifest({
289
+ ...baseManifest(),
290
+ declarativeSeams: { pollers: ['box-lifecycle'], uiSections: ['boxes'] },
291
+ });
292
+ assert.equal(r.valid, true, r.errors.join('; '));
293
+ });
294
+
295
+ test('declarativeSeams: an exemption for a seam the module does not declare is an ERROR', () => {
296
+ const r = validateManifest({
297
+ ...baseManifest(),
298
+ contributes: { routes: ['box'] }, // no pollers declared
299
+ declarativeSeams: { pollers: ['box-lifecycle'] },
300
+ });
301
+ assert.equal(r.valid, false);
302
+ assert.ok(r.errors.some((e) => /does not declare contributes\.pollers/.test(e)), r.errors.join('; '));
303
+ });
304
+
305
+ test('declarativeSeams: an exemption naming an ENTRY the module does not list is an ERROR (the typo case)', () => {
306
+ const r = validateManifest({
307
+ ...baseManifest(),
308
+ declarativeSeams: { pollers: ['box-lifecyle'] }, // transposed letters
309
+ });
310
+ assert.equal(r.valid, false);
311
+ assert.ok(r.errors.some((e) => /"box-lifecyle" is not in contributes\.pollers/.test(e)), r.errors.join('; '));
312
+ });
313
+
314
+ test('declarativeSeams: the per-entry check spans EVERY seam, not just bare string arrays', () => {
315
+ // webSurfaces declares OBJECTS and `migrations: true` is implicit, so an earlier cut
316
+ // that compared exemptions against raw strings bailed on both (`if (!isStrArray…)
317
+ // continue`) and let a bogus exemption validate clean — 2 of the 8 allowed keys
318
+ // silently outside acceptance criterion 3. The comparison is against the LABELS now.
319
+ const base = { ...baseManifest(), contributes: { webSurfaces: [{ host: 'status.', dir: 'public' }] } };
320
+ const bad = validateManifest({ ...base, declarativeSeams: { webSurfaces: ['bogus'] } });
321
+ assert.equal(bad.valid, false, 'a bogus webSurfaces exemption must not validate');
322
+ assert.ok(bad.errors.some((e) => /"bogus" is not in contributes\.webSurfaces/.test(e)), bad.errors.join('; '));
323
+ assert.equal(validateManifest({ ...base, declarativeSeams: { webSurfaces: ['status.(public)'] } }).valid, true);
324
+
325
+ const mig = { ...baseManifest(), contributes: { migrations: true } };
326
+ assert.equal(validateManifest({ ...mig, declarativeSeams: { migrations: ['bogus'] } }).valid, false);
327
+ assert.equal(validateManifest({ ...mig, declarativeSeams: { migrations: ['migrations/'] } }).valid, true);
328
+ });
329
+
330
+ test('declarativeSeams: an unknown seam key is an ERROR, not a silent no-op', () => {
331
+ const r = validateManifest({ ...baseManifest(), declarativeSeams: { pollerz: ['x'] } });
332
+ assert.equal(r.valid, false);
333
+ assert.ok(r.errors.some((e) => /declarativeSeams\.pollerz: unknown seam/.test(e)), r.errors.join('; '));
334
+ });
335
+
336
+ test('declarativeSeams: shape errors — non-object block, non-string-array entries', () => {
337
+ assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: ['pollers'] }).valid, false);
338
+ assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: 'box-lifecycle' } }).valid, false);
339
+ assert.equal(validateManifest({ ...baseManifest(), declarativeSeams: { pollers: [''] } }).valid, false);
340
+ });
341
+
342
+ test('declarativeSeams is OPTIONAL — a manifest without it still validates', () => {
343
+ const m = baseManifest();
344
+ assert.ok(!('declarativeSeams' in m));
345
+ assert.equal(validateManifest(m).valid, true);
346
+ });
347
+
348
+ test('every bundled module with a declarativeSeams block validates against its own contributes', async () => {
349
+ // The live manifests are the real regression surface: an exemption that drifts from
350
+ // the contributes block it exempts is the defect this field is here to prevent.
351
+ const fsm = (await import('node:fs')).default;
352
+ const modulesDir = path.join(ROOT, 'modules');
353
+ const bad = [];
354
+ for (const k of fsm.readdirSync(modulesDir)) {
355
+ const mp = path.join(modulesDir, k, 'module.json');
356
+ if (!fsm.existsSync(mp)) continue;
357
+ const m = JSON.parse(fsm.readFileSync(mp, 'utf8'));
358
+ if (!m.declarativeSeams) continue;
359
+ const r = validateManifest(m);
360
+ if (!r.valid) bad.push(`${k}: ${r.errors.join('; ')}`);
361
+ }
362
+ assert.deepEqual(bad, [], bad.join('\n'));
363
+ });
@@ -0,0 +1,83 @@
1
+ // tests/project_catalog_forget.mjs
2
+ //
3
+ // The catalog's half of "Disconnect from Bongos entirely" (task 1003249, ADR 0290): a
4
+ // disconnected project leaves the map for good — its catalog row, its catalog client and the
5
+ // membership rows keyed on that client are DELETED, not hidden.
6
+ //
7
+ // It writes rows keyed on values an owner supplies (a slug, an address), so it keeps the two
8
+ // claim rules the publish path keeps (project-catalog.js header):
9
+ // 1. the PROJECT row goes only when its owner is this instance's owner;
10
+ // 2. the CLIENT (and its memberships) goes only when it is bound to this origin or was never
11
+ // credentialed — a slug that collides with a real, credentialed client elsewhere is not
12
+ // ours to delete.
13
+ //
14
+ // DB-free: a recording pool answers by statement shape.
15
+ //
16
+ // Run: node tests/project_catalog_forget.mjs
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const catalog = require('../modules/platform-identity/project-catalog.js');
24
+
25
+ function pool({ client = null } = {}) {
26
+ const calls = [];
27
+ return {
28
+ calls,
29
+ query: async (sql, params) => {
30
+ calls.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
31
+ if (/^SELECT .* FROM platform_identity_sso_clients/.test(sql.replace(/\s+/g, ' ').trim())) return { rows: client ? [client] : [] };
32
+ if (/^DELETE FROM platform_identity_projects/.test(sql.replace(/\s+/g, ' ').trim())) return { rowCount: 1, rows: [] };
33
+ if (/^DELETE FROM platform_identity_project_memberships/.test(sql.replace(/\s+/g, ' ').trim())) return { rowCount: 3, rows: [] };
34
+ if (/^DELETE FROM platform_identity_sso_clients/.test(sql.replace(/\s+/g, ' ').trim())) return { rowCount: 1, rows: [] };
35
+ throw new Error(`unexpected SQL: ${sql}`);
36
+ },
37
+ };
38
+ }
39
+ const TARGET = { clientId: 'lantern', origin: 'https://lantern.example.com/', ownerGithubId: 4242 };
40
+ const deletes = (p) => p.calls.filter((c) => /^DELETE/.test(c.sql));
41
+
42
+ test('an uncredentialed catalog-only client: the project row, the client and its memberships all go', async () => {
43
+ const p = pool({ client: { origin: 'https://lantern.example.com', secret_hash: '' } });
44
+ const out = await catalog.forgetOwnedProject(TARGET, { pool: p });
45
+ assert.deepEqual(out, { project: 1, client: 1, memberships: 3 });
46
+ const d = deletes(p);
47
+ assert.match(d[0].sql, /DELETE FROM platform_identity_projects WHERE origin = \$1 AND owner_github_id = \$2/);
48
+ assert.deepEqual(d[0].params, ['https://lantern.example.com', 4242], 'origin normalised, owner-scoped (claim rule 2)');
49
+ assert.deepEqual(d[1].params, ['lantern']);
50
+ assert.match(d[1].sql, /platform_identity_project_memberships/);
51
+ assert.match(d[2].sql, /platform_identity_sso_clients/, 'memberships go before the client they key on');
52
+ });
53
+
54
+ test('a credentialed client bound to THIS origin is this project\'s — it goes too', async () => {
55
+ const p = pool({ client: { origin: 'https://lantern.example.com', secret_hash: 'abc123' } });
56
+ const out = await catalog.forgetOwnedProject(TARGET, { pool: p });
57
+ assert.equal(out.client, 1);
58
+ });
59
+
60
+ test('a credentialed client at ANOTHER origin shares only the slug — it and its memberships are left alone (claim rule 1)', async () => {
61
+ const p = pool({ client: { origin: 'https://someone-else.example.org', secret_hash: 'abc123' } });
62
+ const out = await catalog.forgetOwnedProject(TARGET, { pool: p });
63
+ assert.deepEqual(out, { project: 1, client: 0, memberships: 0 });
64
+ assert.equal(deletes(p).length, 1, 'only the owner-scoped project row');
65
+ });
66
+
67
+ test('no client row: the project row still goes', async () => {
68
+ const p = pool();
69
+ assert.deepEqual(await catalog.forgetOwnedProject(TARGET, { pool: p }), { project: 1, client: 0, memberships: 0 });
70
+ });
71
+
72
+ test('no address or no owner identity: nothing is touched', async () => {
73
+ for (const t of [{ ...TARGET, origin: '' }, { ...TARGET, ownerGithubId: null }, { ...TARGET, clientId: '' }]) {
74
+ const p = pool({ client: { origin: 'https://lantern.example.com', secret_hash: '' } });
75
+ assert.deepEqual(await catalog.forgetOwnedProject(t, { pool: p }), { project: 0, client: 0, memberships: 0 });
76
+ assert.equal(p.calls.length, 0);
77
+ }
78
+ });
79
+
80
+ test('the kernel port exposes it as `forget`, beside publish and catalogued', () => {
81
+ assert.equal(typeof catalog.projectCatalogPort.forget, 'function');
82
+ assert.deepEqual(Object.keys(catalog.projectCatalogPort).sort(), ['catalogued', 'forget', 'publish']);
83
+ });
@@ -1305,7 +1305,8 @@ test('"delete" never claims to erase — the fold states what is kept', () => {
1305
1305
  assert.match(fold, /does <b>not<\/b> erase the project/i, 'the distinction is stated, not implied');
1306
1306
  assert.match(fold, /torn down/, 'it names the state the project actually lands in');
1307
1307
  assert.match(fold, /repository on GitHub is untouched/, 'and what survives');
1308
- assert.match(fold, /no permanent-delete button/i, 'the absent capability is admitted, not faked');
1308
+ // the true delete exists since task 1003249 — the fold points at it rather than admitting its absence
1309
+ assert.match(fold, /use <b>Disconnect from Bongos entirely<\/b> below/, 'the erase lives in its own card, and the fold says where');
1309
1310
  });
1310
1311
 
1311
1312
  test('tearing a project down asks for its NAME — the page\'s heaviest control', () => {
@@ -1350,12 +1351,17 @@ test('the delete section mirrors the server gate — every state but torn_down',
1350
1351
  'and the sentence is dropped where no such button rendered');
1351
1352
  });
1352
1353
 
1353
- test('a teardown does not silence the address — the copy says what the runner does', () => {
1354
+ test('a teardown keeps the address reserved and says nothing answers there — the copy says what the runner does', () => {
1354
1355
  const fn = (SCRIPT.match(/function teardownTruth\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1355
1356
  assert.ok(fn, 'teardownTruth() must be extractable');
1356
- // the runner has NO DNS or Caddy leg, so the hostname keeps resolving and 502s
1357
+ // the runner parks the hostname and removes its Caddy block (task 1002897), but the
1358
+ // row keeps the address, so the copy says "reserved", never "goes away"
1357
1359
  assert.doesNotMatch(fn, /the address stops answering/,
1358
- 'the old claim was untrue — teardown releases neither the DNS record nor the Caddy block');
1360
+ 'the address is held for a revive, not released');
1361
+ assert.match(fn, /nothing answers there until you bring it back/,
1362
+ 'what a visitor now meets at the parked address');
1363
+ assert.doesNotMatch(fn, /shows an error page/,
1364
+ 'the pre-park 502 promise is gone');
1359
1365
  assert.match(fn, /stays reserved for this project/,
1360
1366
  'the honest word, and the accurate one — a revive re-uses exactly this hostname');
1361
1367
  assert.match(fn, /repository is untouched/, 'what survives is still stated in both shapes');