@bongos/core 1.19.1073 → 1.19.1074

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 (297) hide show
  1. package/.bongos-core.json +320 -465
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -60
  5. package/clients/bongos-client/index.cjs +4 -60
  6. package/clients/bongos-client/index.d.ts +5 -90
  7. package/clients/bongos-client/index.mjs +4 -60
  8. package/config/branding.neutral.json +1 -5
  9. package/config/modules.neutral.json +2 -3
  10. package/config/scheduled-routines.json +0 -2
  11. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  12. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  13. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  14. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  15. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
  16. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  17. package/docs/adr/README.md +1 -0
  18. package/docs/api/openapi.json +171 -1661
  19. package/docs/api-reference.md +9 -41
  20. package/docs/architecture.md +16 -1
  21. package/docs/branding-contract.md +0 -3
  22. package/docs/copy-inventory.md +454 -479
  23. package/docs/copy-registry.json +746 -978
  24. package/docs/file-map.md +5 -34
  25. package/docs/module-api-changelog.md +2 -0
  26. package/docs/modules-contract.md +13 -1
  27. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  28. package/docs/onboarding/diagrams/README.md +3 -3
  29. package/docs/onboarding/diagrams/assertions.json +2 -22
  30. package/docs/onboarding/primer.md +9 -28
  31. package/docs/page-inventory.json +4 -1
  32. package/docs/page-readings.json +1032 -1077
  33. package/docs/recipes/render-pilot.md +29 -10
  34. package/migrations/core_257_module_store_registry.sql +87 -0
  35. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  36. package/modules/agents/module.json +1 -0
  37. package/modules/autonomy/module.json +1 -0
  38. package/modules/builder-settings/module.json +1 -0
  39. package/modules/builder-settings/render-prefs.js +4 -5
  40. package/modules/copy-desk/docx.js +78 -5
  41. package/modules/copy-desk/module.json +1 -0
  42. package/modules/copy-desk/routes/copy-desk.js +9 -9
  43. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  44. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  45. package/modules/discord/module.json +1 -0
  46. package/modules/economy/module.json +1 -0
  47. package/modules/economy/routes/credits.js +1 -2
  48. package/modules/government/catalog.js +13 -17
  49. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  50. package/modules/government/module.json +1 -0
  51. package/modules/government/protected-surfaces.json +3 -11
  52. package/modules/government/resolver.js +3 -46
  53. package/modules/government/routes/government.js +0 -2
  54. package/modules/government/session-scopes.js +43 -83
  55. package/modules/government/session-scopes.json +8 -18
  56. package/modules/grading/module.json +1 -0
  57. package/modules/hall-ui/module.json +1 -0
  58. package/modules/hall-ui/public/board-lib.js +2 -2
  59. package/modules/hall-ui/public/builders.js +29 -40
  60. package/modules/hall-ui/public/collab.html +1 -1
  61. package/modules/hall-ui/public/collab.js +1 -1
  62. package/modules/hall-ui/public/diagrams.html +3 -3
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/gate.js +2 -2
  65. package/modules/hall-ui/public/gate.states.json +1 -1
  66. package/modules/hall-ui/public/hall-render.js +43 -79
  67. package/modules/hall-ui/public/index.html +5 -1
  68. package/modules/hall-ui/public/modules.js +1 -0
  69. package/modules/hall-ui/public/oversight.css +5 -5
  70. package/modules/hall-ui/public/palette.js +0 -11
  71. package/modules/hall-ui/public/primer.js +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings-sessions.js +1 -1
  74. package/modules/hall-ui/public/settings.css +0 -1
  75. package/modules/hall-ui/public/settings.html +4 -33
  76. package/modules/hall-ui/public/settings.js +3 -33
  77. package/modules/hall-ui/public/settings.states.json +2 -2
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +10 -13
  80. package/modules/hall-ui/public/task.html +4 -1
  81. package/modules/hall-ui/public/task.js +36 -1
  82. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  83. package/modules/hall-ui/public/tweak-editor.css +16 -0
  84. package/modules/hall-ui/public/tweak-editor.html +26 -3
  85. package/modules/hall-ui/public/tweak-editor.js +32 -1
  86. package/modules/hall-ui/public/work.js +1 -1
  87. package/modules/hall-ui/records/tweak-editor.md +1 -0
  88. package/modules/ideas/module.json +1 -0
  89. package/modules/lifecycle/db-claim-reads.js +1 -58
  90. package/modules/lifecycle/db-ship.js +0 -5
  91. package/modules/lifecycle/db.js +0 -4
  92. package/modules/lifecycle/kickoff-checklist.js +0 -4
  93. package/modules/lifecycle/lifecycle.js +0 -8
  94. package/modules/lifecycle/module.json +1 -0
  95. package/modules/lifecycle/routes/tasks.js +5 -7
  96. package/modules/memory/module.json +1 -0
  97. package/modules/npm-release/module.json +1 -0
  98. package/modules/onboarding/module.json +1 -0
  99. package/modules/onboarding/onboarding-state.js +17 -36
  100. package/modules/onboarding/routes/access-requests.js +3 -3
  101. package/modules/platform-identity/module.json +1 -0
  102. package/modules/platform-identity/platform-identity.js +13 -5
  103. package/modules/platform-identity/routes/sso.js +2 -2
  104. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  105. package/modules/provisioning/module.json +1 -0
  106. package/modules/provisioning/provisioning.js +2 -2
  107. package/modules/provisioning/starter-bundles.js +5 -25
  108. package/modules/public-landing/module.json +1 -0
  109. package/modules/public-landing/public/assets/cosmos.css +1 -1
  110. package/modules/public-landing/public/contact.html +1 -1
  111. package/modules/public-landing/public/privacy.html +1 -1
  112. package/modules/public-landing/public/projects.html +101 -32
  113. package/modules/public-landing/public/projects.probes.json +2 -2
  114. package/modules/public-landing/public/projects.states.json +4 -3
  115. package/modules/public-landing/public/terms.html +1 -1
  116. package/modules/security/module.json +1 -0
  117. package/modules/sessions/module.json +1 -0
  118. package/modules/specialities/module.json +1 -0
  119. package/modules/status-ui/module.json +1 -0
  120. package/modules/ui-design/module.json +1 -0
  121. package/package-lock.json +2 -2
  122. package/package.json +1 -1
  123. package/release-notes.json +78 -0
  124. package/scripts/gds/artifact-format.js +203 -0
  125. package/scripts/gds/audit-authorship.sh +1 -13
  126. package/scripts/gds/claim.js +0 -15
  127. package/scripts/gds/cli-lib.js +8 -8
  128. package/scripts/gds/diagram-facts.js +12 -23
  129. package/scripts/gds/do-api.js +13 -80
  130. package/scripts/gds/doc-cli-guard.js +2 -0
  131. package/scripts/gds/doctor.js +1 -1
  132. package/scripts/gds/feedback-latest.js +139 -9
  133. package/scripts/gds/fitness-checks-identity.js +2 -7
  134. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  135. package/scripts/gds/fitness-ratchets.js +4 -4
  136. package/scripts/gds/fitness.js +10 -10
  137. package/scripts/gds/gen-diagrams.js +49 -71
  138. package/scripts/gds/http-api-client.js +12 -19
  139. package/scripts/gds/init.js +1 -1
  140. package/scripts/gds/lint-infra-exec.js +1 -1
  141. package/scripts/gds/local-preview-lib.js +8 -39
  142. package/scripts/gds/local-preview.js +2 -15
  143. package/scripts/gds/migration-namespace.js +3 -3
  144. package/scripts/gds/module-artifact.js +212 -0
  145. package/scripts/gds/module.js +95 -6
  146. package/scripts/gds/package-core.js +5 -116
  147. package/scripts/gds/provision-config.js +2 -2
  148. package/scripts/gds/provision-net.js +2 -3
  149. package/scripts/gds/provision.js +9 -9
  150. package/scripts/gds/publish-manifest.js +1 -2
  151. package/scripts/gds/regen-instance-docs.js +1 -1
  152. package/scripts/gds/run-unit-tests.js +2 -16
  153. package/scripts/gds/sandbox-stage.js +39 -341
  154. package/scripts/gds/ship-flow.js +6 -6
  155. package/scripts/gds/ship-land.js +3 -4
  156. package/scripts/gds/ship-regen.js +10 -41
  157. package/scripts/gds/ship.js +4 -24
  158. package/scripts/gds/start.js +0 -15
  159. package/scripts/hall-preview/README.md +5 -5
  160. package/scripts/hall-preview/server.js +6 -6
  161. package/scripts/render-diagrams.sh +1 -1
  162. package/src/bongos/api-errors.js +1 -1
  163. package/src/bongos/auth-github.js +2 -3
  164. package/src/bongos/auth.js +51 -126
  165. package/src/bongos/db-kernel.js +2 -3
  166. package/src/bongos/db.js +5 -28
  167. package/src/bongos/module-scope-map.js +14 -29
  168. package/src/bongos/module-store.js +141 -0
  169. package/src/bongos/route-rank-check.js +3 -16
  170. package/src/bongos/routes/auth.js +0 -116
  171. package/src/bongos/routes/builders.js +5 -15
  172. package/src/bongos/routes/instance.js +8 -14
  173. package/src/bongos/routes/me.js +6 -22
  174. package/src/bongos/routes/modules.js +83 -0
  175. package/src/bongos/routes.js +5 -0
  176. package/src/module-api.js +13 -6
  177. package/src/module-loader/catalog.js +3 -0
  178. package/src/module-loader/loader.js +4 -4
  179. package/src/module-loader/manifest-schema.js +15 -7
  180. package/src/module-seams.js +2 -3
  181. package/src/modules.js +42 -14
  182. package/tests/agents_spend_guard.mjs +1 -1
  183. package/tests/api_client.mjs +0 -15
  184. package/tests/auth_page_gate.mjs +10 -10
  185. package/tests/canonical_profile_url.mjs +1 -3
  186. package/tests/claim_action.mjs +4 -1
  187. package/tests/claim_from_task_and_home.mjs +127 -0
  188. package/tests/consumer_layout_boot.mjs +2 -0
  189. package/tests/copy_desk_page_docx.mjs +33 -2
  190. package/tests/core_upgrade_runner.mjs +6 -6
  191. package/tests/deploy_divergence_line.mjs +11 -11
  192. package/tests/diagram_facts_offset.mjs +1 -1
  193. package/tests/do_api.mjs +106 -0
  194. package/tests/feedback_latest.mjs +122 -0
  195. package/tests/fitness.mjs +44 -42
  196. package/tests/gate_approvals.mjs +0 -1
  197. package/tests/go_live.mjs +3 -3
  198. package/tests/government_abuse_matrix.mjs +20 -19
  199. package/tests/government_ownership_scope.mjs +33 -35
  200. package/tests/government_parity.mjs +2 -2
  201. package/tests/government_protected_surfaces.mjs +56 -37
  202. package/tests/government_require_permission.mjs +3 -3
  203. package/tests/government_seed.mjs +37 -4
  204. package/tests/government_session_scope.mjs +92 -366
  205. package/tests/hall_error_envelope.mjs +4 -0
  206. package/tests/hall_landing_boot.mjs +4 -1
  207. package/tests/hall_palette.mjs +1 -16
  208. package/tests/hall_record_world.mjs +6 -0
  209. package/tests/hall_tweak_editor.mjs +52 -1
  210. package/tests/host_topology_skips.mjs +1 -2
  211. package/tests/html_comment_nesting.mjs +137 -0
  212. package/tests/infra_exec_paths.mjs +9 -10
  213. package/tests/init.mjs +4 -5
  214. package/tests/instance_manifest.mjs +18 -25
  215. package/tests/kickoff_checklist.mjs +1 -1
  216. package/tests/lib_sh_resolution.mjs +0 -221
  217. package/tests/local_preview.mjs +0 -12
  218. package/tests/module-scope-map.mjs +9 -9
  219. package/tests/module_catalog.mjs +1 -1
  220. package/tests/module_cli.mjs +15 -15
  221. package/tests/module_contributions.mjs +6 -6
  222. package/tests/module_loader.mjs +6 -6
  223. package/tests/module_manifest.mjs +13 -4
  224. package/tests/module_store_publish.mjs +307 -0
  225. package/tests/module_store_publish_route.mjs +121 -0
  226. package/tests/module_store_registry_migration.mjs +70 -0
  227. package/tests/modules.mjs +56 -26
  228. package/tests/onboarding_route_signals.mjs +21 -31
  229. package/tests/onboarding_state.mjs +35 -49
  230. package/tests/permission_path.mjs +0 -3
  231. package/tests/platform_boot.mjs +1 -2
  232. package/tests/profile_route.mjs +1 -6
  233. package/tests/projects_hub.mjs +5 -5
  234. package/tests/projects_hub_app_step.mjs +138 -0
  235. package/tests/projects_hub_module_picker.mjs +2 -2
  236. package/tests/projects_hub_pre_uat.mjs +4 -5
  237. package/tests/provision.mjs +7 -7
  238. package/tests/provisioning_starter_bundles.mjs +0 -22
  239. package/tests/publish_branch_route.mjs +10 -18
  240. package/tests/publish_manifest.mjs +1 -2
  241. package/tests/rank_tier_single_source.mjs +1 -13
  242. package/tests/repo_map.mjs +3 -4
  243. package/tests/runner_drift.mjs +3 -21
  244. package/tests/sandbox_stage.mjs +57 -390
  245. package/tests/session_rename_fallback.mjs +0 -21
  246. package/tests/ship_error_shape.mjs +1 -1
  247. package/tests/task_visual_slots.mjs +31 -0
  248. package/tests/terms_acceptance.mjs +1 -1
  249. package/tests/upgrade.mjs +4 -4
  250. package/tests/watch_sealed_floor.mjs +0 -1
  251. package/tests/wizard_draft_resume.mjs +2 -2
  252. package/tests/wizard_intent_resume.mjs +21 -10
  253. package/tests/wizard_preselect_why.mjs +18 -18
  254. package/docs/onboarding/browser-terminal-guide.md +0 -73
  255. package/docs/recipes/managed-settings-remote-control.md +0 -416
  256. package/modules/dev-box/CLAUDE.md +0 -15
  257. package/modules/dev-box/box-access.js +0 -623
  258. package/modules/dev-box/box-credential.js +0 -94
  259. package/modules/dev-box/box-onboard.js +0 -486
  260. package/modules/dev-box/boxes.js +0 -1038
  261. package/modules/dev-box/db.js +0 -39
  262. package/modules/dev-box/module.json +0 -19
  263. package/modules/dev-box/routes/box.js +0 -1223
  264. package/scripts/gds/box-auth-check.js +0 -160
  265. package/scripts/gds/box-infra.js +0 -321
  266. package/scripts/gds/box-sync.js +0 -216
  267. package/scripts/gds/box.js +0 -1332
  268. package/scripts/gds/cf-tunnel.js +0 -558
  269. package/scripts/gds/smoke-box.sh +0 -150
  270. package/scripts/gds/tree-preflight.js +0 -241
  271. package/src/bongos/app-pair.js +0 -172
  272. package/tests/app_pair.mjs +0 -168
  273. package/tests/box_access.mjs +0 -578
  274. package/tests/box_auth_check.mjs +0 -114
  275. package/tests/box_code_staleness.mjs +0 -129
  276. package/tests/box_connect_e2e.mjs +0 -196
  277. package/tests/box_cost.mjs +0 -70
  278. package/tests/box_credential.mjs +0 -125
  279. package/tests/box_dns_repoint.mjs +0 -153
  280. package/tests/box_env.mjs +0 -64
  281. package/tests/box_host_keys.mjs +0 -126
  282. package/tests/box_infra_resolve.mjs +0 -246
  283. package/tests/box_onboard.mjs +0 -317
  284. package/tests/box_scope_session.mjs +0 -119
  285. package/tests/box_sweep_docs.mjs +0 -119
  286. package/tests/box_sync.mjs +0 -55
  287. package/tests/box_sync_scope_report.mjs +0 -126
  288. package/tests/box_task_scope.mjs +0 -169
  289. package/tests/box_task_scope_pause.mjs +0 -66
  290. package/tests/box_terminal_ssrf.mjs +0 -96
  291. package/tests/boxes.mjs +0 -1585
  292. package/tests/cf_ruleset_preflight.mjs +0 -142
  293. package/tests/cf_tunnel.mjs +0 -496
  294. package/tests/docs_scrubber_damage.mjs +0 -111
  295. package/tests/module_scope_active_claims.mjs +0 -94
  296. package/tests/ship_api_client_pathspec.mjs +0 -100
  297. package/tests/tree_preflight.mjs +0 -243
@@ -1,38 +1,28 @@
1
- // tests/government_session_scope.mjs — session-scopes as a first-class axis
2
- // (BV1.R99, ADR 0151 §3; the model behind ADR 0053). DB-free.
1
+ // tests/government_session_scope.mjs — the session-source registry (BV1.R99,
2
+ // ADR 0151 §3). DB-free.
3
3
  //
4
- // A session-scope is capability attached to the TOKEN, not to the builder: a
5
- // dev-box session is baked onto a machine OUTSIDE the ADR 0016 trust boundary
6
- // (#919/SR#2), so it must stay limited even when its builder is an Archon.
4
+ // Every builder_sessions row carries a `source`. modules/government/session-scopes.json
5
+ // declares the sources an instance accepts; the kernel (src/bongos/auth.js) DENIES a
6
+ // session whose source it does not declare, on every route (requireBuilder) and every
7
+ // page (runPageGate). Every declared source carries its builder's full rank authority.
7
8
  //
8
- // THE PROPERTY UNDER TEST IS "A SCOPE CAN ONLY EVER NARROW", and it fails in two
9
- // opposite directions, so both are proven separately:
10
- // • UNION INSTEAD OF INTERSECTION ⇒ a box token ESCALATES. Proven by C1/C2 (the
11
- // scope names a key the roles do not confer, and the answer must still be
12
- // empty) and structurally by B4 (for every input, the narrowed set is a SUBSET
13
- // of the unnarrowed role answer — a union cannot satisfy a subset assertion).
14
- // • NO DENY-BY-DEFAULT ⇒ an unlisted permission LEAKS. Proven by D1/D2 (a builder
15
- // holding the ENTIRE catalog resolves, on a box session, to a hand-written
16
- // expected list — not to whatever the registry happens to say).
9
+ // The registry used to be able to declare a NARROWING scope — a deny-by-default
10
+ // allow-list for a machine-baked token. Its only user was the dev box's token (source
11
+ // 'box'), retired by goal 1000120, and the allow-list machinery left with it in task
12
+ // 1003894. What this file now proves:
13
+ // A — both loaders read the same registry, and it declares exactly cli/web/legacy;
14
+ // B — both loaders REFUSE a row that still declares a scope, and a malformed or
15
+ // empty registry, rather than degrading to "every source is accepted";
16
+ // C — requireBuilder denies an undeclared source (a leftover 'box' row included)
17
+ // with 403 session_scope and never calls next(); declared sources pass;
18
+ // D — the page gate agrees (tests/auth_page_gate.mjs holds the fuller page matrix).
17
19
  //
18
- // ORACLE DISCIPLINE. A test whose two sides read the same source of truth passes
19
- // while proving nothing, so nothing load-bearing here is driven by the registry:
20
- // • the Archon-box assertions name Archon powers LITERALLY (builder.rank.set,
21
- // government.manage, deploy.push_main …) and cross-check them against `floor`
22
- // run through auth.rankMeetsThreshold — the REAL production rank gate, a
23
- // pre-R99 oracle;
24
- // • the deny-by-default assertions pin a hand-written expected set, so widening
25
- // the registry reds this file instead of silently agreeing with it;
26
- // • the subset property (B4) is checked against resolver.effectivePermissions —
27
- // the unchanged R95 role answer.
28
- //
29
- // End-to-end reachability through the real requireBuilder chain lives in
30
- // tests/box_scope_session.mjs (the #919 regression guard, unchanged in substance by
31
- // R99); section G here adds the one case that file cannot express — a session
32
- // source the registry does not declare at all.
20
+ // ORACLE DISCIPLINE: the expected source list is written by hand, not read from the
21
+ // registry, so declaring a new source reds this file instead of silently agreeing.
33
22
  import assert from 'node:assert/strict';
34
23
  import { test } from 'node:test';
35
24
  import { createRequire } from 'node:module';
25
+ import fs from 'node:fs';
36
26
  import path from 'node:path';
37
27
  import { fileURLToPath } from 'node:url';
38
28
 
@@ -47,44 +37,11 @@ let nextSession = null;
47
37
  dbKernel.lookupSession = async () => nextSession;
48
38
 
49
39
  const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
50
- const catalog = require(path.join(GOV, 'catalog.js'));
51
- const resolver = require(path.join(GOV, 'resolver.js'));
52
- const db = require(path.join(GOV, 'db.js'));
53
40
  const scopes = require(path.join(GOV, 'session-scopes.js'));
54
41
  const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
55
42
 
56
- const { permissionKeys, seedForRank, RANK_ORDER, byKey } = catalog;
57
- const { narrowToSessionScope, resolveSessionPermissions, effectivePermissions } = resolver;
58
-
59
- const ME = 42;
60
- const EVERYTHING = permissionKeys();
61
-
62
- // What a box session is EXPECTED to be able to do — written by hand, deliberately
63
- // NOT read from the registry. Widening the registry must red this file.
64
- const EXPECTED_BOX_SCOPE = ['box.manage.own'];
65
-
66
- // Archon powers, named literally. If a box session ever resolves one of these the
67
- // #919 containment is gone.
68
- const ARCHON_POWERS = [
69
- 'builder.rank.set', 'government.manage', 'deploy.push_main', 'version.close',
70
- 'task.confirm.grade_bypass', 'path.protected.modify', 'infra.ssh',
71
- 'builder.roster.read', 'claim.release.any', 'memory.read.any', 'audit_log.read',
72
- ];
73
- // Metic powers, named literally — the working rank's authority a box token must
74
- // not inherit either.
75
- const METIC_POWERS = ['idea.triage', 'task.create', 'task.edit', 'goal.create', 'box.source.full'];
76
-
77
- // Stub the ONE DB read the resolver makes, so the composition is exercised
78
- // end-to-end without Postgres (the tests/government_ownership_scope.mjs pattern).
79
- const realGetPermissionKeys = db.getPermissionKeysForBuilder;
80
- async function holding(keys, fn) {
81
- db.getPermissionKeysForBuilder = async () => keys;
82
- try {
83
- return await fn();
84
- } finally {
85
- db.getPermissionKeysForBuilder = realGetPermissionKeys;
86
- }
87
- }
43
+ // Hand-written: the sources this instance accepts.
44
+ const EXPECTED_SOURCES = ['cli', 'web', null];
88
45
 
89
46
  function fakeRes() {
90
47
  const res = {
@@ -97,338 +54,107 @@ function fakeRes() {
97
54
  return res;
98
55
  }
99
56
 
100
- // ═══ A — the axis is explicit, well-formed data, and BOTH loaders agree ════════
101
-
102
- test('A1: every scope declares unscoped XOR a permissions allow-list, over a real source', () => {
103
- assert.ok(scopes.SESSION_SCOPES.length >= 2, 'expected at least an unscoped and a scoped source');
104
- for (const scope of scopes.SESSION_SCOPES) {
105
- assert.ok(scope.source === null || (typeof scope.source === 'string' && scope.source.length > 0),
106
- `a scope's source must be a non-empty string or null, saw ${JSON.stringify(scope.source)}`);
107
- assert.notEqual(scope.unscoped, scope.permissions.length > 0,
108
- `${scope.source}: a scope either narrows nothing or names what it allows — never both/neither`);
109
- assert.ok(Object.isFrozen(scope) && Object.isFrozen(scope.permissions));
110
- }
111
- });
112
-
113
- test('A2: every allow-listed key is a real, operational, non-principal catalog key', () => {
114
- for (const scope of scopes.SESSION_SCOPES) {
115
- for (const key of scope.permissions) {
116
- const permission = byKey(key);
117
- assert.ok(permission, `${scope.source} allows "${key}", which the catalog does not define`);
118
- assert.equal(catalog.isPrincipalKey(key), false,
119
- `${scope.source} allows a machine-principal key — the identity axis never composes with a session scope`);
120
- assert.equal(permission.system, false,
121
- `${scope.source} allows a system:true key — a scoped token lives outside the trust boundary`);
122
- }
123
- }
124
- });
125
-
126
- test('A3: the kernel and the governance module read the SAME registry, identically', () => {
127
- // The kernel loads it with fs (ADR 0083's one-way rule) while the module requires
128
- // it as a sibling, so the two normalizations are the thing that could drift.
129
- assert.deepEqual(auth.SESSION_SCOPES.map((s) => ({ ...s, permissions: [...s.permissions] })),
130
- scopes.SESSION_SCOPES.map((s) => ({ ...s, permissions: [...s.permissions] })));
131
- for (const source of scopes.sources()) {
132
- assert.deepEqual(auth.sessionScopeFor(source), scopes.scopeFor(source));
133
- }
134
- });
135
-
136
- test('A4: the declared box scope is exactly the hand-written expectation', () => {
137
- // The pin that makes the deny-by-default tests below meaningful: widening the
138
- // registry must be a conscious edit here, not a silent agreement.
139
- assert.deepEqual([...scopes.allowedKeys('box')], EXPECTED_BOX_SCOPE);
140
- assert.equal(scopes.isScoped('box'), true);
141
- assert.equal(scopes.isScoped('cli'), false);
142
- assert.equal(scopes.isScoped('web'), false);
143
- });
144
-
145
- // ═══ B — THE key test: a box session of an ARCHON ═════════════════════════════
146
-
147
- test('B1: an ARCHON on a box session resolves ONLY the allow-list, not Archon powers', async () => {
148
- await holding(seedForRank('archon'), async () => {
149
- const viaBox = await resolveSessionPermissions(ME, 'box');
150
- assert.deepEqual([...viaBox], EXPECTED_BOX_SCOPE,
151
- 'a box token must carry the scope\'s allow-list and nothing else, however senior its builder');
152
- for (const key of [...ARCHON_POWERS, ...METIC_POWERS]) {
153
- assert.equal(viaBox.includes(key), false, `a box session reached ${key} — the #919 containment is gone`);
154
- }
155
- });
156
- });
57
+ async function runRequireBuilder(session) {
58
+ nextSession = session;
59
+ const req = { headers: { authorization: 'Bearer anytoken' } };
60
+ const res = fakeRes();
61
+ let nexted = false;
62
+ await auth.requireBuilder(req, res, () => { nexted = true; });
63
+ return { res, nexted, req };
64
+ }
157
65
 
158
- test('B2: the same builder on a CLI session DOES hold those powers (the test is not vacuous)', async () => {
159
- await holding(seedForRank('archon'), async () => {
160
- const viaCli = await resolveSessionPermissions(ME, 'cli');
161
- for (const key of [...ARCHON_POWERS, ...METIC_POWERS]) {
162
- assert.ok(viaCli.includes(key), `an Archon's CLI session must still hold ${key} — B1 would prove nothing otherwise`);
163
- }
164
- assert.ok(viaCli.length > EXPECTED_BOX_SCOPE.length + 10,
165
- 'the box scope must be dramatically smaller than the builder\'s real authority');
166
- });
167
- });
66
+ // ═══ A — one registry, read identically by both loaders ═══════════════════════
168
67
 
169
- test('B3: EVERY seeded rank collapses to the same box scope — rank buys nothing on a box token', () => {
170
- // Independent oracle: `floor` (an R94 field) through auth.rankMeetsThreshold, the
171
- // REAL production rank gate. Anything the rank gate would admit for archon but not
172
- // for xenos is rank-bought authority, and none of it may survive a box session.
173
- const rankBought = catalog.PERMISSIONS
174
- .filter((p) => p.floor !== null && auth.rankMeetsThreshold('archon', [p.floor]) && !auth.rankMeetsThreshold('xenos', [p.floor]))
175
- .map((p) => p.key);
176
- assert.ok(rankBought.length >= 30, `expected the archon/metic/thetes tiers, saw ${rankBought.length}`);
177
- for (const rank of RANK_ORDER) {
178
- const viaBox = narrowToSessionScope(seedForRank(rank), 'box');
179
- for (const key of rankBought) {
180
- assert.equal(viaBox.includes(key), false, `${rank}'s box session reached rank-bought ${key}`);
181
- }
182
- }
183
- // ...and every rank's box session is identical, because the scope — not the rank —
184
- // is what decides.
185
- const perRank = RANK_ORDER.map((r) => [...narrowToSessionScope(seedForRank(r), 'box')]);
186
- for (const resolved of perRank) assert.deepEqual(resolved, perRank[0]);
68
+ test('A1: the registry declares exactly the hand-written sources', () => {
69
+ assert.deepEqual(scopes.sources(), EXPECTED_SOURCES);
70
+ assert.deepEqual(auth.SESSION_SCOPES.map((s) => s.source), EXPECTED_SOURCES);
187
71
  });
188
72
 
189
- test('B4: for EVERY source and EVERY input, narrowing returns a SUBSET of the role answer', () => {
190
- // The structural statement of "can only narrow", against the unchanged R95 role
191
- // answer. A union cannot satisfy a subset assertion, so this is the property that
192
- // makes widening inexpressible rather than merely unintended.
193
- const inputs = [
194
- EVERYTHING, [], ['idea.triage'], ['box.manage.own'], seedForRank('xenos'),
195
- seedForRank('thetes'), seedForRank('metic'), seedForRank('archon'),
196
- ['not.a.real.permission'], ['principal.bfg', 'box.manage.own'],
197
- ];
198
- for (const source of [...scopes.sources(), 'ghost', undefined]) {
199
- for (const input of inputs) {
200
- const base = new Set(effectivePermissions(input));
201
- for (const key of narrowToSessionScope(input, source)) {
202
- assert.ok(base.has(key),
203
- `source ${JSON.stringify(source)}: narrowing produced "${key}", which the roles never conferred — this is a WIDENING`);
204
- }
205
- }
73
+ test('A2: the kernel and the governance module agree on every source, and on the retired one', () => {
74
+ for (const source of [...EXPECTED_SOURCES, 'box', 'ghost', undefined, 7]) {
75
+ assert.equal(!!auth.sessionScopeFor(source), !!scopes.scopeFor(source), `loaders disagree on ${String(source)}`);
206
76
  }
77
+ assert.equal(auth.sessionScopeFor('box'), null, 'the retired dev-box source must not be declared');
78
+ assert.ok(auth.sessionScopeFor(undefined), 'an absent source is the declared legacy row');
207
79
  });
208
80
 
209
- // ═══ C — intersection, NOT union: the scope cannot grant ══════════════════════
210
-
211
- test('C1: a session-scope cannot grant a permission the builder\'s roles lack', async () => {
212
- // The scope names box.manage.own; this builder holds everything EXCEPT it. A
213
- // union would hand it over; an intersection must return nothing.
214
- const withoutTheKey = EVERYTHING.filter((k) => !EXPECTED_BOX_SCOPE.includes(k));
215
- await holding(withoutTheKey, async () => {
216
- assert.deepEqual([...await resolveSessionPermissions(ME, 'box')], [],
217
- 'the scope GRANTED a key the roles do not confer — this is a union, not an intersection');
218
- });
219
- });
220
-
221
- test('C2: a builder with no roles at all gets nothing from any scope', async () => {
222
- await holding([], async () => {
223
- for (const source of [...scopes.sources(), 'ghost']) {
224
- assert.deepEqual([...await resolveSessionPermissions(ME, source)], [],
225
- `source ${JSON.stringify(source)}: an empty role set must stay empty — a scope is not a grant`);
226
- }
227
- });
228
- });
229
-
230
- test('C3: a null/blank builder resolves to nothing on every source', async () => {
231
- for (const builderId of [null, undefined, '']) {
232
- for (const source of scopes.sources()) {
233
- assert.deepEqual([...await resolveSessionPermissions(builderId, source)], []);
234
- }
81
+ test('A3: no row declares a scope any more', () => {
82
+ const raw = JSON.parse(fs.readFileSync(path.join(GOV, 'session-scopes.json'), 'utf8'));
83
+ for (const row of raw.scopes) {
84
+ assert.equal(row.permissions, undefined, `${row.source}: an allow-list without enforcement would grant full authority`);
85
+ assert.equal(row.unscoped, undefined, `${row.source}: the scoped/unscoped distinction is gone`);
235
86
  }
236
87
  });
237
88
 
238
- // ═══ D — deny-by-default: an unlisted permission is denied ════════════════════
239
-
240
- test('D1: holding the ENTIRE catalog, a box session still resolves only the allow-list', async () => {
241
- await holding(EVERYTHING, async () => {
242
- assert.deepEqual([...await resolveSessionPermissions(ME, 'box')], EXPECTED_BOX_SCOPE,
243
- 'an unlisted permission leaked through — the allow-list is not deny-by-default');
244
- });
245
- });
246
-
247
- test('D2: every catalog key outside the allow-list is denied on a box session', () => {
248
- const resolved = new Set(narrowToSessionScope(EVERYTHING, 'box'));
249
- const listed = new Set(EXPECTED_BOX_SCOPE);
250
- const leaked = [...resolved].filter((k) => !listed.has(k));
251
- assert.deepEqual(leaked, [], `these unlisted permissions survived a box session: ${leaked.join(', ')}`);
252
- // and the coverage is real — the catalog is far bigger than the allow-list
253
- assert.ok(EVERYTHING.length > EXPECTED_BOX_SCOPE.length + 50,
254
- 'the catalog should dwarf the box allow-list, or D1/D2 assert almost nothing');
255
- });
89
+ // ═══ B — fail closed at load ══════════════════════════════════════════════════
256
90
 
257
- test('D3: route reachability is deny-by-default too — a route that declares nothing is out of reach', () => {
258
- assert.equal(auth.sessionScopeAdmits('box', undefined), false, 'a route with no declaration must deny a box session');
259
- assert.equal(auth.sessionScopeAdmits('box', []), false, 'an empty declaration is not an opt-in');
260
- assert.equal(auth.sessionScopeAdmits('box', ['idea.triage']), false, 'a declaration outside the allow-list must deny');
261
- assert.equal(auth.sessionScopeAdmits('box', [...EXPECTED_BOX_SCOPE, 'idea.triage']), false,
262
- 'every declared capability must be inside the scope — one unlisted key denies the whole route');
263
- assert.equal(auth.sessionScopeAdmits('box', EXPECTED_BOX_SCOPE), true);
91
+ test('B1: the kernel parser refuses a narrowing row, an empty registry, and duplicates', () => {
92
+ assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'box', permissions: ['task.ship'] }] }), /Narrowing session scopes were removed/);
93
+ assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'cli', unscoped: true }] }), /declares a scope/);
94
+ assert.throws(() => auth.parseSessionScopes({ scopes: [] }), /declares no session sources/);
95
+ assert.throws(() => auth.parseSessionScopes({}), /declares no session sources/);
96
+ assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'cli' }, { source: 'cli' }] }), /duplicate/);
97
+ assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: '' }] }), /non-empty session source/);
98
+ // …and the happy shape parses, so the refusals above are not a parser that refuses everything.
99
+ assert.equal(auth.parseSessionScopes({ scopes: [{ source: 'cli' }, { source: null }] }).size, 2);
264
100
  });
265
101
 
266
- // ═══ E — an unknown key on an allow-list is refused, and inert if it got through ══
267
-
268
- test('E1: an unknown, system, or principal key on an allow-list is REJECTED at load', () => {
269
- // Exercises the SHIPPED assertion (the module exports the validator it runs on
270
- // its own data), not a re-implementation of it.
271
- const scoped = (permissions) => [scopes.normalizeScope({ source: 'box', permissions })];
272
- assert.throws(() => scopes.validateAgainstCatalog(scoped(['not.a.real.permission'])),
273
- /which the permission catalog does not define/);
274
- assert.throws(() => scopes.validateAgainstCatalog(scoped(['principal.bfg'])),
275
- /machine-principal permission/);
276
- assert.throws(() => scopes.validateAgainstCatalog(scoped(['deploy.push_main'])),
277
- /system:true permission/);
278
- assert.throws(() => scopes.validateAgainstCatalog([scopes.normalizeScope({ source: 'box', unscoped: true, permissions: ['box.manage.own'] })]),
279
- /never both\/neither/);
280
- assert.throws(() => scopes.validateAgainstCatalog([scopes.normalizeScope({ source: 'box' })]),
281
- /never both\/neither/);
282
- // the real registry passes its own validator (the fixtures above are not the
283
- // only thing keeping this green)
284
- assert.doesNotThrow(() => scopes.validateAgainstCatalog(scopes.SESSION_SCOPES));
102
+ test('B2: the governance validator refuses the same shapes', () => {
103
+ const v = (rows) => scopes.validateScopes(rows.map(scopes.normalizeScope));
104
+ assert.throws(() => v([{ source: 'box', permissions: ['task.ship'] }]), /Narrowing session scopes were removed/);
105
+ assert.throws(() => v([]), /declares no session sources/);
106
+ assert.throws(() => v([{ source: 'web' }, { source: 'web' }]), /declared twice/);
107
+ assert.equal(v([{ source: 'web' }]).length, 1);
285
108
  });
286
109
 
287
- test('E2: the kernel loader refuses a malformed registry rather than defaulting to unscoped', () => {
288
- assert.throws(() => auth.parseSessionScopes({}), /declares no session scopes/);
289
- assert.throws(() => auth.parseSessionScopes({ scopes: [] }), /declares no session scopes/);
290
- assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'box' }] }), /never both\/neither/);
291
- assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'box', unscoped: true, permissions: ['x'] }] }), /never both\/neither/);
292
- assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 7, unscoped: true }] }), /non-empty session source string/);
293
- assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'box', permissions: [42] }] }), /non-string permission key/);
294
- assert.throws(() => auth.parseSessionScopes({ scopes: [{ source: 'cli', unscoped: true }, { source: 'cli', unscoped: true }] }), /duplicate session source/);
295
- });
110
+ // ═══ C — requireBuilder: undeclared ⇒ 403, declared ⇒ through ═════════════════
296
111
 
297
- test('E3: an unknown key that somehow reached an allow-list grants nothing', () => {
298
- // Defence in depth behind E1: the intersection is taken against the CATALOG-
299
- // FILTERED role answer, so a key the catalog does not define can never appear on
300
- // both sides — it is inert as well as refused.
301
- const stub = { source: 'x', label: null, unscoped: false, permissions: Object.freeze(['not.a.real.permission']) };
302
- const held = effectivePermissions([...EVERYTHING, 'not.a.real.permission']);
303
- assert.equal(held.includes('not.a.real.permission'), false, 'the role answer already drops unknown keys');
304
- assert.deepEqual(held.filter((k) => stub.permissions.includes(k)), [],
305
- 'an unknown allow-list key cannot survive the intersection');
112
+ test('C1: a leftover dev-box session (source box) is denied, whatever its rank', async () => {
113
+ const { res, nexted, req } = await runRequireBuilder({ builder_id: 3, github_login: 'boxbot', rank: 'archon', status: 'active', source: 'box' });
114
+ assert.equal(nexted, false, 'a retired source must not reach any route');
115
+ assert.equal(res._status, 403);
116
+ assert.equal(res._json.error.code, 'session_scope');
117
+ assert.equal(req.builder, undefined, 'no builder is attached to a denied request');
306
118
  });
307
119
 
308
- // ═══ F — a normal CLI/web session is unaffected ═══════════════════════════════
309
-
310
- test('F1: an unscoped session resolves EXACTLY the builder\'s role answer, for every rank', async () => {
311
- for (const rank of RANK_ORDER) {
312
- const roles = seedForRank(rank);
313
- for (const source of ['cli', 'web', null, undefined]) {
314
- assert.deepEqual([...narrowToSessionScope(roles, source)], [...effectivePermissions(roles)],
315
- `${rank} on ${JSON.stringify(source)}: an unscoped session must narrow NOTHING`);
316
- }
317
- }
318
- await holding(seedForRank('metic'), async () => {
319
- assert.deepEqual([...await resolveSessionPermissions(ME, 'cli')],
320
- [...await resolver.resolveBuilderPermissions(ME)]);
321
- });
120
+ test('C2: an unknown source is denied the same way', async () => {
121
+ const { res, nexted } = await runRequireBuilder({ builder_id: 4, github_login: 'mystery', rank: 'archon', status: 'active', source: 'not-a-source' });
122
+ assert.equal(nexted, false);
123
+ assert.equal(res._json.error.code, 'session_scope');
322
124
  });
323
125
 
324
- test('F2: an unscoped session reaches every route, declared or not', () => {
126
+ test('C3: cli, web and legacy sessions pass untouched', async () => {
325
127
  for (const source of ['cli', 'web', null, undefined]) {
326
- assert.equal(auth.sessionScopeAdmits(source, undefined), true);
327
- assert.equal(auth.sessionScopeAdmits(source, []), true);
328
- assert.equal(auth.sessionScopeAdmits(source, ['box.manage.own']), true);
329
- assert.equal(auth.sessionScopeAdmits(source, ['government.manage']), true);
128
+ const session = { builder_id: 1, github_login: 'someone', rank: 'xenos', status: 'active' };
129
+ if (source !== undefined) session.source = source;
130
+ const { nexted, res } = await runRequireBuilder(session);
131
+ assert.equal(nexted, true, `a ${String(source)} session must reach the route`);
132
+ assert.equal(res._status, null);
330
133
  }
331
134
  });
332
135
 
333
- // ═══ G — an unrecognised session source denies ════════════════════════════════
334
-
335
- test('G1: an unrecognised source resolves to NOTHING and reaches NO route', async () => {
336
- const unrecognised = ['ghost', 'BOX', 'Box', 'box ', ' cli', 'admin', 0, 1, {}, [], true, false, NaN];
337
- await holding(EVERYTHING, async () => {
338
- for (const source of unrecognised) {
339
- assert.equal(auth.sessionScopeFor(source), null, `${String(source)} must not resolve to a scope`);
340
- assert.deepEqual([...await resolveSessionPermissions(ME, source)], [],
341
- `${String(source)} must resolve to no authority at all, never to "full"`);
342
- assert.equal(auth.sessionScopeAdmits(source, ['box.manage.own']), false);
343
- assert.equal(auth.sessionScopeAdmits(source, undefined), false);
344
- }
345
- });
346
- });
347
-
348
- test('G2: requireBuilder DENIES a session whose source the registry does not declare', async () => {
349
- nextSession = { builder_id: 3, github_login: 'ghost', rank: 'archon', status: 'active', source: 'ghost' };
350
- const req = { headers: { authorization: 'Bearer ghosttoken' } };
136
+ test('C4: the retired doorway alias admits nothing extra', async () => {
137
+ // src/module-api.js keeps `allowBoxScope` as a no-op until the next MAJOR. Mounting
138
+ // it ahead of requireBuilder must not let a box session through.
139
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
140
+ nextSession = { builder_id: 3, github_login: 'boxbot', rank: 'archon', status: 'active', source: 'box' };
141
+ const req = { headers: { authorization: 'Bearer boxtoken' } };
351
142
  const res = fakeRes();
352
143
  let nexted = false;
144
+ api.allowBoxScope(req, res, () => {});
353
145
  await auth.requireBuilder(req, res, () => { nexted = true; });
354
- assert.equal(nexted, false, 'an unclassifiable session must never be treated as unscoped');
355
- assert.equal(res._status, 403);
356
- assert.equal(res._json.error.code, 'box_scope');
357
- // ...and it stays denied even on a route that opted in, because the DENIAL is the
358
- // session's, not the route's.
359
- const optedIn = { headers: { authorization: 'Bearer ghosttoken' } };
360
- auth.allowBoxScope(optedIn, fakeRes(), () => {});
361
- const res2 = fakeRes();
362
- let nexted2 = false;
363
- await auth.requireBuilder(optedIn, res2, () => { nexted2 = true; });
364
- assert.equal(nexted2, false);
365
- assert.equal(res2._status, 403);
366
- nextSession = null;
367
- });
368
-
369
- // ═══ H — the earlier axes are undisturbed ════════════════════════════════════
370
-
371
- test('H1: R95/R98/R100 invariants still hold — the role answer is untouched', async () => {
372
- // R99 composes ON TOP of effectivePermissions; widening INSIDE it would red
373
- // tests/government_parity.mjs Parts A and C.
374
- assert.deepEqual(effectivePermissions(['principal.bfg', 'box.manage.own']), ['box.manage.own']);
375
- assert.deepEqual(effectivePermissions(['idea.triage', 'not.a.real.permission']), ['idea.triage']);
376
- for (const rank of RANK_ORDER) {
377
- assert.deepEqual([...effectivePermissions(seedForRank(rank))].sort(),
378
- [...seedForRank(rank)].filter((k) => !catalog.isPrincipalKey(k)).sort());
379
- }
380
- await holding(EVERYTHING, async () => {
381
- // principals are still the other axis
382
- assert.deepEqual([...resolver.resolvePrincipalPermissions('bfg')], ['principal.bfg']);
383
- assert.deepEqual([...resolver.resolvePrincipalPermissions(null)], []);
384
- // ownership is still a conjunction the plain resolve refuses to answer
385
- assert.equal(await resolver.builderHasPermissions(ME, ['box.manage.own']), false);
386
- assert.equal(await resolver.authorizeOwned(ME, 'box.manage.own', { ownerId: 99 }), false);
387
- assert.equal(await resolver.authorizeOwned(ME, 'box.manage.own', { ownerId: ME }), true);
388
- });
389
- });
390
-
391
- test('H2: a box session narrows to an OWNERSHIP-SCOPED key, which still is not an unconditional grant', async () => {
392
- // The two axes compose without either swallowing the other: the session-scope
393
- // says WHICH keys survive; the ownership axis still demands the row be yours.
394
- await holding(seedForRank('archon'), async () => {
395
- assert.deepEqual([...await resolveSessionPermissions(ME, 'box')], ['box.manage.own']);
396
- assert.equal(await resolver.authorizeOwned(ME, 'box.manage.own', { ownerId: 99 }), false,
397
- 'surviving the session scope must not turn .own into an unconditional grant');
398
- // the cross-owner escalation an Archon holds by role is NOT in the box scope
399
- assert.equal(narrowToSessionScope(seedForRank('archon'), 'box').includes('box.fleet.manage'), false);
400
- });
401
- });
402
-
403
- test('H3: the resolve is still per-request and frozen — no memoised session authority', async () => {
404
- let calls = 0;
405
- let keys = ['box.manage.own'];
406
- db.getPermissionKeysForBuilder = async () => { calls += 1; return keys; };
407
- try {
408
- assert.deepEqual([...await resolveSessionPermissions(ME, 'box')], ['box.manage.own']);
409
- keys = [];
410
- assert.deepEqual([...await resolveSessionPermissions(ME, 'box')], []);
411
- assert.equal(calls, 2, 'a revoked grant must take effect on the very next resolve (ADR 0016)');
412
- } finally {
413
- db.getPermissionKeysForBuilder = realGetPermissionKeys;
414
- }
415
- assert.ok(Object.isFrozen(narrowToSessionScope(EVERYTHING, 'box')));
416
- assert.ok(Object.isFrozen(narrowToSessionScope(EVERYTHING, 'ghost')));
146
+ assert.equal(nexted, false);
147
+ assert.equal(res._json.error.code, 'session_scope');
417
148
  });
418
149
 
419
- // ═══ I — the ONE governance port carries the axis ════════════════════════════
150
+ // ═══ D — the auth exports carry no narrowing machinery ════════════════════════
420
151
 
421
- test('I1: the session-scope axis lands on the single governance port', () => {
422
- const { governmentPort } = require(path.join(GOV, 'routes', 'government.js'));
423
- for (const name of ['resolveSessionPermissions', 'narrowToSessionScope', 'sessionScopeFor', 'listSessionScopes']) {
424
- assert.equal(typeof governmentPort[name], 'function', `the port must expose ${name}`);
152
+ test('D1: the narrowing helpers are gone from auth and the resolver', () => {
153
+ for (const name of ['allowBoxScope', 'sessionScope', 'sessionScopeAdmits']) {
154
+ assert.equal(auth[name], undefined, `auth.${name} should be gone`);
425
155
  }
426
- // still the same one object port the earlier slices registered
427
- for (const name of ['resolveBuilderPermissions', 'resolvePrincipalPermissions', 'authorizeOwned']) {
428
- assert.equal(typeof governmentPort[name], 'function');
156
+ const resolver = require(path.join(GOV, 'resolver.js'));
157
+ for (const name of ['narrowToSessionScope', 'resolveSessionPermissions']) {
158
+ assert.equal(resolver[name], undefined, `resolver.${name} should be gone`);
429
159
  }
430
- assert.deepEqual([...governmentPort.sessionScopeFor('box').permissions], EXPECTED_BOX_SCOPE);
431
- assert.equal(governmentPort.sessionScopeFor('ghost'), null);
432
- assert.deepEqual([...governmentPort.narrowToSessionScope(seedForRank('archon'), 'box')], EXPECTED_BOX_SCOPE);
433
- assert.ok(governmentPort.listSessionScopes().length >= 2);
434
160
  });
@@ -212,6 +212,10 @@ async function bootTaskPage({ task, failWith }) {
212
212
  const realToast = sandbox.window.OTB.toast;
213
213
  sandbox.window.OTB = { ...sandbox.window.OTB, toast: (m) => { toasts.push(String(m)); } };
214
214
  void realToast;
215
+ // task 1003459: task.js now destructures window.OTBClaimAction at eval time
216
+ // (its Claim it button) — load claim-action.js first, exactly the real
217
+ // page's script order (task.html loads it before task.js).
218
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'claim-action.js'), 'utf8'), sandbox, { filename: 'claim-action.js' });
215
219
  vm.runInContext(fs.readFileSync(path.join(HALL, 'task.js'), 'utf8'), sandbox, { filename: 'task.js' });
216
220
  assert.ok(document._ready, 'task.js registered a DOMContentLoaded handler');
217
221
  await document._ready();
@@ -91,7 +91,7 @@ async function boot({ meStatus }) {
91
91
  wirePager: () => {},
92
92
  },
93
93
  OTBHallRender: () => ({
94
- SETUP_CONFIG_KEY: 'k', analyticsState: {}, lbState: {},
94
+ analyticsState: {}, lbState: {},
95
95
  shipState: {}, shipsPager: { page: 1 }, welcomeDismissalKey: () => 'k',
96
96
  mountHallWidgets: spy('mountHallWidgets'),
97
97
  renderLanding: spy('renderLanding'),
@@ -99,6 +99,9 @@ async function boot({ meStatus }) {
99
99
  renderNav: spy('renderNav'), renderOnboarding: spy('renderOnboarding'),
100
100
  renderProfile: spy('renderProfile'), renderRecentShips: spy('renderRecentShips'),
101
101
  renderWelcomeCore: spy('renderWelcomeCore'),
102
+ // task 1003459 — wireEvents() calls this unconditionally now (the Home
103
+ // Next-up Claim button), so an incomplete stub throws mid-wire.
104
+ wireNextUpClaim: spy('wireNextUpClaim'),
102
105
  }),
103
106
  matchMedia: () => ({ matches: false, addEventListener() {} }),
104
107
  localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
@@ -28,7 +28,7 @@ const SHELL_SRC = read('shell.js');
28
28
 
29
29
  // Boot palette.js against a DOM stub whose sidebar carries `navItems`, and whose
30
30
  // `visibleIds` decide which section groups are on offer. Returns window.OTBPalette.
31
- function bootPalette({ navItems = [], visibleIds = ['nav-standing', 'nav-work', 'shell-user'], modules = null } = {}) {
31
+ function bootPalette({ navItems = [], visibleIds = ['nav-standing', 'nav-work', 'shell-user'] } = {}) {
32
32
  const makeNav = (it) => ({
33
33
  hidden: !!it.hidden,
34
34
  target: it.external ? '_blank' : '',
@@ -60,7 +60,6 @@ function bootPalette({ navItems = [], visibleIds = ['nav-standing', 'nav-work',
60
60
  activeElement: null,
61
61
  };
62
62
  const windowObj = { location: { hash: '', assign() {} }, addEventListener() {}, open() {} };
63
- if (modules) windowObj.__MODULES__ = { enabled: modules };
64
63
  const sandbox = {
65
64
  window: windowObj, document: documentObj, navigator: { platform: 'Win32', userAgent: 'test' },
66
65
  console,
@@ -143,20 +142,6 @@ test('Settings panel anchors exist in settings.js RAIL_GROUPS', () => {
143
142
  }
144
143
  });
145
144
 
146
- test('the Dev box panel jump carries settings.js own dev-box module condition', () => {
147
- // settings.js builds that panel only when moduleOn('dev-box'); offering the
148
- // jump regardless would land on a panel that was never rendered.
149
- assert.match(read('settings.js'), /moduleOn\('dev-box'\)\s*\?\s*\[\{ id: 'devbox'/,
150
- 'settings.js still gates the devbox panel on the module');
151
- assert.match(PALETTE_SRC, /href: '\/settings#devbox', module: 'dev-box'/,
152
- 'so the palette entry must declare the same module');
153
-
154
- const on = bootPalette({ navItems: NAV_FIXTURE, modules: { 'dev-box': true } }).compute('dev box');
155
- assert.ok(on.some((r) => r.href === '/settings#devbox'), 'offered when the module is on');
156
- const off = bootPalette({ navItems: NAV_FIXTURE, modules: { 'dev-box': false } }).compute('dev box');
157
- assert.ok(!off.some((r) => r.href === '/settings#devbox'), 'withheld when the module is off');
158
- });
159
-
160
145
  test('the anchor guard actually CATCHES a declared id that does not exist', () => {
161
146
  const groups = ['work', 'karma', 'progress', 'achievements'];
162
147
  assert.equal(groups.includes('standing-nope'.replace('standing-', '')), false,
@@ -286,6 +286,11 @@ const TASK_SRC = read('task.js');
286
286
  // task.html loads md-reader.js ahead of task.js (task 1003465), so the sandbox
287
287
  // does the same: the description renders through it.
288
288
  const MD_SRC = read('md-reader.js');
289
+ // task 1003459: task.js now destructures window.OTBClaimAction at eval time
290
+ // (its Claim it button) — task.html loads claim-action.js ahead of task.js,
291
+ // so the sandbox does too. Real file, not a stub: it is dependency-free and
292
+ // DOM-free, so running it is exactly as cheap as stubbing it.
293
+ const CLAIM_ACTION_SRC = read('claim-action.js');
289
294
 
290
295
  // grade / sessionLogs / claims (task 1003437): left undefined, the stub answers
291
296
  // like a client that never asked (the key is absent); null or an array answers
@@ -400,6 +405,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
400
405
  sandbox.globalThis = sandbox;
401
406
  vm.createContext(sandbox);
402
407
  if (withReader) vm.runInContext(MD_SRC, sandbox, { filename: 'md-reader.js' });
408
+ vm.runInContext(CLAIM_ACTION_SRC, sandbox, { filename: 'claim-action.js' });
403
409
  vm.runInContext(TASK_SRC, sandbox, { filename: 'task.js' });
404
410
  assert.ok(onReady, 'task.js registers its DOMContentLoaded boot');
405
411
  return {