@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.
- package/.bongos-core.json +189 -74
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
- package/docs/adr/0177-goal-membership-kind.md +26 -2
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
- package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
- package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
- package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +102 -11
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +205 -190
- package/docs/copy-registry.json +435 -269
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/modules-contract.md +1 -1
- package/modules/builder-settings/render-prefs.js +281 -33
- package/modules/builder-settings/routes/settings.js +20 -12
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/dev-box/module.json +4 -0
- package/modules/hall-ui/public/settings-render.js +138 -0
- package/modules/hall-ui/public/settings.html +16 -9
- package/modules/hall-ui/public/settings.js +3 -70
- package/modules/lifecycle/ship-card.js +49 -13
- package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
- package/modules/platform-identity/platform-identity.js +8 -19
- package/modules/platform-identity/project-catalog.js +24 -0
- package/modules/platform-identity/routes/sso.js +11 -14
- package/modules/platform-identity/tests/platform-identity.mjs +7 -9
- package/modules/provisioning/capacity-gate.js +54 -0
- package/modules/provisioning/catalog-bridge.js +2 -0
- package/modules/provisioning/disconnect.js +35 -0
- package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/disconnect.js +63 -0
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +116 -11
- package/modules/public-landing/public/projects.probes.json +3 -0
- package/modules/ui-design/kit/serve.js +3 -2
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +78 -0
- package/scripts/gds/cli-lib.js +37 -0
- package/scripts/gds/fitness-migration-contraction.js +50 -0
- package/scripts/gds/fitness.js +18 -0
- package/scripts/gds/land-watch.js +8 -2
- package/scripts/gds/provision-config.js +4 -4
- package/scripts/gds/provision-disconnect.js +90 -0
- package/scripts/gds/provision-teardown.js +108 -0
- package/scripts/gds/provision.js +6 -65
- package/scripts/gds/seam-wiring-guard.js +273 -0
- package/scripts/gds/ship-finish.js +17 -10
- package/scripts/gds/ship-flow.js +5 -5
- package/scripts/gds/start.js +13 -7
- package/src/bongos/auth-admission.js +7 -8
- package/src/bongos/auth-config.js +3 -2
- package/src/bongos/goal-scope-check.js +23 -8
- package/src/bongos/module-scope-map.js +40 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +79 -1
- package/tests/catalog_only_client.mjs +8 -5
- package/tests/fitness.mjs +22 -0
- package/tests/goal_scope_check.mjs +63 -1
- package/tests/hall_settings_world.mjs +57 -1
- package/tests/hub_disconnect_ui.mjs +105 -0
- package/tests/land_watch.mjs +44 -0
- package/tests/membership_kind_lockstep.mjs +86 -27
- package/tests/module-scope-map.mjs +39 -0
- package/tests/module_exports_unique_keys.mjs +70 -0
- package/tests/module_manifest.mjs +85 -0
- package/tests/project_catalog_forget.mjs +83 -0
- package/tests/projects_hub.mjs +10 -4
- package/tests/projects_hub_pre_uat.mjs +5 -3
- package/tests/provision_disconnect.mjs +231 -0
- package/tests/provision_teardown_address.mjs +230 -0
- package/tests/provisioning_capacity_gate.mjs +192 -0
- package/tests/provisioning_disconnect.mjs +231 -0
- package/tests/provisioning_restart.mjs +5 -5
- package/tests/public_landing_projects.mjs +4 -3
- package/tests/render_prefs.mjs +152 -0
- package/tests/seam_wiring_guard.mjs +234 -0
- package/tests/ship_card.mjs +67 -1
- package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// tests/seam_wiring_guard.mjs — the declared-seam-is-consumed guard (task 1003819,
|
|
2
|
+
// ADR 0289; fitness Check 33, successor to abandoned task 1002464).
|
|
3
|
+
//
|
|
4
|
+
// The scanner is the tested surface, driven over SYNTHETIC manifests with an injected
|
|
5
|
+
// `exists` — the scanScopeMapSource / scanDeterministicBody precedent. That keeps every
|
|
6
|
+
// case hermetic (no tmp dirs, no fixture modules) AND lets a case prove the guard
|
|
7
|
+
// actually catches a violation, which a suite that only runs it over the real tree
|
|
8
|
+
// cannot: a clean tree passes whether the check works or is a stub.
|
|
9
|
+
//
|
|
10
|
+
// The three properties that make this guard worth having, each pinned below:
|
|
11
|
+
// 1. a declared entry whose consumer's artifact is absent is REPORTED, naming the
|
|
12
|
+
// module key and the seam;
|
|
13
|
+
// 2. a legitimately declarative-only entry is DISTINGUISHED from that, and a STALE
|
|
14
|
+
// exemption (the artifact exists now) is itself reported;
|
|
15
|
+
// 3. the wired set is reported on the PASS path too — a guard that is silent when
|
|
16
|
+
// it passes leaves no evidence it ran.
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const guard = require(path.join(ROOT, 'scripts', 'gds', 'seam-wiring-guard.js'));
|
|
27
|
+
const { scanSeamWiring, SEAM_CONSUMERS, declaredEntries, entryLabel } = guard;
|
|
28
|
+
|
|
29
|
+
// A synthetic module. `dir` is a bare key — the scanner only ever hands it to
|
|
30
|
+
// SEAM_CONSUMERS.artifact() and to `exists`, both of which we control here.
|
|
31
|
+
const mod = (key, contributes, declarativeSeams) => ({
|
|
32
|
+
key,
|
|
33
|
+
dir: `modules/${key}`,
|
|
34
|
+
manifest: { key, contributes, ...(declarativeSeams ? { declarativeSeams } : {}) },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// An `exists` that answers true for exactly the paths listed (forward-slashed).
|
|
38
|
+
const only = (...paths) => {
|
|
39
|
+
const set = new Set(paths);
|
|
40
|
+
return (p) => set.has(String(p).split(path.sep).join('/'));
|
|
41
|
+
};
|
|
42
|
+
const nothingExists = () => false;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// 1. a declared seam with no consumer artifact is reported
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
test('reports a declared poller whose file was never shipped, naming module + seam', () => {
|
|
49
|
+
const r = scanSeamWiring([mod('dev-box', { pollers: ['ghost'] })], { exists: nothingExists });
|
|
50
|
+
assert.equal(r.ok, false);
|
|
51
|
+
assert.equal(r.findings.length, 1);
|
|
52
|
+
assert.match(r.findings[0], /module "dev-box"/);
|
|
53
|
+
assert.match(r.findings[0], /contributes\.pollers: "ghost"/);
|
|
54
|
+
assert.match(r.findings[0], /modules\/dev-box\/pollers\/ghost\.js does not exist/);
|
|
55
|
+
// and it says what to do about it — the three legitimate resolutions
|
|
56
|
+
assert.match(r.findings[0], /Ship the artifact, drop the declaration, or record it in declarativeSeams\.pollers/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('every seam kind is covered: file, per-module file, dir, and the webSurfaces object', () => {
|
|
60
|
+
const r = scanSeamWiring([
|
|
61
|
+
mod('a', { routes: ['missing'] }),
|
|
62
|
+
mod('b', { uiSections: ['sec'] }),
|
|
63
|
+
mod('c', { skills: ['sk'] }),
|
|
64
|
+
mod('d', { webSurfaces: [{ host: 'x.', dir: 'public' }] }),
|
|
65
|
+
mod('e', { migrations: true }),
|
|
66
|
+
], { exists: nothingExists });
|
|
67
|
+
assert.equal(r.findings.length, 5, r.findings.join('\n'));
|
|
68
|
+
for (const [key, frag] of [
|
|
69
|
+
['a', 'modules/a/routes/missing.js'],
|
|
70
|
+
['b', 'modules/b/public/hall-widget.js'],
|
|
71
|
+
['c', 'modules/c/skills/sk'],
|
|
72
|
+
['d', 'modules/d/public'],
|
|
73
|
+
['e', 'modules/e/migrations'],
|
|
74
|
+
]) {
|
|
75
|
+
assert.ok(r.findings.some((f) => f.includes(`module "${key}"`) && f.includes(frag)),
|
|
76
|
+
`no finding for ${key} naming ${frag}:\n${r.findings.join('\n')}`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a seam NOTHING consumes is reported on sight — the strongest form of the defect', () => {
|
|
81
|
+
// `rooms` is allowed by the manifest schema but read by no code in the tree.
|
|
82
|
+
assert.equal(SEAM_CONSUMERS.rooms.consumer, null);
|
|
83
|
+
// …and shipping the file does not help, because nothing would load it.
|
|
84
|
+
const r = scanSeamWiring([mod('game', { rooms: ['WorldRoom'] })], { exists: only('modules/game/rooms/WorldRoom.js') });
|
|
85
|
+
assert.equal(r.ok, false);
|
|
86
|
+
assert.match(r.findings[0], /NOTHING in the tree consumes the rooms seam at all/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a disciplines entry absent from the registry is reported', () => {
|
|
90
|
+
const r = scanSeamWiring([mod('ui-design', { disciplines: ['nope'] })], { registryKeys: new Set(['ui']) });
|
|
91
|
+
assert.equal(r.ok, false);
|
|
92
|
+
assert.match(r.findings[0], /has no such entry/);
|
|
93
|
+
// a null registry leaves the seam UNCHECKED rather than failing it
|
|
94
|
+
assert.equal(scanSeamWiring([mod('ui-design', { disciplines: ['nope'] })], { registryKeys: null }).ok, true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// 2. declarative-only is distinguished, and a stale exemption is caught
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
test('a declarative-only entry is recorded, not reported — the load-bearing nuance', () => {
|
|
102
|
+
const r = scanSeamWiring(
|
|
103
|
+
[mod('dev-box', { pollers: ['box-lifecycle'] }, { pollers: ['box-lifecycle'] })],
|
|
104
|
+
{ exists: nothingExists },
|
|
105
|
+
);
|
|
106
|
+
assert.equal(r.ok, true, r.findings.join('; '));
|
|
107
|
+
assert.deepEqual(r.declarative, [{ key: 'dev-box', seam: 'pollers', entry: 'box-lifecycle' }]);
|
|
108
|
+
assert.deepEqual(r.wired, []);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the exemption is per-ENTRY, not a blanket skip of the seam', () => {
|
|
112
|
+
const r = scanSeamWiring(
|
|
113
|
+
[mod('dev-box', { pollers: ['box-lifecycle', 'ghost'] }, { pollers: ['box-lifecycle'] })],
|
|
114
|
+
{ exists: nothingExists },
|
|
115
|
+
);
|
|
116
|
+
assert.equal(r.ok, false);
|
|
117
|
+
assert.equal(r.findings.length, 1);
|
|
118
|
+
assert.match(r.findings[0], /"ghost"/);
|
|
119
|
+
assert.deepEqual(r.declarative.map((d) => d.entry), ['box-lifecycle']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a STALE exemption — the artifact exists now — is itself reported', () => {
|
|
123
|
+
const r = scanSeamWiring(
|
|
124
|
+
[mod('dev-box', { pollers: ['box-lifecycle'] }, { pollers: ['box-lifecycle'] })],
|
|
125
|
+
{ exists: only('modules/dev-box/pollers/box-lifecycle.js') },
|
|
126
|
+
);
|
|
127
|
+
assert.equal(r.ok, false);
|
|
128
|
+
assert.match(r.findings[0], /exempts pollers "box-lifecycle" as declarative-only, but modules\/dev-box\/pollers\/box-lifecycle\.js exists/);
|
|
129
|
+
assert.match(r.findings[0], /drop the stale declarativeSeams\.pollers entry/);
|
|
130
|
+
assert.deepEqual(r.declarative, [], 'a stale exemption must not also count as declarative-only');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('an exemption on one seam does not leak to another seam of the same module', () => {
|
|
134
|
+
const r = scanSeamWiring(
|
|
135
|
+
[mod('dev-box', { pollers: ['x'], uiSections: ['x'] }, { pollers: ['x'] })],
|
|
136
|
+
{ exists: nothingExists },
|
|
137
|
+
);
|
|
138
|
+
assert.equal(r.findings.length, 1);
|
|
139
|
+
assert.match(r.findings[0], /contributes\.uiSections/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a skill counts as wired from the module tree OR its vendor/ subdir', () => {
|
|
143
|
+
const own = scanSeamWiring([mod('ui-design', { skills: ['style'] })], { exists: only('modules/ui-design/skills/style/SKILL.md') });
|
|
144
|
+
assert.equal(own.ok, true, own.findings.join('; '));
|
|
145
|
+
const vendored = scanSeamWiring([mod('ui-design', { skills: ['style'] })], { exists: only('modules/ui-design/skills/vendor/style/SKILL.md') });
|
|
146
|
+
assert.equal(vendored.ok, true, vendored.findings.join('; '));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('a skills/<name>/ dir with NO SKILL.md is unwired — the consumer requires the file', () => {
|
|
150
|
+
// moduleSkillSources tests `fs.existsSync(path.join(p, 'SKILL.md'))`, so a bare dir
|
|
151
|
+
// is not a skill to it. Probing the directory would call such an entry wired while
|
|
152
|
+
// the real consumer skipped it — this guard's own version of the defect it catches.
|
|
153
|
+
const r = scanSeamWiring([mod('ui-design', { skills: ['style'] })], { exists: only('modules/ui-design/skills/style') });
|
|
154
|
+
assert.equal(r.ok, false, 'a dir without SKILL.md must not count as wired');
|
|
155
|
+
assert.match(r.findings[0], /modules\/ui-design\/skills\/style\/SKILL\.md does not exist/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('an entry that reaches outside its own module dir is reported, never probed', () => {
|
|
159
|
+
// Only routes/pollers/rooms/uiSections/skills are constrained to non-empty strings
|
|
160
|
+
// by the schema, so an entry could carry '..'. The probe is an existence test over
|
|
161
|
+
// repo-committed content, but a declaration reaching out of its module is a finding.
|
|
162
|
+
let probed = 0;
|
|
163
|
+
const r = scanSeamWiring([mod('a', { routes: ['../../etc/passwd'] })], { exists: () => { probed += 1; return true; } });
|
|
164
|
+
assert.equal(r.ok, false);
|
|
165
|
+
assert.match(r.findings[0], /outside the module's own directory/);
|
|
166
|
+
assert.equal(probed, 0, 'the escaping path must not be probed at all');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// 3. the wired set is reported on the PASS path too
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
test('a fully wired module reports its wired entries, so a pass is evidence the guard ran', () => {
|
|
174
|
+
const r = scanSeamWiring(
|
|
175
|
+
[mod('economy', { routes: ['cost', 'credits'], migrations: true })],
|
|
176
|
+
{ exists: only('modules/economy/routes/cost.js', 'modules/economy/routes/credits.js', 'modules/economy/migrations') },
|
|
177
|
+
);
|
|
178
|
+
assert.equal(r.ok, true, r.findings.join('; '));
|
|
179
|
+
assert.equal(r.wired.length, 3);
|
|
180
|
+
assert.deepEqual(r.wired.map((w) => w.entry).sort(), ['cost', 'credits', 'migrations/']);
|
|
181
|
+
for (const w of r.wired) assert.ok(w.via, 'each wired entry names the consumer that wires it');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('an empty array and an absent contributes declare nothing at all', () => {
|
|
185
|
+
const r = scanSeamWiring([mod('discord', { uiSections: [] }), mod('quiet', {})], { exists: nothingExists });
|
|
186
|
+
assert.equal(r.ok, true);
|
|
187
|
+
assert.deepEqual(r.wired, []);
|
|
188
|
+
assert.deepEqual(r.declarative, []);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('an unknown contributes key is validateManifest\'s error, not this guard\'s', () => {
|
|
192
|
+
const r = scanSeamWiring([mod('x', { pollerz: ['a'] })], { exists: nothingExists });
|
|
193
|
+
assert.equal(r.ok, true, 'the guard must not double-report a shape error');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the guard and the schema share ONE enumerator + labeller, not two copies', () => {
|
|
197
|
+
// The exemption cross-check (validateManifest) and the wiring check (this guard)
|
|
198
|
+
// must agree on what a contributes block declares and what each entry is called.
|
|
199
|
+
// Two copies would drift, and the drift would land on whether an exemption is honoured.
|
|
200
|
+
const schema = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
|
|
201
|
+
assert.equal(declaredEntries, schema.declaredEntries);
|
|
202
|
+
assert.equal(entryLabel, schema.entryLabel);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('declaredEntries + entryLabel: migrations true/array, and the two webSurface shapes', () => {
|
|
206
|
+
assert.deepEqual(declaredEntries('migrations', true), [true]);
|
|
207
|
+
assert.deepEqual(declaredEntries('migrations', ['001_x.sql']), ['001_x.sql']);
|
|
208
|
+
assert.deepEqual(declaredEntries('migrations', false), []);
|
|
209
|
+
assert.deepEqual(declaredEntries('routes', undefined), []);
|
|
210
|
+
assert.equal(entryLabel('migrations', true), 'migrations/');
|
|
211
|
+
assert.equal(entryLabel('webSurfaces', { host: 'status.', dir: 'public' }), 'status.(public)');
|
|
212
|
+
assert.equal(entryLabel('webSurfaces', { apexRoot: true, dir: 'public' }), 'apexRoot:public');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// the live tree — the regression the guard exists to hold
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
test('the real module set is clean: every declared seam is wired or recorded declarative-only', () => {
|
|
220
|
+
const r = guard.checkSeamWiring();
|
|
221
|
+
assert.equal(r.ok, true, r.violations.join('\n'));
|
|
222
|
+
assert.equal(r.hardFail, false);
|
|
223
|
+
// the pass note carries the evidence, not just "ok"
|
|
224
|
+
assert.match(r.note, /WIRED across \d+ seam\(s\)/);
|
|
225
|
+
assert.match(r.note, /recorded declarative-only/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('every seam the manifest schema allows has an entry in SEAM_CONSUMERS', () => {
|
|
229
|
+
// Otherwise a new contributes key would be silently unchecked — the same gap in a
|
|
230
|
+
// new place. The schema's list is the source; this guard must span all of it.
|
|
231
|
+
const { ALLOWED_CONTRIBUTES } = require(path.join(ROOT, 'src', 'module-loader', 'manifest-schema.js'));
|
|
232
|
+
const missing = ALLOWED_CONTRIBUTES.filter((k) => !(k in SEAM_CONSUMERS));
|
|
233
|
+
assert.deepEqual(missing, [], `SEAM_CONSUMERS has no entry for: ${missing.join(', ')}`);
|
|
234
|
+
});
|
package/tests/ship_card.mjs
CHANGED
|
@@ -291,7 +291,73 @@ test('emitCard with widgetsEnabled:false: monospace only, no HTML, off-marker',
|
|
|
291
291
|
assert.equal(file, null, 'no HTML file written when widgets are off');
|
|
292
292
|
assert.match(out, /Example World · task #1266/); // the monospace card still printed
|
|
293
293
|
assert.ok(!out.includes('[otb-card-html]'), 'no [otb-card-html] marker emitted');
|
|
294
|
-
assert.match(out, /
|
|
294
|
+
assert.match(out, /rich cards are off/); // off-marker tells the skill not to call show_widget
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ---- the card-richness ladder (task 1002518) --------------------------------
|
|
298
|
+
// emitCard receives the resolved rung as FLAGS, never as a level string: the
|
|
299
|
+
// ladder lives in modules/builder-settings/render-prefs.js and a module may not
|
|
300
|
+
// require a sibling module (ADR 0083). These pin what each rung draws.
|
|
301
|
+
|
|
302
|
+
test('flags.expanded opens the widget summary; without it the summary stays folded', () => {
|
|
303
|
+
const model = card.buildCardModel(shippedInput);
|
|
304
|
+
const folded = card.renderCardHtml(model);
|
|
305
|
+
const open = card.renderCardHtml(model, { expanded: true });
|
|
306
|
+
// The summary body is the LAST div; folded hides it, expanded shows it.
|
|
307
|
+
assert.match(folded, /<div style="display:none;margin-top:0\.6rem/, 'default card folds its summary');
|
|
308
|
+
assert.match(open, /<div style="display:block;margin-top:0\.6rem/, 'expanded card opens its summary');
|
|
309
|
+
// The toggle still works either way — expanded only picks the starting state.
|
|
310
|
+
assert.match(open, /Toggle task summary/);
|
|
311
|
+
assert.match(open, /rotate\(90deg\)/, 'chevron points down when it starts open');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('minimal rung: no monospace card at all, but the confirmation line survives', () => {
|
|
315
|
+
const lines = [];
|
|
316
|
+
const file = card.emitCard(card.buildCardModel(shippedInput), 99997, {
|
|
317
|
+
log: (s) => lines.push(String(s)),
|
|
318
|
+
flags: { showCard: false, showWidget: false, expanded: false },
|
|
319
|
+
});
|
|
320
|
+
const out = lines.join('\n');
|
|
321
|
+
assert.equal(file, null, 'no HTML written at the minimal rung');
|
|
322
|
+
assert.ok(!out.includes('Example World · task #1266'), 'the monospace card is NOT printed');
|
|
323
|
+
assert.ok(!out.includes('[otb-card-html]'), 'no widget marker emitted');
|
|
324
|
+
// task 1001730's floor holds at every rung — this is the whole reason the
|
|
325
|
+
// confirm line is emitted before the card branches.
|
|
326
|
+
assert.match(out, /\[otb-confirm\] shipped: task 1266/, 'the guaranteed line still prints');
|
|
327
|
+
assert.match(out, /minimal cards/, 'the directive tells the skill to relay only that line');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('rich rung: the widget is written AND its summary starts open', () => {
|
|
331
|
+
const lines = [];
|
|
332
|
+
const file = card.emitCard(card.buildCardModel(shippedInput), 99996, {
|
|
333
|
+
log: (s) => lines.push(String(s)),
|
|
334
|
+
flags: { showCard: true, showWidget: true, expanded: true },
|
|
335
|
+
});
|
|
336
|
+
assert.ok(file, 'HTML file written at the rich rung');
|
|
337
|
+
const html = fs.readFileSync(file, 'utf8');
|
|
338
|
+
assert.match(html, /<div style="display:block;margin-top:0\.6rem/, 'summary open on arrival');
|
|
339
|
+
const out = lines.join('\n');
|
|
340
|
+
assert.match(out, /Example World · task #1266/, 'monospace card still printed');
|
|
341
|
+
assert.match(out, /\[otb-card-html\]/, 'widget marker emitted');
|
|
342
|
+
fs.unlinkSync(file);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('flags absent falls back to the historical boolean meaning', () => {
|
|
346
|
+
// A caller that has not learned the ladder yet (an older script, a test) keeps
|
|
347
|
+
// exactly the behaviour it had: monospace always, widget iff true, never open.
|
|
348
|
+
for (const widgetsEnabled of [true, false]) {
|
|
349
|
+
const lines = [];
|
|
350
|
+
const file = card.emitCard(card.buildCardModel(shippedInput), 99995, {
|
|
351
|
+
log: (s) => lines.push(String(s)), widgetsEnabled,
|
|
352
|
+
});
|
|
353
|
+
const out = lines.join('\n');
|
|
354
|
+
assert.match(out, /Example World · task #1266/, `boolean ${widgetsEnabled} still prints the monospace card`);
|
|
355
|
+
assert.equal(!!file, widgetsEnabled, `boolean ${widgetsEnabled} decides the widget`);
|
|
356
|
+
if (file) {
|
|
357
|
+
assert.match(fs.readFileSync(file, 'utf8'), /<div style="display:none;margin-top:0\.6rem/, 'never expanded via the boolean');
|
|
358
|
+
fs.unlinkSync(file);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
295
361
|
});
|
|
296
362
|
|
|
297
363
|
// ---- lifecycle trail: full journey on every card (tasks 1487 + 1489) ----
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// tests/sso_checkin_legacy_field_gone.mjs — the test of the shim's ABSENCE: the
|
|
2
|
+
// legacy `role` field on POST /sso/membership/check-in is gone (task 1003092,
|
|
3
|
+
// ADR 0177 §"the window closed"; the Lineage lesson of ADR 0174).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS, AND WHY IT LANDED WITH THE REMOVAL AND NOT BEFORE. Task
|
|
6
|
+
// 1003090 renamed the coarse project membership from `role` to `membership_kind`
|
|
7
|
+
// but kept accepting the legacy field for a cross-version window, because the
|
|
8
|
+
// callers are OTHER instances on their own upgrade schedule. For as long as an
|
|
9
|
+
// un-upgraded instance could still send `role: 'builder'`, the field was
|
|
10
|
+
// load-bearing — dropping it would have silently defaulted that instance's owner
|
|
11
|
+
// to 'member' (the owner flag vanishing from My Projects). Only once every
|
|
12
|
+
// federated instance that can check in was past core 1.19.241 could the hub
|
|
13
|
+
// stop understanding it — and the moment it does, THIS test is what keeps the
|
|
14
|
+
// shim from quietly coming back.
|
|
15
|
+
//
|
|
16
|
+
// Three things are pinned, at three altitudes:
|
|
17
|
+
// 1. ROUTE — a body carrying `role` is refused by the strict schema (400,
|
|
18
|
+
// unknown_field) and touches the database NOT AT ALL: no client lookup, no
|
|
19
|
+
// membership statement. "Must no longer set the membership" is proven by the
|
|
20
|
+
// absence of any SQL, not by inspecting a value it might have written.
|
|
21
|
+
// 2. CONTROL — the same body with `membership_kind` instead still lands, and
|
|
22
|
+
// the UPDATE carries the kind it named. The removal is a wall, not a leak.
|
|
23
|
+
// 3. CONTRACT — the generated OpenAPI + reference no longer list the field, so
|
|
24
|
+
// a typed client regenerated from them cannot offer it.
|
|
25
|
+
//
|
|
26
|
+
// DB-free: a recording fake pool, the REAL sso router, the REAL domain module,
|
|
27
|
+
// core pinned through the doorway (the tests/sso_checkin_identity_spoof.mjs
|
|
28
|
+
// harness). Runs in the default unit gate.
|
|
29
|
+
//
|
|
30
|
+
// Run: node --test tests/sso_checkin_legacy_field_gone.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { test, after } from 'node:test';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { dirname, join } from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const express = require('express');
|
|
42
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
|
|
44
|
+
// ---- the recording fake pool ----------------------------------------------
|
|
45
|
+
let queued = [];
|
|
46
|
+
let calls = [];
|
|
47
|
+
const pool = {
|
|
48
|
+
query: async (text, params) => {
|
|
49
|
+
calls.push({ text: String(text).replace(/\s+/g, ' ').trim(), params: params || [] });
|
|
50
|
+
return queued.shift() || { rows: [], rowCount: 0 };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
function expect(...responses) { queued = responses; calls = []; }
|
|
54
|
+
const sqlFor = (re) => calls.filter((c) => re.test(c.text));
|
|
55
|
+
|
|
56
|
+
// ---- the real routes over the real domain, with core pinned ----------------
|
|
57
|
+
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
|
58
|
+
const moduleApiPath = require.resolve('../src/module-api.js');
|
|
59
|
+
const helpers = require('../src/bongos/routes/_helpers.js');
|
|
60
|
+
require.cache[moduleApiPath] = {
|
|
61
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
62
|
+
exports: {
|
|
63
|
+
pool,
|
|
64
|
+
validateOrRespond: helpers.validateOrRespond,
|
|
65
|
+
resolveSession: async () => null,
|
|
66
|
+
logger: () => noopLog,
|
|
67
|
+
hasProvider: () => true,
|
|
68
|
+
registerProvider: () => {},
|
|
69
|
+
on: () => () => {},
|
|
70
|
+
resolveEnv: () => null,
|
|
71
|
+
userAgent: () => 'cloud-bongos-tests',
|
|
72
|
+
LIVE_RANK_LADDER: ['xenos', 'thetes', 'metic', 'archon'],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
77
|
+
const buildSsoRoutes = require('../modules/platform-identity/routes/sso.js');
|
|
78
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
79
|
+
|
|
80
|
+
const app = express();
|
|
81
|
+
app.use(express.json());
|
|
82
|
+
app.use(attachFail);
|
|
83
|
+
app.use(buildSsoRoutes());
|
|
84
|
+
const server = app.listen(0, '127.0.0.1');
|
|
85
|
+
await new Promise((r) => server.once('listening', r));
|
|
86
|
+
const port = server.address().port;
|
|
87
|
+
after(() => server.close());
|
|
88
|
+
|
|
89
|
+
function call(method, path, body) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
92
|
+
const req = http.request({
|
|
93
|
+
host: '127.0.0.1', port, method, path,
|
|
94
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {},
|
|
95
|
+
}, (res) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
res.on('data', (c) => chunks.push(c));
|
|
98
|
+
res.on('end', () => {
|
|
99
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
100
|
+
let json = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ }
|
|
101
|
+
resolve({ status: res.statusCode, json, text });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
req.on('error', reject);
|
|
105
|
+
req.end(payload);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- fixtures: a legitimate federated instance, checking in its own OWNER -----
|
|
110
|
+
//
|
|
111
|
+
// The owner is the case that made the window load-bearing: a legacy-only sender
|
|
112
|
+
// said `role: 'owner'`, and a hub that ignored the field would have defaulted the
|
|
113
|
+
// row to 'member'. Now that the hub REFUSES the field instead, nothing is written
|
|
114
|
+
// at all — which is the correct failure, because it is loud on the sender's side.
|
|
115
|
+
const CLIENT_ID = 't1003092-instance';
|
|
116
|
+
const CLIENT_SECRET = 't1003092-secret';
|
|
117
|
+
const OWNER = 7100309200;
|
|
118
|
+
|
|
119
|
+
const clientRow = () => ({
|
|
120
|
+
client_id: CLIENT_ID,
|
|
121
|
+
secret_hash: pi.sha256hex(CLIENT_SECRET),
|
|
122
|
+
name: 'Federated Project',
|
|
123
|
+
redirect_uris: ['https://federated.example/api/gds/auth/web/callback'],
|
|
124
|
+
origin: 'https://federated.example',
|
|
125
|
+
owner_github_id: String(OWNER),
|
|
126
|
+
status: 'active',
|
|
127
|
+
});
|
|
128
|
+
const membershipRow = (over = {}) => ({
|
|
129
|
+
id: '5', client_id: CLIENT_ID, github_id: String(OWNER),
|
|
130
|
+
membership_kind: 'owner', joined_at: 'T0', last_active: 'T0',
|
|
131
|
+
signin_verified_at: '2026-09-01T00:00:00.000Z', ...over,
|
|
132
|
+
});
|
|
133
|
+
const baseBody = () => ({
|
|
134
|
+
client_id: CLIENT_ID,
|
|
135
|
+
client_secret: CLIENT_SECRET,
|
|
136
|
+
github_id: OWNER,
|
|
137
|
+
github_login: 'federated-owner',
|
|
138
|
+
display_name: 'Federated Owner',
|
|
139
|
+
avatar_url: 'https://federated.example/av.png',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const errorCode = (r) => r.json?.error?.code ?? r.json?.error;
|
|
143
|
+
const unknownFields = (r) => (r.json?.error?.details ?? r.json?.details ?? [])
|
|
144
|
+
.filter((d) => d.reason === 'unknown_field').map((d) => d.field);
|
|
145
|
+
|
|
146
|
+
// ── 1. ROUTE: the legacy field is refused, and refused BEFORE any SQL ──────────
|
|
147
|
+
|
|
148
|
+
test('a check-in carrying ONLY the legacy field is refused 400 and sets no membership', async () => {
|
|
149
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow()] });
|
|
150
|
+
const r = await call('POST', '/sso/membership/check-in', { ...baseBody(), role: 'owner' });
|
|
151
|
+
assert.equal(r.status, 400, `legacy-only body must be refused, got ${r.status}: ${r.text}`);
|
|
152
|
+
assert.equal(errorCode(r), 'validation_failed');
|
|
153
|
+
assert.deepEqual(unknownFields(r), ['role'], 'and the refusal names the field, so the sender can see why');
|
|
154
|
+
assert.equal(calls.length, 0,
|
|
155
|
+
'strict validation runs before the client lookup — a legacy body must not even authenticate, let alone write');
|
|
156
|
+
assert.equal(sqlFor(/platform_identity_project_memberships/).length, 0,
|
|
157
|
+
'no membership statement of any kind: this is the "must no longer set the membership" the task asked for');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a check-in carrying BOTH spellings is refused too — the window admitted the pair, the wall admits neither', async () => {
|
|
161
|
+
// A sender inside the window sent both deliberately (task 1003090). No shipped
|
|
162
|
+
// core does any more (tests/membership_kind_lockstep.mjs pins the sender), so a
|
|
163
|
+
// body with both is by definition an un-upgraded sender, and it is refused rather
|
|
164
|
+
// than half-honoured: honouring `membership_kind` while ignoring `role` would make
|
|
165
|
+
// the legacy field look accepted when it is not.
|
|
166
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow()] });
|
|
167
|
+
const r = await call('POST', '/sso/membership/check-in', { ...baseBody(), membership_kind: 'owner', role: 'owner' });
|
|
168
|
+
assert.equal(r.status, 400);
|
|
169
|
+
assert.deepEqual(unknownFields(r), ['role']);
|
|
170
|
+
assert.equal(calls.length, 0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('the legacy VALUE is no longer a recognised kind: "builder" is clamped like any unknown value, not translated', async () => {
|
|
174
|
+
// The translator that turned 'builder' into 'member' is gone with the field. What
|
|
175
|
+
// remains is the domain's GENERIC clamp — touchMembership writes 'member' for any
|
|
176
|
+
// value outside MEMBERSHIP_KINDS, whether that is the old craft word or a rank
|
|
177
|
+
// name. The two must land identically: if 'builder' were still special-cased
|
|
178
|
+
// anywhere on the path, this is where it would show as a difference.
|
|
179
|
+
const landed = async (kind) => {
|
|
180
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow()] }, { rows: [{ id: '9', handle: 'federated-owner' }] });
|
|
181
|
+
await call('POST', '/sso/membership/check-in', { ...baseBody(), membership_kind: kind });
|
|
182
|
+
const membership = sqlFor(/platform_identity_project_memberships/);
|
|
183
|
+
assert.equal(membership.length, 1, `one membership statement for kind=${kind}`);
|
|
184
|
+
return membership[0].params[2];
|
|
185
|
+
};
|
|
186
|
+
assert.equal(await landed('builder'), 'member', 'the craft word is not a kind — clamped');
|
|
187
|
+
assert.equal(await landed('archon'), 'member', 'a rank name is not a kind — clamped the same way');
|
|
188
|
+
assert.equal(await landed('visitor'), 'visitor', 'CONTROL: a real kind passes through untouched');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── 2. CONTROL: the one spelling still lands, and lands the kind it named ──────
|
|
192
|
+
|
|
193
|
+
test('CONTROL: the same check-in with membership_kind lands, and the UPDATE carries the owner kind', async () => {
|
|
194
|
+
expect(
|
|
195
|
+
{ rows: [clientRow()] },
|
|
196
|
+
{ rows: [membershipRow()] },
|
|
197
|
+
{ rows: [{ id: '9', handle: 'federated-owner' }] },
|
|
198
|
+
);
|
|
199
|
+
const r = await call('POST', '/sso/membership/check-in', { ...baseBody(), membership_kind: 'owner' });
|
|
200
|
+
assert.equal(r.status, 200, `control must still pass, got ${r.status}: ${r.text}`);
|
|
201
|
+
const membership = sqlFor(/platform_identity_project_memberships/);
|
|
202
|
+
assert.equal(membership.length, 1, 'exactly one membership statement');
|
|
203
|
+
assert.match(membership[0].text, /^UPDATE platform_identity_project_memberships/);
|
|
204
|
+
assert.ok(membership[0].params.includes('owner'),
|
|
205
|
+
'the kind the sender named is the kind written — the owner flag survives the window closing');
|
|
206
|
+
assert.equal(membership[0].params[0], CLIENT_ID, 'keyed to the AUTHENTICATED client');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('CONTROL: a check-in naming no kind at all still defaults to member (the sender-side default is unchanged)', async () => {
|
|
210
|
+
expect({ rows: [clientRow()] }, { rows: [membershipRow({ membership_kind: 'member' })] }, { rows: [{ id: '9', handle: 'x' }] });
|
|
211
|
+
const r = await call('POST', '/sso/membership/check-in', baseBody());
|
|
212
|
+
assert.equal(r.status, 200);
|
|
213
|
+
const membership = sqlFor(/platform_identity_project_memberships/);
|
|
214
|
+
assert.equal(membership.length, 1);
|
|
215
|
+
assert.ok(membership[0].params.includes('member'), 'no kind → member, exactly as inside the window');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── 3. CONTRACT: the generated API surface no longer offers the field ──────────
|
|
219
|
+
|
|
220
|
+
test('the generated OpenAPI request schema for the check-in has no `role` property', () => {
|
|
221
|
+
const spec = JSON.parse(readFileSync(join(ROOT, 'docs', 'api', 'openapi.json'), 'utf8'));
|
|
222
|
+
const key = Object.keys(spec.paths).find((p) => p.endsWith('/sso/membership/check-in'));
|
|
223
|
+
assert.ok(key, 'the check-in route is in the spec');
|
|
224
|
+
const ref = spec.paths[key].post.requestBody.content['application/json'].schema.$ref;
|
|
225
|
+
const schema = spec.components.schemas[ref.split('/').pop()];
|
|
226
|
+
const props = Object.keys(schema.properties || {});
|
|
227
|
+
assert.ok(props.includes('membership_kind'), `membership_kind is the field (got ${props.join(', ')})`);
|
|
228
|
+
assert.ok(!props.includes('role'),
|
|
229
|
+
'the legacy field is gone from the contract — a client regenerated from this spec cannot offer it');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('the generated API reference row for the check-in lists one spelling', () => {
|
|
233
|
+
const md = readFileSync(join(ROOT, 'docs', 'api-reference.md'), 'utf8');
|
|
234
|
+
const row = md.split('\n').find((l) => l.includes('`/api/bongos/sso/membership/check-in`'));
|
|
235
|
+
assert.ok(row, 'the check-in row is in the reference');
|
|
236
|
+
const fields = row.split('|')[4];
|
|
237
|
+
assert.match(fields, /`membership_kind`/);
|
|
238
|
+
assert.ok(!/`role`/.test(fields), 'the reference is generated from the route schema, so it drops the field with it');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('the route source itself declares no `role` rule (the schema is the wall, so the wall must be in the schema)', () => {
|
|
242
|
+
const src = readFileSync(join(ROOT, 'modules', 'platform-identity', 'routes', 'sso.js'), 'utf8');
|
|
243
|
+
const route = src.slice(src.indexOf("router.post('/sso/membership/check-in'"), src.indexOf("router.post('/sso/activity/rollup'"));
|
|
244
|
+
assert.ok(!/^\s*role:\s*\{/m.test(route), 'no `role:` rule in the check-in schema');
|
|
245
|
+
assert.ok(!/membershipKindFromLegacy/.test(route), 'and no translator call — the mapping left with the field');
|
|
246
|
+
});
|