@bongos/core 1.19.1035 → 1.19.1037

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 (81) hide show
  1. package/.bongos-core.json +102 -92
  2. package/.claude/skills/grade-recover/SKILL.md +1 -1
  3. package/clients/bongos-client/index.d.ts +1 -1
  4. package/docs/adr/0302-the-third-identical-failure-stops-the-session-and-asks.md +3 -2
  5. package/docs/adr/0329-the-rendered-fit-check-is-a-kit-floor-and-an-advisory-ship-check-row.md +2 -2
  6. package/docs/adr/0339-a-projects-owner-upgrades-it-and-a-hosted-project-hands-off-to-the-hub-door.md +84 -0
  7. package/docs/adr/README.md +2 -1
  8. package/docs/api/openapi.json +7 -5
  9. package/docs/copy-inventory.md +10 -10
  10. package/docs/copy-registry.json +13 -13
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/economy/credits.js +1 -1
  13. package/modules/grading/grader-config.js +3 -12
  14. package/modules/grading/grader-no-artifact.js +227 -2
  15. package/modules/grading/grader-prompt.js +1 -1
  16. package/modules/grading/grader-rubric.json +1 -86
  17. package/modules/grading/grader-score.js +1 -169
  18. package/modules/grading/grader-subagent.js +1 -51
  19. package/modules/grading/grader.js +26 -211
  20. package/modules/hall-ui/public/atlas.html +1 -1
  21. package/modules/hall-ui/public/blockers.html +1 -1
  22. package/modules/hall-ui/public/board-room.html +1 -1
  23. package/modules/hall-ui/public/collab.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/deploy.html +1 -1
  26. package/modules/hall-ui/public/diagrams.html +1 -1
  27. package/modules/hall-ui/public/drachmae.html +1 -1
  28. package/modules/hall-ui/public/fleet.html +1 -1
  29. package/modules/hall-ui/public/gate.html +1 -1
  30. package/modules/hall-ui/public/goals.html +1 -1
  31. package/modules/hall-ui/public/government.html +1 -1
  32. package/modules/hall-ui/public/idea.html +1 -1
  33. package/modules/hall-ui/public/ideas.html +1 -1
  34. package/modules/hall-ui/public/index.html +1 -1
  35. package/modules/hall-ui/public/modules.html +1 -1
  36. package/modules/hall-ui/public/people.html +1 -1
  37. package/modules/hall-ui/public/primer.html +1 -1
  38. package/modules/hall-ui/public/profile.html +1 -1
  39. package/modules/hall-ui/public/project-settings.html +1 -1
  40. package/modules/hall-ui/public/ranks.html +1 -1
  41. package/modules/hall-ui/public/roadmap.html +1 -1
  42. package/modules/hall-ui/public/roster.html +1 -1
  43. package/modules/hall-ui/public/sessions.html +1 -1
  44. package/modules/hall-ui/public/settings.html +1 -1
  45. package/modules/hall-ui/public/shell.js +36 -5
  46. package/modules/hall-ui/public/studio.html +1 -1
  47. package/modules/hall-ui/public/task.html +1 -1
  48. package/modules/hall-ui/public/watch.html +1 -1
  49. package/modules/hall-ui/public/work.html +1 -1
  50. package/modules/lifecycle/db-ship.js +3 -3
  51. package/modules/provisioning/core-upgrade-authz.js +85 -0
  52. package/modules/provisioning/core-upgrade.js +2 -1
  53. package/modules/provisioning/provisioning.js +4 -4
  54. package/modules/provisioning/routes/core-upgrade.js +12 -6
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/release-notes.json +20 -0
  58. package/scripts/gds/claim.js +2 -1
  59. package/scripts/gds/grade-smoke.js +3 -3
  60. package/scripts/gds/provision-core-upgrade.js +12 -4
  61. package/scripts/gds/render-check.js +1 -1
  62. package/scripts/gds/ship-check.js +1 -1
  63. package/scripts/gds/ship-flow.js +1 -1
  64. package/scripts/gds/ship-grade-steps.js +2 -2
  65. package/scripts/gds/visual-review-nudge.js +2 -1
  66. package/src/bongos/auth.js +8 -2
  67. package/src/bongos/routes/core-update.js +25 -3
  68. package/src/module-api.js +1 -1
  69. package/tests/adr_0162_gate_citation.mjs +167 -0
  70. package/tests/core_upgrade_owner_door.mjs +444 -0
  71. package/tests/grader.mjs +48 -126
  72. package/tests/grader_unbacked_score.mjs +1 -1
  73. package/tests/nav_permission_atoms.mjs +1 -1
  74. package/tests/provisioning_teardown_intent.mjs +28 -2
  75. package/tests/regrade_actual_minutes.mjs +18 -17
  76. package/tests/render_check.mjs +1 -1
  77. package/tests/scouting_page_gate.mjs +11 -3
  78. package/tests/skill_grade_audit.mjs +3 -3
  79. package/tests/skill_grader_health.mjs +2 -2
  80. package/scripts/gds/audit-ships.js +0 -319
  81. package/tests/audit_ships_git_argv.mjs +0 -173
@@ -0,0 +1,167 @@
1
+ // tests/adr_0162_gate_citation.mjs — "no new gates" is not what ADR 0162 decided; no surface
2
+ // may cite 0162 as the reason something is a signal and not a gate (task 1004280).
3
+ //
4
+ // WHAT WENT WRONG. Task 1003573 corrected one slogan cited to ADR 0162 ("verdicts are advisory,
5
+ // never self-executing" — ADR 0158 §2's rule; tests/adr_0162_citation.mjs pins that). A sibling
6
+ // slogan rode along: "no new gates (ADR 0162)", "a signal, never a gate (ADR 0162)", "NOT A
7
+ // PIPELINE GATE (ADR 0162)". It is broader than the ADR. 0162 retired REVIEW gates — a gate that
8
+ // needs a particular person's approval before work can land — and its own table keeps four
9
+ // automatic controls, three of which fail CLOSED (the grader among them). ADR 0241 ("What ADR 0162
10
+ // actually forbids") states the test 0162 leaves behind: an automatic, state-read, rank-consistent
11
+ // gate with a named escape hatch is admissible. So "this is not a gate because 0162" cites a rule
12
+ // 0162 does not make, and it hides the surface's real reason for staying advisory.
13
+ //
14
+ // The surfaces keep their choice (most were right to stay advisory); only the reason changed:
15
+ // 0162 for "no human approval in the land path", and the surface's own reason otherwise.
16
+ //
17
+ // WHAT THIS PINS. (1) The premise, read from the ADRs: 0162 keeps fail-closed automatic controls
18
+ // and never says "no new gates"; 0241 says what 0162 forbids. (2) No tracked surface cites 0162
19
+ // with the gate shorthand, in any of the shapes the 2026-09-25 sweep found.
20
+ //
21
+ // Run: node --test tests/adr_0162_gate_citation.mjs
22
+
23
+ import { test } from 'node:test';
24
+ import assert from 'node:assert/strict';
25
+ import { readFileSync } from 'node:fs';
26
+ import { spawnSync } from 'node:child_process';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
32
+
33
+ const ADR_0162 = 'docs/adr/0162-review-gates-retired-rank-consistent-ci-only.md';
34
+ const ADR_0241 = 'docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md';
35
+
36
+ // A match is a line that gives 0162 as the reason something is not (or must not be) a gate.
37
+ const GATE_SHORTHANDS = [
38
+ // The advisory claim, then 0162 as its source: "no new gates (ADR 0162)", "A SIGNAL, never a
39
+ // gate (ADR 0162)", "NOT A PIPELINE GATE (ADR 0162)", "no ship is newly gated (ADR 0162)",
40
+ // "WITHOUT adding a gate (ADR 0162)", "It gates NOTHING (ADR 0162 / ADR 0241)", "a new gate on
41
+ // the ship path (ADR 0162)", "It had to stay a signal (ADR 0162)", "never flips its exit (ADR 0162)".
42
+ /\b(?:no new gates?|never a gate|not a (?:pipeline )?gate|newly gated|adding a gate|gates nothing|new gate on the ship path|stay a signal|never flips its exit)\b[^\n()]{0,40}\(ADR 0162\b/i,
43
+ // 0162, then the shorthand as its gloss: "ADR 0162 — no new gates", "(ADR 0162: no new gates)",
44
+ // "[ADR 0162](…) (no new gates)".
45
+ /\b0162\b[\])]?\s*(?:\(|—|–|:)\s*no new gates?\b/i,
46
+ // A describe-only posture cited to 0162: "(describe, never confirm/flip — ADR 0162)". That is
47
+ // 0158 §2's rule — the sibling test's subject, in a shape it does not match.
48
+ /\bnever confirm\/flip\s*[—–-]\s*ADR 0162\b/i,
49
+ ];
50
+
51
+ // Deliberately NOT corrected, each with its reason. Shrink-only: when one is fixed, delete its
52
+ // row — the last test fails until you do, so a stale exemption cannot outlive what it excused.
53
+ const KNOWN_DEFERRED = new Map([
54
+ // HARD-FLOOR surfaces (scripts/gds/gate-review.js HARD_FLOOR_GLOBS lists `.claude/hooks/`):
55
+ // even a comment-only edit escalates to the owner, which a citation fix does not justify on
56
+ // its own. Correct them with the next real edit (the redteam-patrol.yml precedent, task 1003573).
57
+ ['.claude/hooks/allow-rule-guard.js', 'hard-floor gate surface'],
58
+ ['.claude/hooks/git-guard.js', 'hard-floor gate surface'],
59
+ // A hall PAGE script: any edit under modules/*/public/ is UI work, so its ship files an artist
60
+ // review that holds the deploy (ADR 0241). A comment fix does not justify that; correct it
61
+ // with the next real edit to the page.
62
+ ['modules/hall-ui/public/studio.js', 'page script — an edit files an artist review'],
63
+ ]);
64
+
65
+ const HISTORICAL = /^docs\/session-logs\//;
66
+ // This file and its sibling quote the shapes they hunt, as examples.
67
+ const SELF = new Set(['tests/adr_0162_gate_citation.mjs', 'tests/adr_0162_citation.mjs']);
68
+ const TEXT = /\.(?:md|js|mjs|cjs|json|ya?ml|sql|sh|html|txt)$/;
69
+ // A line that NAMES the slogan as its subject is discussing it, not citing by it.
70
+ const DISCUSSES_SLOGAN = /paraphrase|shorthand/i;
71
+ // Markdown link targets carry the ADR's filename, which would sit between a citation and its gloss.
72
+ const stripLinkTargets = (s) => s.replace(/\]\([^)\s]*\)/g, ']');
73
+ const continuation = (s) => (s || '').replace(/^\s*(?:\/\/+|#+|--|\*|>|-)?\s*/, '');
74
+
75
+ function offendersIn(rel, text) {
76
+ const lines = stripLinkTargets(text).split('\n');
77
+ const hits = [];
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const here = lines[i];
80
+ if (DISCUSSES_SLOGAN.test(here)) continue;
81
+ // Join the next line so a citation wrapped mid-phrase still reads as one; only a match
82
+ // that STARTS on this line is this line's.
83
+ const window = `${here} ${continuation(lines[i + 1])}`;
84
+ if (!/0162/.test(window)) continue;
85
+ for (const re of GATE_SHORTHANDS) {
86
+ const m = re.exec(window);
87
+ if (m && m.index < here.length) { hits.push(`${rel}:${i + 1}: ${here.trim().slice(0, 140)}`); break; }
88
+ }
89
+ }
90
+ return hits;
91
+ }
92
+
93
+ function trackedTextFiles() {
94
+ const r = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
95
+ if (r.status !== 0) return null; // a packaged core is not a git checkout
96
+ return r.stdout.split('\0').filter((f) => f && TEXT.test(f));
97
+ }
98
+
99
+ test('the premise: ADR 0162 keeps automatic gates that fail closed, and never says "no new gates"', () => {
100
+ const adr0162 = read(ADR_0162);
101
+ assert.match(adr0162, /Review requirements on `main` are \*\*retired permanently\*\*/, 'what 0162 retired');
102
+ const failsClosed = adr0162.split('\n').filter((l) => /^\|.*\|\s*closed\s*\|\s*$/.test(l));
103
+ assert.ok(failsClosed.length >= 3,
104
+ `0162's compensating-controls table should keep >=3 controls that fail CLOSED (found ${failsClosed.length}) — `
105
+ + 'if the ADR changed, re-ask whether "no new gates" became its rule before re-pointing these citations');
106
+ assert.doesNotMatch(adr0162, /no new gates?/i, 'ADR 0162 never says "no new gates"');
107
+ assert.match(read(ADR_0241), /^## What ADR 0162 actually forbids$/m, 'ADR 0241 is where the narrower rule is written');
108
+ });
109
+
110
+ test('the detector recognises every shape the sweep corrected, and passes the true citations', () => {
111
+ const flag = (line) => offendersIn('probe.md', line).length > 0;
112
+ for (const bad of [
113
+ '- **ADR 0162 — no new gates.** This skill describes and recovers; it never inserts a human-approval step',
114
+ '// A SIGNAL, never a gate (ADR 0162): /ship-check shows it as an advisory row; ship.js and the grader never',
115
+ '// step is a signal, never a gate (ADR 0162): a non-zero exit reads `warn` (3 = no browser reads `skipped`).',
116
+ '// A NUDGE, never a gate (ADR 0162). "Is this UI work" is the ONE predicate the',
117
+ ' // human\'s OK on the before/after. A nudge, never a gate (ADR 0162).',
118
+ '// guess. Report-only — it never changes the outcome (ADR 0162: no new gates).',
119
+ ' // happy path pays nothing and no ship is newly gated (ADR 0162).',
120
+ '- **Relates to:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) (no new gates), [ADR 0197](0197-the-ui-design-module.md) (the kit)',
121
+ 'or both. It had to stay a signal (ADR 0162).',
122
+ '- **Block the fourth attempt.** That is a new gate on the ship path (ADR 0162), and a wrong',
123
+ 'shows them as an ADVISORY row that never flips its exit (ADR 0162). Not a grader worker',
124
+ '// NOT A PIPELINE GATE (ADR 0162): it gates no merge, no CI check, no ship.',
125
+ '// It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.',
126
+ '// pass and WITHOUT adding a gate (ADR 0162). Every case below that has a low',
127
+ '// read-only (describe, never confirm/flip — ADR 0162).',
128
+ ]) assert.ok(flag(bad), `should flag: ${bad}`);
129
+
130
+ for (const good of [
131
+ '- **Related:** ADR 0162 (no new human gates on the ship path)',
132
+ 'No review requirement — gates are CI checks, not human approvals (ADR 0162).',
133
+ '- **Relates to:** [ADR 0162](0162-review-gates-retired-rank-consistent-ci-only.md) (review gates retired: no human approval in the land path)',
134
+ 'no confirms, no status flips, and no new approval step on anyone\'s ship (ADR 0162). The remediation lines',
135
+ '// A NUDGE, never a gate: as a gate, the human\'s OK would be a person\'s approval in\n// the land path, which ADR 0162 retired.',
136
+ '// A SIGNAL, never a gate (ADR 0329, decisions 3-4): /ship-check shows it as an advisory row',
137
+ 'It never holds a builder\'s ship, which is what keeps it compatible with ADR 0162.',
138
+ 'This matters, because the shorthand "no new gates (ADR 0162)" is broader than the ADR',
139
+ ]) assert.ok(!flag(good), `should pass: ${good}`);
140
+ });
141
+
142
+ test('no tracked surface cites ADR 0162 as the reason something is not a gate', (t) => {
143
+ const files = trackedTextFiles();
144
+ if (!files) return t.skip('not a git checkout — nothing tracked to scan');
145
+ const offenders = [];
146
+ for (const rel of files) {
147
+ if (SELF.has(rel) || HISTORICAL.test(rel) || KNOWN_DEFERRED.has(rel)) continue;
148
+ let text;
149
+ try { text = readFileSync(path.join(ROOT, rel), 'utf8'); } catch { continue; }
150
+ if (!text.includes('0162')) continue;
151
+ offenders.push(...offendersIn(rel, text));
152
+ }
153
+ assert.deepEqual(offenders, [],
154
+ 'these cite ADR 0162 for "no new gates" / "a signal, never a gate". 0162 retired gates that need a '
155
+ + 'particular person\'s approval in the land path, and keeps automatic gates that fail closed (ADR 0241). '
156
+ + 'Cite 0162 for "no human approval in the land path", and name the surface\'s own reason for staying advisory.');
157
+ });
158
+
159
+ test('every deferred exemption still has something to excuse (shrink-only)', (t) => {
160
+ if (!trackedTextFiles()) return t.skip('not a git checkout');
161
+ for (const [rel, why] of KNOWN_DEFERRED) {
162
+ let text = '';
163
+ try { text = read(rel); } catch { /* deleted: the exemption is stale too */ }
164
+ assert.ok(offendersIn(rel, text).length > 0,
165
+ `${rel} (${why}) no longer cites 0162 for the gate shorthand — delete its KNOWN_DEFERRED row`);
166
+ }
167
+ });
@@ -0,0 +1,444 @@
1
+ // tests/core_upgrade_owner_door.mjs — a project's owner may move ITS core (task 1004174, ADR 0339).
2
+ //
3
+ // The owner's ruling (2026-09-26): only a project's owner upgrades it. The deploy door was
4
+ // `core.pin.move` alone, so the owner of a hosted project below Archon could see they were
5
+ // behind and never act. This file proves the widening is exactly that wide and no wider:
6
+ //
7
+ // 1. the owner of a TENANT row is admitted, to the preview and the move, for that row only;
8
+ // 2. everyone else meets the unmodified atom gate, and a non-owner is refused byte-for-byte
9
+ // as before, whether or not the id exists;
10
+ // 3. the platform's own row stays atom-only, even for the caller who owns it;
11
+ // 4. the intent's actor names which door was used, and the runner's parse accepts both and
12
+ // nothing else (the id is interpolated into a shell command);
13
+ // 5. the /deploy page shell admits a project owner and still refuses a stranger;
14
+ // 6. a hosted project's hall hands its owner to the hub's door and never trusts a bad URL.
15
+ //
16
+ // These EXECUTE the real gate, router, page gate and shell.js; none of them reads source text.
17
+ //
18
+ // Run: node tests/core_upgrade_owner_door.mjs
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+
30
+ // The session double must be installed BEFORE auth.js loads: auth.js destructures
31
+ // lookupSession at require time (the tests/scouting_page_gate.mjs precedent).
32
+ const dbKernel = require('../src/bongos/db-kernel.js');
33
+ let nextSession = null;
34
+ dbKernel.lookupSession = async () => nextSession;
35
+
36
+ const auth = require('../src/bongos/auth.js');
37
+ const seams = require('../src/module-seams.js');
38
+ const catalog = require('../modules/government/catalog.js');
39
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
40
+ const AZ = require('../modules/provisioning/core-upgrade-authz.js');
41
+ const { requestedByOf } = require('../scripts/gds/provision-core-upgrade.js');
42
+ const { hubDeployUrl } = require('../src/bongos/routes/core-update.js');
43
+
44
+ let passed = 0;
45
+ let failed = 0;
46
+ async function t(name, fn) {
47
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
48
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
49
+ }
50
+
51
+ // The cast. Builder 7 owns the hosted project hoodify-finance (a xenos on the hub, as
52
+ // erikwils is); 8 is a stranger; 42 is an Archon. Builder 7 is also recorded as owning the
53
+ // platform row here, which is the case the ruling must NOT open.
54
+ const OWNER = { id: 7, rank: 'xenos' };
55
+ const STRANGER = { id: 8, rank: 'xenos' };
56
+ const ARCHON = { id: 42, rank: 'archon' };
57
+ const TENANT = Object.freeze({ id: 41, slug: 'hoodify-finance', status: 'active', hosting_shape: 'cloud-host', owner_builder_id: 7 });
58
+ const COTENANT = Object.freeze({ id: 43, slug: 'mercury', status: 'active', hosting_shape: 'co-tenant', owner_builder_id: 7 });
59
+ const PLATFORM = Object.freeze({ id: 1, slug: 'cloudbongos', status: 'active', hosting_shape: 'control-plane', owner_builder_id: 7 });
60
+ const ROWS = new Map([[41, TENANT], [43, COTENANT], [1, PLATFORM]]);
61
+
62
+ // ── 1. the predicate ──────────────────────────────────────────────────────────
63
+
64
+ console.log('\nwho owns an upgradable project:');
65
+
66
+ await t('the owner of a cloud-host or co-tenant row owns it', () => {
67
+ assert.equal(AZ.ownsUpgradableProject(TENANT, OWNER), true);
68
+ assert.equal(AZ.ownsUpgradableProject(COTENANT, OWNER), true);
69
+ assert.equal(AZ.ownsUpgradableProject(TENANT, { id: '7' }), true, 'ids compare as ids, not as types');
70
+ });
71
+
72
+ await t('the platform row is never an owner-movable project, even for its recorded owner', () => {
73
+ assert.equal(AZ.ownsUpgradableProject(PLATFORM, OWNER), false);
74
+ assert.equal(AZ.OWNER_SHAPES.has('control-plane'), false);
75
+ });
76
+
77
+ await t('it fails closed on every missing piece', () => {
78
+ assert.equal(AZ.ownsUpgradableProject(TENANT, STRANGER), false, 'someone else');
79
+ assert.equal(AZ.ownsUpgradableProject(null, OWNER), false, 'no row');
80
+ assert.equal(AZ.ownsUpgradableProject(TENANT, null), false, 'no builder');
81
+ assert.equal(AZ.ownsUpgradableProject(TENANT, { id: null }), false, 'a builder with no id');
82
+ assert.equal(AZ.ownsUpgradableProject({ ...TENANT, owner_builder_id: null }, { id: null }), false, 'null never equals null');
83
+ assert.equal(AZ.ownsUpgradableProject({ ...TENANT, hosting_shape: 'something-new' }, OWNER), false, 'a later shape is excluded by omission');
84
+ });
85
+
86
+ // ── 2. the middleware ─────────────────────────────────────────────────────────
87
+
88
+ console.log('\nthe OR gate:');
89
+
90
+ // The atom gate as a spy: admits an Archon, refuses everyone else with the ONE body the
91
+ // door has always sent. Nothing about the refusal depends on the id.
92
+ function pinGateSpy() {
93
+ const spy = (req, res, next) => {
94
+ spy.calls += 1;
95
+ if (req.builder && req.builder.rank === 'archon') return next();
96
+ res.statusCode = 403;
97
+ res.body = { ok: false, error: 'forbidden', permission: 'core.pin.move' };
98
+ return undefined;
99
+ };
100
+ spy.calls = 0;
101
+ return spy;
102
+ }
103
+
104
+ async function runGate({ id, builder, getInstanceById, log }) {
105
+ const pinGate = pinGateSpy();
106
+ const lookups = [];
107
+ const provisioning = {
108
+ getInstanceById: getInstanceById || (async (_db, n) => { lookups.push(n); return ROWS.get(n) || null; }),
109
+ };
110
+ const gate = AZ.requireOwnProjectOrPin({ pinGate, provisioning, pool: {}, log });
111
+ const req = { params: { id }, builder };
112
+ const res = {};
113
+ let admitted = false;
114
+ await gate(req, res, () => { admitted = true; });
115
+ return { admitted, pinCalls: pinGate.calls, authority: req.coreUpgradeAuthority, res, lookups };
116
+ }
117
+
118
+ await t('the owner is admitted to THEIR project without meeting the atom', async () => {
119
+ const r = await runGate({ id: '41', builder: OWNER });
120
+ assert.equal(r.admitted, true);
121
+ assert.equal(r.pinCalls, 0, 'ownership answered first');
122
+ assert.equal(r.authority, 'owner');
123
+ });
124
+
125
+ await t('a stranger meets the atom and is refused', async () => {
126
+ const r = await runGate({ id: '41', builder: STRANGER });
127
+ assert.equal(r.admitted, false);
128
+ assert.equal(r.pinCalls, 1);
129
+ assert.equal(r.authority, 'pin');
130
+ assert.equal(r.res.statusCode, 403);
131
+ });
132
+
133
+ await t('the refusal is byte-identical whether the id exists or not', async () => {
134
+ const real = await runGate({ id: '41', builder: STRANGER });
135
+ const ghost = await runGate({ id: '999', builder: STRANGER });
136
+ assert.deepEqual(ghost.res, real.res, 'a stranger learns nothing about which ids exist');
137
+ });
138
+
139
+ await t('the owner of the platform row is NOT admitted by ownership', async () => {
140
+ const r = await runGate({ id: '1', builder: OWNER });
141
+ assert.equal(r.admitted, false);
142
+ assert.equal(r.pinCalls, 1, 'the platform row stays on the atom');
143
+ });
144
+
145
+ await t('an Archon is admitted by the atom, on a project they do not own', async () => {
146
+ const r = await runGate({ id: '41', builder: ARCHON });
147
+ assert.equal(r.admitted, true);
148
+ assert.equal(r.pinCalls, 1);
149
+ assert.equal(r.authority, 'pin', 'so the actor records the Archon door');
150
+ });
151
+
152
+ await t('an id that is not a plain positive integer is never looked up', async () => {
153
+ for (const id of ['abc', '0', '-1', '1e3', '41; DROP TABLE x', '', ' 41', '041x']) {
154
+ const r = await runGate({ id, builder: OWNER });
155
+ assert.deepEqual(r.lookups, [], `"${id}" reached the database`);
156
+ assert.equal(r.admitted, false, `"${id}" admitted a xenos`);
157
+ assert.equal(r.pinCalls, 1, `"${id}" skipped the atom`);
158
+ }
159
+ });
160
+
161
+ await t('a failing ownership read falls to the atom: it can deny an owner, never admit anyone', async () => {
162
+ const logged = [];
163
+ const log = { error: (m) => logged.push(m) };
164
+ const boom = async () => { throw new Error('connection reset (row: secret-owner-data)'); };
165
+ const owner = await runGate({ id: '41', builder: OWNER, getInstanceById: boom, log });
166
+ assert.equal(owner.admitted, false, 'an outage denies the owner');
167
+ assert.equal(owner.pinCalls, 1);
168
+ const archon = await runGate({ id: '41', builder: ARCHON, getInstanceById: boom, log });
169
+ assert.equal(archon.admitted, true, 'and leaves the Archon path working');
170
+ assert.ok(logged.length >= 1 && logged.every((m) => /falling back to core\.pin\.move/.test(m)), 'the fallback is logged');
171
+ });
172
+
173
+ // ── 3. the actor and the runner's parse ───────────────────────────────────────
174
+
175
+ console.log('\nthe actor says which door was used:');
176
+
177
+ await t('owner and Archon moves are labelled apart, and both carry the builder id', () => {
178
+ assert.equal(AZ.coreUpgradeActor({ coreUpgradeAuthority: 'owner', builder: { id: 7 } }), 'api:owner:7');
179
+ assert.equal(AZ.coreUpgradeActor({ coreUpgradeAuthority: 'pin', builder: { id: 42 } }), 'api:archon:42');
180
+ assert.equal(AZ.coreUpgradeActor({ builder: { id: 42 } }), 'api:archon:42', 'no recorded door is the Archon door');
181
+ });
182
+
183
+ await t('the runner attributes both, and passes nothing but digits into its command', () => {
184
+ assert.equal(requestedByOf({ requested_by: 'api:archon:42' }), '42');
185
+ assert.equal(requestedByOf({ requested_by: 'api:owner:7' }), '7');
186
+ for (const bad of ['api:owner:7; rm -rf /', 'api:owner:', 'api:self', 'api:owner:7 ', 'owner:7', 'api:root:1']) {
187
+ assert.equal(requestedByOf({ requested_by: bad }), '', `"${bad}" must not pass`);
188
+ }
189
+ assert.equal(requestedByOf({ requested_by: 42 }), '');
190
+ assert.equal(requestedByOf(null), '');
191
+ assert.equal(requestedByOf({}), '');
192
+ });
193
+
194
+ // ── 4. the assembled router, driven by real requests ──────────────────────────
195
+
196
+ console.log('\nthe routes, end to end:');
197
+
198
+ await t('POST: the owner queues a move on their project; a stranger and the platform row are refused', async () => {
199
+ const api = require('../src/module-api.js');
200
+ const prov = require('../modules/provisioning/provisioning.js');
201
+ const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
202
+ const saved = {};
203
+ const stub = (key, value) => {
204
+ saved[key] = Object.getOwnPropertyDescriptor(api, key);
205
+ Object.defineProperty(api, key, { value, configurable: true, writable: true });
206
+ };
207
+ // The caller rides a test header; the atom gate is the spy's rule (Archon only) with
208
+ // the door's own refusal shape. Everything between them is the real code.
209
+ stub('requireBuilder', (req, _res, next) => { req.builder = JSON.parse(req.headers['x-test-builder']); next(); });
210
+ stub('requirePermission', (atom) => (req, res, next) => {
211
+ if (req.builder.rank === 'archon') return next();
212
+ return res.status(403).json({ ok: false, error: 'forbidden', permission: atom });
213
+ });
214
+ stub('pool', {});
215
+ const queued = [];
216
+ const savedProv = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent };
217
+ prov.getInstanceById = async (_db, n) => ROWS.get(Number(n)) || null;
218
+ prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
219
+ queued.push({ id, action, requestedBy, targetVersion });
220
+ return { intent: { id: queued.length, action, state: 'pending', target_version: targetVersion }, created: true };
221
+ };
222
+ prov.recordEvent = async () => {};
223
+ delete require.cache[routePath];
224
+
225
+ const express = require('express');
226
+ const app = express();
227
+ app.use(express.json());
228
+ app.use(require(routePath)());
229
+ const server = app.listen(0);
230
+ await new Promise((r) => server.once('listening', r));
231
+ const base = `http://127.0.0.1:${server.address().port}`;
232
+ const post = (id, who) => fetch(`${base}/provisioning/instances/${id}/core-upgrade`, {
233
+ method: 'POST',
234
+ headers: { 'content-type': 'application/json', 'x-test-builder': JSON.stringify(who) },
235
+ body: JSON.stringify({ to: '1.19.1033', dry_run: true }),
236
+ });
237
+ try {
238
+ const own = await post(41, OWNER);
239
+ assert.notEqual(own.status, 403, 'the owner is not refused on their own project');
240
+ assert.deepEqual(queued.map((q) => [q.id, q.requestedBy]), [[41, 'api:owner:7']], 'queued, and attributed to the owner door');
241
+
242
+ const strangerReal = await post(41, STRANGER);
243
+ const strangerGhost = await post(999, STRANGER);
244
+ assert.equal(strangerReal.status, 403);
245
+ assert.equal(strangerGhost.status, 403);
246
+ assert.deepEqual(await strangerReal.json(), await strangerGhost.json(), 'the same refusal for a real and a missing id');
247
+
248
+ const platform = await post(1, OWNER);
249
+ assert.equal(platform.status, 403, 'owning the platform row does not open the platform to a non-Archon');
250
+
251
+ const archon = await post(41, ARCHON);
252
+ assert.notEqual(archon.status, 403);
253
+ assert.deepEqual(queued.map((q) => q.requestedBy), ['api:owner:7', 'api:archon:42'], 'nothing else was queued');
254
+
255
+ const preview = await fetch(`${base}/provisioning/instances/41/core-upgrade`, { headers: { 'x-test-builder': JSON.stringify(STRANGER) } });
256
+ assert.equal(preview.status, 403, 'the preview is gated like the move');
257
+
258
+ const cp = await fetch(`${base}/provisioning/control-plane`, { headers: { 'x-test-builder': JSON.stringify(OWNER) } });
259
+ assert.equal(cp.status, 403, 'the platform entrance keeps the atom alone');
260
+ } finally {
261
+ server.close();
262
+ for (const [k, d] of Object.entries(saved)) Object.defineProperty(api, k, d);
263
+ prov.getInstanceById = savedProv.get;
264
+ prov.enqueueIntent = savedProv.enq;
265
+ prov.recordEvent = savedProv.rec;
266
+ delete require.cache[routePath];
267
+ }
268
+ });
269
+
270
+ // ── 5. the /deploy page shell ─────────────────────────────────────────────────
271
+
272
+ console.log('\nthe /deploy page shell:');
273
+
274
+ function fakeRes() {
275
+ const res = {
276
+ _redirect: null,
277
+ status() { return this; },
278
+ json() { return this; },
279
+ redirect(a, b) { this._redirect = b === undefined ? { code: 302, loc: a } : { code: a, loc: b }; return this; },
280
+ end() { return this; },
281
+ };
282
+ attachFail({}, res, () => {});
283
+ return res;
284
+ }
285
+
286
+ async function runPage(session, hub) {
287
+ nextSession = session;
288
+ seams._reset();
289
+ seams.registerProvider('government', {
290
+ resolveBuilderPermissions: async () => catalog.seedForRank(session ? session.rank : 'xenos'),
291
+ });
292
+ if (hub) seams.registerProvider('platform-identity.hub', hub);
293
+ const res = fakeRes();
294
+ let nexted = false;
295
+ await auth.requireCorePinPage({ headers: { cookie: 'gds_session=t' } }, res, () => { nexted = true; });
296
+ return { res, nexted };
297
+ }
298
+
299
+ const OWNER_SESSION = { builder_id: 7, github_id: '9001', rank: 'xenos' };
300
+ const STRANGER_SESSION = { builder_id: 8, github_id: '9002', rank: 'xenos' };
301
+ const ARCHON_SESSION = { builder_id: 42, github_id: '9042', rank: 'archon' };
302
+ const owns = { isProjectOwner: async (githubId) => String(githubId) === '9001' };
303
+
304
+ await t('a project owner holding no atom reaches the shell', async () => {
305
+ const { nexted } = await runPage(OWNER_SESSION, owns);
306
+ assert.ok(nexted, 'without this the widening is real in the API and unreachable in the product');
307
+ });
308
+
309
+ await t('a stranger gets exactly the redirect the page sent before the second door existed', async () => {
310
+ const withDoor = await runPage(STRANGER_SESSION, owns);
311
+ const noDoor = await runPage(STRANGER_SESSION, null);
312
+ assert.equal(withDoor.nexted, false);
313
+ assert.deepEqual(withDoor.res._redirect, noDoor.res._redirect);
314
+ assert.match(withDoor.res._redirect.loc, /denied=core\.pin\.move/, 'and it still names the atom');
315
+ });
316
+
317
+ await t('an Archon is admitted by the atom without the second door being asked', async () => {
318
+ const spy = { isProjectOwner: () => { throw new Error('must not be consulted for an atom holder'); } };
319
+ const { nexted } = await runPage(ARCHON_SESSION, spy);
320
+ assert.ok(nexted);
321
+ });
322
+
323
+ seams._reset();
324
+ nextSession = null;
325
+
326
+ // ── 6. the hand-off address, and the hall that uses it ────────────────────────
327
+
328
+ console.log('\nthe hand-off to the hub:');
329
+
330
+ await t('deploy_url is the hub origin plus this project\'s slug, and null without a hub', () => {
331
+ assert.equal(hubDeployUrl({ origin: 'https://cloudbongos.com', clientId: 'hoodify-finance' }),
332
+ 'https://cloudbongos.com/deploy?project=hoodify-finance');
333
+ assert.equal(hubDeployUrl({ origin: 'https://cloudbongos.com/', clientId: 'mercury' }),
334
+ 'https://cloudbongos.com/deploy?project=mercury', 'a trailing slash is not doubled');
335
+ assert.equal(hubDeployUrl({ origin: 'https://h.test', clientId: 'a b&c' }), 'https://h.test/deploy?project=a%20b%26c');
336
+ assert.equal(hubDeployUrl({ origin: 'https://cloudbongos.com' }), null, 'no client id: not federated');
337
+ assert.equal(hubDeployUrl({ origin: 'javascript:alert(1)', clientId: 'x' }), null);
338
+ assert.equal(hubDeployUrl({ origin: 'https://h.test/"><script>', clientId: 'x' }), null);
339
+ assert.equal(hubDeployUrl(null), null);
340
+ });
341
+
342
+ const SHELL = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/shell.js'), 'utf8');
343
+
344
+ // The real shell.js in a DOM stub (the tests/core_update_banner.mjs harness), with the
345
+ // /core-update answer carrying a deploy_url and elements that remember their attributes.
346
+ function boot({ provisioning, deployUrl }) {
347
+ const els = new Map();
348
+ const inserted = [];
349
+ const makeEl = () => ({
350
+ dataset: {}, style: {}, textContent: '', hidden: false, _html: '', _attrs: {},
351
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
352
+ setAttribute(k, v) { this._attrs[k] = String(v); }, getAttribute(k) { return k in this._attrs ? this._attrs[k] : null; }, removeAttribute(k) { delete this._attrs[k]; },
353
+ addEventListener() {},
354
+ querySelector() { return { addEventListener() {} }; },
355
+ querySelectorAll() { return []; },
356
+ appendChild() {}, focus() {}, click() {}, scrollIntoView() {}, remove() {},
357
+ insertAdjacentElement(_pos, el) { inserted.push(el); },
358
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
359
+ });
360
+ // The rail's Deploy item as the rendered markup makes it: data-gate="archon"
361
+ // data-module="provisioning" (shell.js renders both attributes onto the <a>).
362
+ const deploy = makeEl();
363
+ deploy.dataset.gate = 'archon';
364
+ deploy.dataset.module = 'provisioning';
365
+ els.set('nav-deploy', deploy);
366
+ const documentObj = {
367
+ body: { dataset: { page: 'settings' } },
368
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
369
+ getElementById(id) { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); },
370
+ querySelectorAll() { return []; },
371
+ createElement() { return makeEl(); },
372
+ addEventListener() {}, dispatchEvent() {},
373
+ get cookie() { return ''; }, set cookie(_v) {},
374
+ };
375
+ const location = { hostname: 'h', origin: 'https://h', pathname: '/settings', hash: '', assign() {}, replace() {} };
376
+ const update = { running: '1.19.871', latest: '1.19.1033', behind: true, reading: 'ok' };
377
+ const windowObj = {
378
+ __BRANDING__: {}, __MODULES__: { enabled: { provisioning } },
379
+ BongosClient: {
380
+ createClient: () => ({
381
+ request: (_method, p) => (p === '/api/bongos/core-update'
382
+ ? Promise.resolve({ status: 200, data: { ok: true, update, deploy_url: deployUrl } })
383
+ : new Promise(() => {})),
384
+ }),
385
+ },
386
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
387
+ localStorage: { getItem: () => null, setItem() {} },
388
+ requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
389
+ };
390
+ const sandbox = { window: windowObj, document: documentObj, location, console, setTimeout, clearTimeout, queueMicrotask,
391
+ CustomEvent: class { constructor(ty, i) { this.type = ty; Object.assign(this, i); } } };
392
+ sandbox.globalThis = sandbox;
393
+ vm.createContext(sandbox);
394
+ vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
395
+ windowObj.OTBShell.applyAccess({ authed: true, rank: 'archon', permissions: { 'core.pin.move': true } });
396
+ return {
397
+ banner: () => inserted.find((e) => String(e._html).includes('is available')),
398
+ deployItem: () => els.get('nav-deploy'),
399
+ };
400
+ }
401
+ const settle = () => new Promise((r) => setTimeout(r, 0));
402
+ const HUB = 'https://cloudbongos.com/deploy?project=hoodify-finance';
403
+
404
+ await t('on a hosted project, the banner\'s Upgrade goes to the hub door for THIS project', async () => {
405
+ const b = boot({ provisioning: false, deployUrl: HUB });
406
+ await settle();
407
+ assert.ok(b.banner(), 'the banner renders');
408
+ assert.ok(b.banner().innerHTML.includes(`href="${HUB}"`), 'Upgrade links to the hub');
409
+ assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/, 'never the dead local page');
410
+ });
411
+
412
+ await t('on a hosted project, the rail\'s Deploy item is re-pointed at the hub', async () => {
413
+ const b = boot({ provisioning: false, deployUrl: HUB });
414
+ await settle();
415
+ const item = b.deployItem();
416
+ assert.equal(item.getAttribute('href'), HUB);
417
+ assert.equal('module' in item.dataset, false, 'its module gate is lifted so the access loop stops hiding it');
418
+ assert.equal(item.hidden, false, 'an Archon on their own project sees it');
419
+ });
420
+
421
+ await t('a hosted project with no hub, or a hub address that is not plain http(s), offers no link', async () => {
422
+ for (const url of [null, 'javascript:alert(1)', 'https://x.test/"><img src=x onerror=alert(1)>', 42]) {
423
+ const b = boot({ provisioning: false, deployUrl: url });
424
+ await settle();
425
+ assert.ok(b.banner(), 'the news is still told');
426
+ assert.doesNotMatch(b.banner().innerHTML, /core-update-notice__action/, `no action for ${JSON.stringify(url)}`);
427
+ const item = b.deployItem();
428
+ assert.equal(item.getAttribute('href'), null, `the rail is not re-pointed for ${JSON.stringify(url)}`);
429
+ assert.equal(item.dataset.module, 'provisioning', 'and it keeps its module gate, so a hosted project still hides it');
430
+ }
431
+ });
432
+
433
+ await t('on the platform, the local door wins: the hub address is ignored', async () => {
434
+ const b = boot({ provisioning: true, deployUrl: HUB });
435
+ await settle();
436
+ assert.match(b.banner().innerHTML, /href="\/deploy"/);
437
+ assert.ok(!b.banner().innerHTML.includes(HUB));
438
+ const item = b.deployItem();
439
+ assert.notEqual(item.getAttribute('href'), HUB, 'the rail keeps the local /deploy');
440
+ assert.equal(item.dataset.module, 'provisioning', 'and its module gate, which the platform satisfies');
441
+ });
442
+
443
+ console.log(`\n${passed} passed, ${failed} failed`);
444
+ process.exit(failed === 0 ? 0 : 1);