@bongos/core 1.18.3 → 1.18.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +1575 -285
- package/.claude/skills/builder-box/SKILL.md +6 -0
- package/.claude/skills/builder-sequence/SKILL.md +97 -0
- package/.claude/skills/builder-ship/SKILL.md +8 -2
- package/.claude/skills/builder-start/SKILL.md +11 -0
- package/.claude/skills/builder-sync/SKILL.md +41 -0
- package/.claude/skills/fix-task/SKILL.md +64 -0
- package/.claude/skills/goal-create/SKILL.md +158 -0
- package/.claude/skills/goal-review/SKILL.md +3 -1
- package/.claude/skills/idea-triage/SKILL.md +14 -8
- package/.claude/skills/merge-mode/SKILL.md +2 -2
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +45 -18
- package/.env.local.example +4 -3
- package/README.md +4 -2
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +2 -2
- package/clients/bongos-client/bongos-client.global.js +82 -18
- package/clients/bongos-client/index.cjs +82 -18
- package/clients/bongos-client/index.d.ts +131 -29
- package/clients/bongos-client/index.mjs +82 -18
- package/config/branding.neutral.json +6 -1
- package/config/governance.neutral.json +21 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
- package/docs/adr/0090-metic-task-authoring.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
- package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
- package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
- package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
- package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
- package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
- package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
- package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
- package/docs/adr/0152-metic-task-abandonment.md +67 -0
- package/docs/adr/0153-sequential-chain-runner.md +51 -0
- package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
- package/docs/adr/README.md +10 -0
- package/docs/api/openapi.json +843 -17
- package/docs/api-reference.md +23 -10
- package/docs/architecture.md +4 -2
- package/docs/branding-contract.md +3 -1
- package/docs/canonical-permissions.md +21 -8
- package/docs/file-map.md +8 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
- package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
- package/docs/onboarding/diagrams/assertions.json +4 -7
- package/docs/onboarding/drachmae-and-karma.md +3 -3
- package/docs/onboarding/primer.md +17 -16
- package/docs/onboarding/slash-commands.md +124 -0
- package/docs/recipes/builder-box-lifecycle.md +23 -0
- package/docs/recipes/devbox-app-release.md +14 -4
- package/docs/recipes/instance-first-run.md +16 -0
- package/docs/recipes/local-dev.md +81 -0
- package/docs/recipes/ops-gotchas.md +19 -1
- package/docs/recipes/overnight-builder.md +1 -1
- package/docs/recipes/parallel-goal-waves.md +174 -0
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/standalone-live-docs.md +48 -0
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
- package/migrations/003_pms.sql +6 -7
- package/migrations/006_pms_v2.sql +3 -31
- package/migrations/core_193_task_needs_rebase.sql +28 -0
- package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
- package/migrations/core_195_idea_triage_provenance.sql +57 -0
- package/migrations/core_196_session_reward_topup.sql +69 -0
- package/modules/autonomy/pollers/routine-timers.js +9 -8
- package/modules/builder-settings/wandering-prefs.js +61 -18
- package/modules/dev-box/app/README.md +73 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
- package/modules/dev-box/app/electron-builder.yml +95 -0
- package/modules/dev-box/app/package-lock.json +5291 -0
- package/modules/dev-box/app/package.json +30 -0
- package/modules/dev-box/app/renderer/app.js +319 -0
- package/modules/dev-box/app/renderer/index.html +45 -0
- package/modules/dev-box/app/renderer/styles.css +246 -0
- package/modules/dev-box/app/renderer/theme.generated.css +16 -0
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
- package/modules/dev-box/app/scripts/gen-brand.js +138 -0
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
- package/modules/dev-box/app/scripts/gen-icons.js +286 -0
- package/modules/dev-box/app/scripts/vendor-client.js +23 -0
- package/modules/dev-box/app/src/box-controller.js +287 -0
- package/modules/dev-box/app/src/brand.generated.json +13 -0
- package/modules/dev-box/app/src/brand.js +16 -0
- package/modules/dev-box/app/src/config.js +19 -0
- package/modules/dev-box/app/src/gds-client.js +90 -0
- package/modules/dev-box/app/src/main.js +610 -0
- package/modules/dev-box/app/src/pairing.js +57 -0
- package/modules/dev-box/app/src/preload.js +29 -0
- package/modules/dev-box/app/src/session-store.js +58 -0
- package/modules/dev-box/app/src/ssh-setup.js +569 -0
- package/modules/dev-box/app/src/task-picker.js +70 -0
- package/modules/dev-box/app/src/tray.js +99 -0
- package/modules/dev-box/app/src/updater.js +55 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
- package/modules/dev-box/app/test/unit.mjs +526 -0
- package/modules/dev-box/box-access.js +121 -4
- package/modules/dev-box/boxes.js +20 -0
- package/modules/dev-box/module.json +1 -1
- package/modules/dev-box/routes/box.js +87 -3
- package/modules/economy/credits.js +116 -25
- package/modules/economy/reward.js +8 -0
- package/modules/economy/streak.js +2 -1
- package/modules/governance/CLAUDE.md +15 -0
- package/modules/governance/catalog.js +419 -0
- package/modules/governance/config.js +174 -0
- package/modules/governance/db.js +125 -0
- package/modules/governance/migrations/governance_001_tables.sql +81 -0
- package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
- package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
- package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
- package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
- package/modules/governance/module.json +10 -0
- package/modules/governance/protected-surfaces.js +88 -0
- package/modules/governance/protected-surfaces.json +80 -0
- package/modules/governance/rank-roles.js +83 -0
- package/modules/governance/resolver.js +221 -0
- package/modules/governance/routes/governance.js +89 -0
- package/modules/governance/routes/role-sync.js +49 -0
- package/modules/governance/session-scopes.js +127 -0
- package/modules/governance/session-scopes.json +38 -0
- package/modules/hall-ui/public/atlas.css +117 -0
- package/modules/hall-ui/public/atlas.html +3 -114
- package/modules/hall-ui/public/brand-apply.js +34 -6
- package/modules/hall-ui/public/builders.js +187 -219
- package/modules/hall-ui/public/diagrams.html +2 -0
- package/modules/hall-ui/public/dom-utils.js +27 -0
- package/modules/hall-ui/public/drachmae.html +2 -0
- package/modules/hall-ui/public/drachmae.js +54 -1
- package/modules/hall-ui/public/gate.css +98 -0
- package/modules/hall-ui/public/gate.html +3 -95
- package/modules/hall-ui/public/goal-inbox.js +4 -1
- package/modules/hall-ui/public/goals-page.js +626 -114
- package/modules/hall-ui/public/goals.css +79 -1
- package/modules/hall-ui/public/goals.html +38 -11
- package/modules/hall-ui/public/hall-widgets.js +62 -52
- package/modules/hall-ui/public/harbor.css +123 -0
- package/modules/hall-ui/public/harbor.html +3 -120
- package/modules/hall-ui/public/index.html +11 -4
- package/modules/hall-ui/public/instances-panel.js +4 -1
- package/modules/hall-ui/public/modules.css +63 -0
- package/modules/hall-ui/public/modules.html +3 -60
- package/modules/hall-ui/public/new-project-wizard.js +7 -3
- package/modules/hall-ui/public/not-ready.html +2 -0
- package/modules/hall-ui/public/pair.css +73 -0
- package/modules/hall-ui/public/pair.html +3 -70
- package/modules/hall-ui/public/palette.js +302 -0
- package/modules/hall-ui/public/primer.css +13 -0
- package/modules/hall-ui/public/primer.html +2 -0
- package/modules/hall-ui/public/profile.html +2 -0
- package/modules/hall-ui/public/ranks.html +2 -0
- package/modules/hall-ui/public/roadmap.css +179 -0
- package/modules/hall-ui/public/roadmap.html +9 -172
- package/modules/hall-ui/public/roadmap.js +329 -316
- package/modules/hall-ui/public/sessions.css +16 -0
- package/modules/hall-ui/public/sessions.html +3 -13
- package/modules/hall-ui/public/settings.html +2 -0
- package/modules/hall-ui/public/settings.js +4 -4
- package/modules/hall-ui/public/shell.js +143 -18
- package/modules/hall-ui/public/style.css +231 -39
- package/modules/hall-ui/public/task-detail.js +347 -26
- package/modules/hall-ui/public/task.html +6 -1
- package/modules/hall-ui/public/watch.html +2 -0
- package/modules/hall-ui/public/work.html +8 -6
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/ideas/inbox.js +61 -11
- package/modules/ideas/routes/inbox.js +23 -0
- package/modules/lifecycle/claim-eligibility.js +72 -0
- package/modules/lifecycle/db.js +519 -139
- package/modules/lifecycle/done-when.js +18 -3
- package/modules/lifecycle/github-push.js +19 -2
- package/modules/lifecycle/goal-advisory.js +114 -0
- package/modules/lifecycle/goal-authz.js +62 -0
- package/modules/lifecycle/lifecycle.js +9 -0
- package/modules/lifecycle/publish-reconciler.js +109 -6
- package/modules/lifecycle/routes/claims.js +68 -23
- package/modules/lifecycle/routes/done-when.js +110 -15
- package/modules/lifecycle/routes/goals.js +8 -38
- package/modules/lifecycle/routes/tasks.js +38 -6
- package/modules/lifecycle/ship-card.js +6 -0
- package/modules/onboarding/onboarding-state.js +1 -1
- package/modules/platform-identity/CLAUDE.md +15 -0
- package/modules/platform-identity/hub-device.js +147 -0
- package/modules/platform-identity/hub-keys.js +151 -0
- package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
- package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
- package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
- package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
- package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
- package/modules/platform-identity/module.json +18 -0
- package/modules/platform-identity/platform-identity.js +531 -0
- package/modules/platform-identity/pollers/code-sweep.js +43 -0
- package/modules/platform-identity/routes/my-projects.js +140 -0
- package/modules/platform-identity/routes/projects.js +107 -0
- package/modules/platform-identity/routes/scouting.js +35 -0
- package/modules/platform-identity/routes/sso.js +402 -0
- package/modules/platform-identity/tests/platform-identity.mjs +388 -0
- package/modules/platform-identity/ui/.gitkeep +0 -0
- package/modules/security/routes/reports.js +10 -0
- package/modules/sessions/db.js +166 -21
- package/modules/sessions/module.json +1 -1
- package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
- package/modules/sessions/routes/sessions.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/anim/idle/frame_00.webp +0 -0
- package/public-landing/assets/anim/idle/frame_01.webp +0 -0
- package/public-landing/assets/anim/idle/frame_02.webp +0 -0
- package/public-landing/assets/anim/idle/frame_03.webp +0 -0
- package/public-landing/assets/anim/idle/frame_04.webp +0 -0
- package/public-landing/assets/anim/idle/frame_05.webp +0 -0
- package/public-landing/assets/anim/idle/frame_06.webp +0 -0
- package/public-landing/assets/anim/idle/frame_07.webp +0 -0
- package/public-landing/assets/anim/idle/frame_08.webp +0 -0
- package/public-landing/assets/anim/idle/frame_09.webp +0 -0
- package/public-landing/assets/anim/idle/frame_10.webp +0 -0
- package/public-landing/assets/anim/idle/frame_11.webp +0 -0
- package/public-landing/assets/anim/idle/frame_12.webp +0 -0
- package/public-landing/assets/anim/idle/frame_13.webp +0 -0
- package/public-landing/assets/anim/idle/frame_14.webp +0 -0
- package/public-landing/assets/anim/idle/frame_15.webp +0 -0
- package/public-landing/assets/anim/idle/frame_16.webp +0 -0
- package/public-landing/assets/anim/idle/frame_17.webp +0 -0
- package/public-landing/assets/anim/idle/frame_18.webp +0 -0
- package/public-landing/assets/anim/idle/frame_19.webp +0 -0
- package/public-landing/assets/anim/idle/frame_20.webp +0 -0
- package/public-landing/assets/anim/idle/frame_21.webp +0 -0
- package/public-landing/assets/anim/idle/frame_22.webp +0 -0
- package/public-landing/assets/anim/idle/frame_23.webp +0 -0
- package/public-landing/assets/anim/idle/frame_24.webp +0 -0
- package/public-landing/assets/anim/idle/frame_25.webp +0 -0
- package/public-landing/assets/anim/idle/frame_26.webp +0 -0
- package/public-landing/assets/anim/idle/frame_27.webp +0 -0
- package/public-landing/assets/anim/idle/frame_28.webp +0 -0
- package/public-landing/assets/anim/idle/frame_29.webp +0 -0
- package/public-landing/assets/anim/idle/frame_30.webp +0 -0
- package/public-landing/assets/anim/idle/frame_31.webp +0 -0
- package/public-landing/assets/anim/idle/frame_32.webp +0 -0
- package/public-landing/assets/anim/idle/frame_33.webp +0 -0
- package/public-landing/assets/anim/idle/frame_34.webp +0 -0
- package/public-landing/assets/anim/idle/frame_35.webp +0 -0
- package/public-landing/assets/anim/idle/frame_36.webp +0 -0
- package/public-landing/assets/anim/idle/frame_37.webp +0 -0
- package/public-landing/assets/anim/idle/frame_38.webp +0 -0
- package/public-landing/assets/anim/idle/frame_39.webp +0 -0
- package/public-landing/assets/anim/idle/frame_40.webp +0 -0
- package/public-landing/assets/anim/idle/frame_41.webp +0 -0
- package/public-landing/assets/anim/idle/frame_42.webp +0 -0
- package/public-landing/assets/anim/idle/frame_43.webp +0 -0
- package/public-landing/create.html +51 -58
- package/public-landing/gate/README.md +137 -0
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +587 -0
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +799 -0
- package/public-landing/gate/space.js +1826 -0
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
- package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
- package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
- package/public-landing/world/assets/plates/scene-establishing.png +0 -0
- package/scripts/gds/abandon.js +6 -4
- package/scripts/gds/api.js +10 -5
- package/scripts/gds/api.ps1 +26 -0
- package/scripts/gds/api.sh +6 -0
- package/scripts/gds/audit-ships.js +7 -1
- package/scripts/gds/backfill-sessions.js +78 -18
- package/scripts/gds/backup.js +23 -1
- package/scripts/gds/box-connect-lib.js +124 -31
- package/scripts/gds/box-sync.js +66 -0
- package/scripts/gds/box.js +225 -53
- package/scripts/gds/cf-tunnel.js +145 -6
- package/scripts/gds/claim.js +34 -3
- package/scripts/gds/cli-lib.js +161 -27
- package/scripts/gds/code.js +6 -2
- package/scripts/gds/deploy-divergence.js +209 -0
- package/scripts/gds/dev-lib.js +309 -0
- package/scripts/gds/dev.js +322 -0
- package/scripts/gds/doctor.js +90 -61
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/fetch-sound-prefs.js +2 -2
- package/scripts/gds/fitness.js +228 -12
- package/scripts/gds/gds-db-backup.service +16 -1
- package/scripts/gds/gds-db-offsite.service +9 -1
- package/scripts/gds/gen-api-client.js +1 -1
- package/scripts/gds/gen-api-docs.js +36 -14
- package/scripts/gds/gen-diagrams.js +45 -28
- package/scripts/gds/gen-file-map.js +2 -1
- package/scripts/gds/gen-freshness.js +32 -0
- package/scripts/gds/gen-repo-map.js +3 -2
- package/scripts/gds/gen-session-index.js +14 -5
- package/scripts/gds/hooks/pre-push.js +2 -2
- package/scripts/gds/init.js +60 -3
- package/scripts/gds/install-git-hooks.js +1 -1
- package/scripts/gds/key-card.js +15 -6
- package/scripts/gds/key-status.js +1 -1
- package/scripts/gds/linkify-refs.js +35 -9
- package/scripts/gds/login.js +16 -1
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +123 -12
- package/scripts/gds/publish-manifest.js +7 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +4 -2
- package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
- package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
- package/scripts/gds/sequence.js +425 -0
- package/scripts/gds/session-cost.js +10 -1
- package/scripts/gds/setup.js +31 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +319 -29
- package/scripts/gds/smoke-gds.js +139 -8
- package/scripts/gds/start.js +171 -22
- package/scripts/gds/status.js +1 -1
- package/scripts/gds/triage.js +10 -9
- package/scripts/gds/upgrade.js +124 -2
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +447 -102
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +39 -0
- package/src/bongos/module-scope-map.js +27 -1
- package/src/bongos/permission-path-check.js +136 -102
- package/src/bongos/repo-info.js +47 -11
- package/src/bongos/route-rank-check.js +429 -46
- package/src/bongos/routes/_helpers.js +12 -1
- package/src/bongos/routes/auth.js +219 -2
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +32 -0
- package/src/instance-config.js +30 -0
- package/src/module-api.js +112 -1
- package/src/platform-server.js +86 -9
- package/tests/abandon_task.mjs +59 -3
- package/tests/api_cli_flags.mjs +53 -0
- package/tests/auth_page_gate.mjs +13 -0
- package/tests/backfill_sessions.mjs +127 -1
- package/tests/backup_cli.mjs +54 -0
- package/tests/bongos_box_connect.mjs +77 -0
- package/tests/box_access.mjs +130 -1
- package/tests/box_credential.mjs +125 -0
- package/tests/box_host_keys.mjs +24 -0
- package/tests/box_infra_resolve.mjs +149 -0
- package/tests/box_scope_session.mjs +6 -2
- package/tests/box_sync.mjs +55 -0
- package/tests/box_task_scope.mjs +99 -0
- package/tests/branding.mjs +12 -0
- package/tests/cf_ruleset_preflight.mjs +142 -0
- package/tests/cf_tunnel.mjs +37 -0
- package/tests/claim_gate_rebase.mjs +98 -0
- package/tests/clawback_confirmed_backlog.mjs +103 -0
- package/tests/cli_lib_formatters.mjs +20 -0
- package/tests/confirm_attribution.mjs +30 -46
- package/tests/credit_grant.mjs +198 -10
- package/tests/criterion_authoring_gate.mjs +272 -0
- package/tests/criterion_progress.mjs +33 -1
- package/tests/dev_lib.mjs +253 -0
- package/tests/devbox_artifact_gate.mjs +75 -0
- package/tests/devbox_dmg_backdrop.mjs +137 -0
- package/tests/devbox_icons.mjs +304 -0
- package/tests/devbox_installer_brand.mjs +232 -0
- package/tests/devbox_key_register.mjs +234 -0
- package/tests/docs_openapi_route.mjs +110 -0
- package/tests/doctor_hooks.mjs +141 -0
- package/tests/extract_json.mjs +140 -0
- package/tests/fitness.mjs +89 -0
- package/tests/gen_freshness.mjs +96 -0
- package/tests/goal_advisory.mjs +251 -0
- package/tests/goal_criterion_confirm_ui.mjs +269 -0
- package/tests/goal_routes.mjs +59 -4
- package/tests/goal_tier.mjs +12 -3
- package/tests/governance_catalog.mjs +129 -0
- package/tests/governance_claim_eligibility.mjs +262 -0
- package/tests/governance_config.mjs +187 -0
- package/tests/governance_ownership_scope.mjs +410 -0
- package/tests/governance_page_gates.mjs +324 -0
- package/tests/governance_parity.mjs +336 -0
- package/tests/governance_principals.mjs +232 -0
- package/tests/governance_protected_surfaces.mjs +208 -0
- package/tests/governance_require_permission.mjs +223 -0
- package/tests/governance_resolver.mjs +84 -0
- package/tests/governance_role_sync.mjs +231 -0
- package/tests/governance_seed.mjs +127 -0
- package/tests/governance_session_scope.mjs +434 -0
- package/tests/governance_vanilla_boot.mjs +203 -0
- package/tests/grade_attribution.mjs +7 -7
- package/tests/hall_audit.mjs +223 -0
- package/tests/hall_nav.mjs +191 -0
- package/tests/hall_palette.mjs +206 -0
- package/tests/hall_signout.mjs +124 -0
- package/tests/hook_user_agents.mjs +10 -0
- package/tests/idea_triage.mjs +162 -0
- package/tests/idp_device_exchange.mjs +93 -0
- package/tests/init.mjs +51 -0
- package/tests/instance_config.mjs +65 -0
- package/tests/instance_mismatch_guard.mjs +23 -6
- package/tests/instance_release_config.mjs +104 -0
- package/tests/linkify_refs.mjs +15 -2
- package/tests/module_contributions.mjs +55 -0
- package/tests/module_loader.mjs +10 -5
- package/tests/module_scope_active_claims.mjs +94 -0
- package/tests/needs_rebase.mjs +92 -0
- package/tests/one_active_claim_per_session.mjs +76 -0
- package/tests/override_attribution.mjs +10 -8
- package/tests/override_request_ownership.mjs +8 -4
- package/tests/parse_cookie_decode.mjs +42 -0
- package/tests/pay_on_land.mjs +129 -0
- package/tests/platform_boot.mjs +100 -1
- package/tests/post_login_landing.mjs +42 -0
- package/tests/preflight_checks.mjs +72 -0
- package/tests/provision.mjs +132 -7
- package/tests/publish_branch_route.mjs +53 -0
- package/tests/publish_reconciler.mjs +187 -2
- package/tests/repo_info_binding.mjs +248 -0
- package/tests/reward_gate.mjs +11 -2
- package/tests/reward_mode_cost_plus_only.mjs +116 -0
- package/tests/reward_mode_surfacing.mjs +168 -0
- package/tests/route_permission_check.mjs +301 -0
- package/tests/route_rank_check.mjs +254 -12
- package/tests/route_validation.mjs +30 -0
- package/tests/sequence.mjs +267 -0
- package/tests/session_digest.mjs +44 -0
- package/tests/session_index.mjs +18 -6
- package/tests/session_records.mjs +39 -1
- package/tests/session_reward_on_land.mjs +381 -0
- package/tests/session_reward_reconcile.mjs +229 -0
- package/tests/ship_api_client_pathspec.mjs +100 -0
- package/tests/ship_base_flag.mjs +93 -0
- package/tests/ship_ci_deploy.mjs +80 -3
- package/tests/ship_upstream_deletion.mjs +57 -0
- package/tests/smoke_target.mjs +233 -0
- package/tests/start_env_guard.mjs +87 -0
- package/tests/start_rebase_warning.mjs +103 -0
- package/tests/status_mirror_sync.mjs +122 -0
- package/tests/stop_refcheck.mjs +19 -1
- package/tests/task_goal_link.mjs +15 -6
- package/tests/upgrade.mjs +139 -0
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// tests/governance_page_gates.mjs — the three HTML PAGE GATES + the wandering
|
|
2
|
+
// CEILING, re-expressed on the governance model (BV1.R107, ADR 0151).
|
|
3
|
+
//
|
|
4
|
+
// DB-free. Two doubles, both installed the way the existing suites do it:
|
|
5
|
+
// • db-kernel.lookupSession is stubbed BEFORE auth.js is required, because
|
|
6
|
+
// auth.js destructures it at load time (tests/auth_page_gate.mjs precedent).
|
|
7
|
+
// • the `governance` KERNEL PORT is a seam stub, so the gates' authority half
|
|
8
|
+
// needs no Postgres, no router, no live module
|
|
9
|
+
// (tests/governance_require_permission.mjs precedent).
|
|
10
|
+
//
|
|
11
|
+
// WHAT THIS FILE HAS TO PROVE, and why the oracles are split.
|
|
12
|
+
// A page gate that resolves its audience from catalog.floor cannot be checked
|
|
13
|
+
// against catalog.floor — a wrong floor would move both sides together and the
|
|
14
|
+
// test would pass while proving nothing. So the expected audience below is a
|
|
15
|
+
// HAND-WRITTEN LITERAL of the PRE-R107 behaviour (read off the deleted rank
|
|
16
|
+
// tests: "any session" / "rank !== 'xenos'" / "rank === 'archon'"), and it is
|
|
17
|
+
// independently cross-checked against auth.rankMeetsThreshold — the real
|
|
18
|
+
// production threshold function, which knows nothing about the catalog.
|
|
19
|
+
// The permission SEED fed to the stub is the real catalog seed, i.e. the rows a
|
|
20
|
+
// builder actually holds; that is the INPUT, not the oracle, so a floor edit
|
|
21
|
+
// reds this file instead of hiding in it.
|
|
22
|
+
//
|
|
23
|
+
// The second property is shape, not audience: a page gate must REDIRECT. These
|
|
24
|
+
// are page navigations and asset loads — a JSON 403 renders as a blank error
|
|
25
|
+
// body instead of sending the visitor somewhere they can act. res.fail() is
|
|
26
|
+
// installed on the response double precisely so a regression to JSON shows up
|
|
27
|
+
// as an assertion rather than a TypeError.
|
|
28
|
+
//
|
|
29
|
+
// Run: node --test tests/governance_page_gates.mjs
|
|
30
|
+
|
|
31
|
+
import assert from 'node:assert/strict';
|
|
32
|
+
import { test } from 'node:test';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
|
|
37
|
+
// --- the session double, installed before auth.js loads ---------------------
|
|
38
|
+
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
39
|
+
let nextSession = null;
|
|
40
|
+
let lookupThrows = false;
|
|
41
|
+
dbKernel.lookupSession = async () => {
|
|
42
|
+
if (lookupThrows) throw new Error('simulated db outage');
|
|
43
|
+
return nextSession;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const auth = require('../src/bongos/auth.js');
|
|
47
|
+
const seams = require('../src/module-seams.js');
|
|
48
|
+
const catalog = require('../modules/governance/catalog.js');
|
|
49
|
+
const resolver = require('../modules/governance/resolver.js');
|
|
50
|
+
const wandering = require('../modules/builder-settings/wandering-prefs.js');
|
|
51
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
52
|
+
|
|
53
|
+
const GATES = ['requireBuilderPage', 'requireNonXenosPage', 'requireArchonPage'];
|
|
54
|
+
const LIVE_RANKS = ['xenos', 'thetes', 'metic', 'archon'];
|
|
55
|
+
|
|
56
|
+
// ═══ the ORACLE — hand-written, from the rank tests R107 deleted ═════════════
|
|
57
|
+
// requireBuilderPage admitted any session at all → every live rank
|
|
58
|
+
// requireNonXenosPage admitted rank !== 'xenos' → thetes and up
|
|
59
|
+
// requireArchonPage admitted rank === 'archon' → archon only
|
|
60
|
+
const PRE_R107_AUDIENCE = {
|
|
61
|
+
requireBuilderPage: { xenos: true, thetes: true, metic: true, archon: true },
|
|
62
|
+
requireNonXenosPage: { xenos: false, thetes: true, metic: true, archon: true },
|
|
63
|
+
requireArchonPage: { xenos: false, thetes: false, metic: false, archon: true },
|
|
64
|
+
};
|
|
65
|
+
// Where a SIGNED-IN builder who fails the gate is sent. Never the sign-in flow —
|
|
66
|
+
// they already have a session, so that would loop.
|
|
67
|
+
const DENIED_TO = {
|
|
68
|
+
requireBuilderPage: '/builders',
|
|
69
|
+
requireNonXenosPage: '/builders/not-ready',
|
|
70
|
+
requireArchonPage: '/builders',
|
|
71
|
+
};
|
|
72
|
+
// The equivalent rank floor, for the independent cross-check below.
|
|
73
|
+
const GATE_FLOOR = {
|
|
74
|
+
requireBuilderPage: 'xenos',
|
|
75
|
+
requireNonXenosPage: 'thetes',
|
|
76
|
+
requireArchonPage: 'archon',
|
|
77
|
+
};
|
|
78
|
+
const SIGN_IN = '/api/gds/auth/web/start';
|
|
79
|
+
|
|
80
|
+
// --- doubles ----------------------------------------------------------------
|
|
81
|
+
function fakeRes() {
|
|
82
|
+
const res = {
|
|
83
|
+
_status: null,
|
|
84
|
+
_json: null,
|
|
85
|
+
_redirect: null,
|
|
86
|
+
status(c) { this._status = c; return this; },
|
|
87
|
+
json(body) { this._json = body; return this; },
|
|
88
|
+
redirect(a, b) {
|
|
89
|
+
this._redirect = b === undefined ? { code: 302, loc: a } : { code: a, loc: b };
|
|
90
|
+
return this;
|
|
91
|
+
},
|
|
92
|
+
end() { return this; },
|
|
93
|
+
};
|
|
94
|
+
// Installed so a regression that answers JSON instead of redirecting is caught
|
|
95
|
+
// by an assertion rather than blowing up on an undefined method.
|
|
96
|
+
attachFail({}, res, () => {});
|
|
97
|
+
return res;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Run one page gate. `rank` null = no session. `resolve` overrides the stubbed
|
|
101
|
+
// port (pass null to leave the port UNREGISTERED).
|
|
102
|
+
async function runGate(name, { rank = null, resolve, port = true } = {}) {
|
|
103
|
+
nextSession = rank === null ? null : { builder_id: 7, rank };
|
|
104
|
+
lookupThrows = false;
|
|
105
|
+
seams._reset();
|
|
106
|
+
if (port) {
|
|
107
|
+
seams.registerProvider('governance', {
|
|
108
|
+
// The real seed for that rank — the rows governance_002 gives the one
|
|
109
|
+
// rank-role a builder holds. The INPUT, not the oracle.
|
|
110
|
+
resolveBuilderPermissions: resolve || (async () => catalog.seedForRank(rank)),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const req = { headers: rank === null ? {} : { cookie: 'gds_session=validtoken' } };
|
|
114
|
+
const res = fakeRes();
|
|
115
|
+
let nexted = false;
|
|
116
|
+
await auth[name](req, res, () => { nexted = true; });
|
|
117
|
+
seams._reset();
|
|
118
|
+
return { res, nexted };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ═══ PART A — the audience is unchanged, rank by rank ════════════════════════
|
|
122
|
+
|
|
123
|
+
test('A0: the hand-written audience table agrees with the real threshold function', () => {
|
|
124
|
+
// A second, catalog-free oracle for the literal above: auth.rankMeetsThreshold
|
|
125
|
+
// is the production rank predicate and reads nothing from modules/governance/.
|
|
126
|
+
for (const gate of GATES) {
|
|
127
|
+
for (const rank of LIVE_RANKS) {
|
|
128
|
+
assert.equal(
|
|
129
|
+
PRE_R107_AUDIENCE[gate][rank],
|
|
130
|
+
auth.rankMeetsThreshold(rank, [GATE_FLOOR[gate]]),
|
|
131
|
+
`${gate}/${rank}: the literal audience must match rankMeetsThreshold at floor ${GATE_FLOOR[gate]}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('A1: each page gate admits exactly the ranks it admitted before R107', async () => {
|
|
138
|
+
const drift = [];
|
|
139
|
+
for (const gate of GATES) {
|
|
140
|
+
for (const rank of LIVE_RANKS) {
|
|
141
|
+
const { nexted } = await runGate(gate, { rank });
|
|
142
|
+
const expected = PRE_R107_AUDIENCE[gate][rank];
|
|
143
|
+
if (nexted !== expected) {
|
|
144
|
+
drift.push(` ${gate} / ${rank}: was ${expected ? 'ADMIT' : 'deny'}, now ${nexted ? 'ADMIT' : 'deny'}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
assert.equal(drift.length, 0, `a page gate's audience changed:\n${drift.join('\n')}`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('A2: a denied SIGNED-IN builder is redirected to an ungated page, not JSON-403ed', async () => {
|
|
152
|
+
for (const gate of GATES) {
|
|
153
|
+
for (const rank of LIVE_RANKS) {
|
|
154
|
+
if (PRE_R107_AUDIENCE[gate][rank]) continue;
|
|
155
|
+
const { res, nexted } = await runGate(gate, { rank });
|
|
156
|
+
assert.equal(nexted, false, `${gate}/${rank} must not admit`);
|
|
157
|
+
assert.deepEqual(res._redirect, { code: 302, loc: DENIED_TO[gate] },
|
|
158
|
+
`${gate}/${rank} must 302 to ${DENIED_TO[gate]}`);
|
|
159
|
+
assert.equal(res._status, null, `${gate}/${rank} must not set an HTTP error status`);
|
|
160
|
+
assert.equal(res._json, null, `${gate}/${rank} must not answer a JSON body`);
|
|
161
|
+
assert.notEqual(res._redirect.loc, SIGN_IN,
|
|
162
|
+
`${gate}/${rank}: a signed-in builder must never be bounced to sign-in (it loops)`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('A3: an UNAUTHENTICATED request redirects into sign-in — never a JSON 401/403', async () => {
|
|
168
|
+
for (const gate of GATES) {
|
|
169
|
+
const { res, nexted } = await runGate(gate, { rank: null });
|
|
170
|
+
assert.equal(nexted, false, `${gate} must not admit an unauthenticated request`);
|
|
171
|
+
assert.equal(res._redirect.code, 302, `${gate} must 302`);
|
|
172
|
+
assert.equal(res._redirect.loc, SIGN_IN, `${gate} must send an anonymous visitor to sign-in`);
|
|
173
|
+
assert.equal(res._status, null, `${gate} must not set an HTTP error status`);
|
|
174
|
+
assert.equal(res._json, null, `${gate} must not answer a JSON body`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ═══ PART B — fail-closed, in every direction (ADR 0016) ═════════════════════
|
|
179
|
+
|
|
180
|
+
test('B1: a session-lookup error fails CLOSED (redirect to sign-in, no page)', async () => {
|
|
181
|
+
for (const gate of GATES) {
|
|
182
|
+
nextSession = null;
|
|
183
|
+
seams._reset();
|
|
184
|
+
seams.registerProvider('governance', { resolveBuilderPermissions: async () => catalog.seedForRank('archon') });
|
|
185
|
+
lookupThrows = true;
|
|
186
|
+
const res = fakeRes();
|
|
187
|
+
let nexted = false;
|
|
188
|
+
await auth[gate]({ headers: { cookie: 'gds_session=validtoken' } }, res, () => { nexted = true; });
|
|
189
|
+
lookupThrows = false;
|
|
190
|
+
seams._reset();
|
|
191
|
+
assert.equal(nexted, false, `${gate} must not serve the page when the lookup errors`);
|
|
192
|
+
assert.equal(res._redirect.loc, SIGN_IN, `${gate} must redirect to sign-in on a lookup error`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('B2: an UNREGISTERED governance port denies every gate, it does not admit', async () => {
|
|
197
|
+
// Same deliberate call requirePermission makes: no port = authority cannot be
|
|
198
|
+
// established, and admitting there would make disabling a module an escalation.
|
|
199
|
+
for (const gate of GATES) {
|
|
200
|
+
const { res, nexted } = await runGate(gate, { rank: 'archon', port: false });
|
|
201
|
+
assert.equal(nexted, false, `${gate}: a missing authority system must never open a page gate`);
|
|
202
|
+
assert.equal(res._redirect.loc, DENIED_TO[gate]);
|
|
203
|
+
assert.equal(res._json, null);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('B3: a throwing resolver denies (a DB blip must not open the page)', async () => {
|
|
208
|
+
for (const gate of GATES) {
|
|
209
|
+
const { res, nexted } = await runGate(gate, {
|
|
210
|
+
rank: 'archon',
|
|
211
|
+
resolve: async () => { throw new Error('connection terminated'); },
|
|
212
|
+
});
|
|
213
|
+
assert.equal(nexted, false, `${gate} must fail CLOSED on a resolve failure`);
|
|
214
|
+
assert.equal(res._redirect.loc, DENIED_TO[gate]);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('B4: the page gates resolve per request — no memoised authority (ADR 0016)', async () => {
|
|
219
|
+
let calls = 0;
|
|
220
|
+
let keys = catalog.seedForRank('archon');
|
|
221
|
+
nextSession = { builder_id: 7, rank: 'archon' };
|
|
222
|
+
lookupThrows = false;
|
|
223
|
+
seams._reset();
|
|
224
|
+
seams.registerProvider('governance', {
|
|
225
|
+
resolveBuilderPermissions: async () => { calls += 1; return keys; },
|
|
226
|
+
});
|
|
227
|
+
const req = { headers: { cookie: 'gds_session=validtoken' } };
|
|
228
|
+
let admitted = false;
|
|
229
|
+
await auth.requireArchonPage(req, fakeRes(), () => { admitted = true; });
|
|
230
|
+
assert.equal(admitted, true, 'an Archon should reach the page');
|
|
231
|
+
keys = []; // the role is revoked between requests
|
|
232
|
+
const second = fakeRes();
|
|
233
|
+
let admittedAgain = false;
|
|
234
|
+
await auth.requireArchonPage(req, second, () => { admittedAgain = true; });
|
|
235
|
+
seams._reset();
|
|
236
|
+
assert.equal(calls, 2, 'the resolver must be consulted on every request');
|
|
237
|
+
assert.equal(admittedAgain, false, 'a revoked role must deny on the very next request');
|
|
238
|
+
assert.equal(second._redirect.loc, '/builders');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('B5: every permission a page gate names is a real catalog key', async () => {
|
|
242
|
+
// Not an oracle — a sanity check that the gates did not migrate onto a typo,
|
|
243
|
+
// which would silently deny everyone (fail-closed, but a bug all the same).
|
|
244
|
+
for (const key of ['page.view.public', 'page.view.builder', 'page.view.governance']) {
|
|
245
|
+
assert.ok(catalog.byKey(key), `${key} must exist in the catalog`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ═══ PART C — the wandering CEILING (role metadata, not a permission) ════════
|
|
250
|
+
// The ceiling is a NUMBER, so it is carried as role metadata rather than as one
|
|
251
|
+
// boolean atom per level. This part pins the values against a hand-written table
|
|
252
|
+
// and proves the clamp is enforcement, not UI copy.
|
|
253
|
+
|
|
254
|
+
const CEILING_TABLE = {
|
|
255
|
+
xenos: { default: 0, ceiling: 0 },
|
|
256
|
+
thetes: { default: 1, ceiling: 1 },
|
|
257
|
+
metic: { default: 2, ceiling: 3 },
|
|
258
|
+
archon: { default: 3, ceiling: 3 },
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
test('C1: the per-role wandering default + ceiling are unchanged', () => {
|
|
262
|
+
for (const [role, expected] of Object.entries(CEILING_TABLE)) {
|
|
263
|
+
assert.equal(wandering.defaultLevelForRank(role), expected.default, `${role} default`);
|
|
264
|
+
assert.equal(wandering.maxLevelForRank(role), expected.ceiling, `${role} ceiling`);
|
|
265
|
+
assert.deepEqual(
|
|
266
|
+
{ default: wandering.roleWandering(role).default, ceiling: wandering.roleWandering(role).ceiling },
|
|
267
|
+
expected,
|
|
268
|
+
`${role} role metadata`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('C2: a role carrying no wandering metadata falls to the Locked floor', () => {
|
|
274
|
+
for (const unknown of ['helot', 'olympian', 'nobody', undefined, null, '']) {
|
|
275
|
+
assert.equal(wandering.defaultLevelForRank(unknown), 0, `default for ${String(unknown)}`);
|
|
276
|
+
assert.equal(wandering.maxLevelForRank(unknown), 0, `ceiling for ${String(unknown)}`);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('C3: a builder cannot raise their OWN level above their role ceiling', () => {
|
|
281
|
+
// The clamp is the enforcement point: a stored override above the ceiling
|
|
282
|
+
// resolves DOWN, so opening the setting to self-service stays safe.
|
|
283
|
+
for (const [role, expected] of Object.entries(CEILING_TABLE)) {
|
|
284
|
+
for (let want = 0; want <= 3; want += 1) {
|
|
285
|
+
assert.equal(
|
|
286
|
+
wandering.effectiveLevel({ wandering: want }, role),
|
|
287
|
+
Math.min(want, expected.ceiling),
|
|
288
|
+
`${role} storing ${want} must resolve to at most ${expected.ceiling}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// And the write path refuses rather than silently clamping.
|
|
293
|
+
const capped = wandering.validatePatch({ wandering: 3 }, { maxLevel: wandering.maxLevelForRank('thetes') });
|
|
294
|
+
assert.equal(capped.ok, false, 'a Thetes asking for Exploratory must be refused');
|
|
295
|
+
assert.equal(capped.errors[0].error, 'above_rank_cap');
|
|
296
|
+
assert.equal(capped.errors[0].max, 1);
|
|
297
|
+
assert.equal(
|
|
298
|
+
wandering.validatePatch({ wandering: 1 }, { maxLevel: wandering.maxLevelForRank('thetes') }).ok,
|
|
299
|
+
true,
|
|
300
|
+
'their own ceiling is still writable',
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('C4: the ceiling is NOT answerable through the boolean permission axis', () => {
|
|
305
|
+
// me.wandering.set is ownership-scoped (R100), so "do you hold it" has no
|
|
306
|
+
// unconditional answer and builderHasPermissions REFUSES it. That refusal is
|
|
307
|
+
// exactly why the numeric ceiling had to be role metadata: there is no boolean
|
|
308
|
+
// question whose answer is "3".
|
|
309
|
+
const perm = catalog.byKey('me.wandering.set');
|
|
310
|
+
assert.equal(perm.scope, 'own', 'me.wandering.set is ownership-scoped');
|
|
311
|
+
assert.equal(perm.resource, 'builder_prefs');
|
|
312
|
+
assert.equal(catalog.isOwnershipScoped('me.wandering.set'), true);
|
|
313
|
+
return resolver.builderHasPermissions(7, ['me.wandering.set']).then((answer) => {
|
|
314
|
+
assert.equal(answer, false, 'an ownership-scoped key must be refused by the unconditional resolve');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('C5: the derived rank projections cannot drift from the role metadata', () => {
|
|
319
|
+
for (const [role, meta] of Object.entries(wandering.ROLE_WANDERING)) {
|
|
320
|
+
assert.equal(wandering.RANK_DEFAULT[role], meta.default, `RANK_DEFAULT.${role}`);
|
|
321
|
+
assert.equal(wandering.RANK_MAX[role], meta.ceiling, `RANK_MAX.${role}`);
|
|
322
|
+
assert.ok(meta.default <= meta.ceiling, `${role}: default must not exceed the ceiling`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// tests/governance_parity.mjs — the compat-seed PARITY proof (BV1.R103, ADR 0151).
|
|
2
|
+
//
|
|
3
|
+
// The promise the whole governance cutover rests on: once governance_002 seeds the
|
|
4
|
+
// four rank-roles and backfills every builder the role matching their rank, each
|
|
5
|
+
// builder holds EXACTLY the powers requireRank gave them — no more, no less.
|
|
6
|
+
//
|
|
7
|
+
// "Today's exact powers" has TWO independent halves, and this file proves each
|
|
8
|
+
// against a DIFFERENT oracle. That split is the point — a check whose two sides
|
|
9
|
+
// both read catalog.floor cannot fail, because a wrong floor moves both together.
|
|
10
|
+
//
|
|
11
|
+
// PART A — the DERIVATION. Does seedForRank's own tier arithmetic reproduce the
|
|
12
|
+
// REAL production gate, auth.rankMeetsThreshold, over the REAL 16-rank ladder
|
|
13
|
+
// (db-kernel RANKS)? Oracle: auth.js. Catches a divergence between catalog.js's
|
|
14
|
+
// private 4-entry RANK_ORDER and the ladder the live system actually enforces,
|
|
15
|
+
// a flipped comparison, or a seed that stops being a threshold predicate.
|
|
16
|
+
// Does NOT catch a wrong floor VALUE (both sides read it) — that is Part B.
|
|
17
|
+
// PART B — the FLOOR VALUES. Is each permission's floor consistent with the rank
|
|
18
|
+
// gate its route actually carries? Oracle: the live route SOURCE, read through
|
|
19
|
+
// route-rank-check.js (the grader's own classifier). This is the half that goes
|
|
20
|
+
// red if someone quietly lowers a floor. BV1.R109 taught that classifier to read
|
|
21
|
+
// `requirePermission` gates too — and it resolves those THROUGH catalog.floor, so
|
|
22
|
+
// a migrated route can no longer serve as B1's independent oracle. Migrated routes
|
|
23
|
+
// move to B3 (which atom does the route name?) and B2's census spans both, so the
|
|
24
|
+
// R104-R107 migration shifts coverage between oracles instead of erasing it.
|
|
25
|
+
// PART C — the two REAL middlewares, requirePermission vs requireRank, over the
|
|
26
|
+
// whole catalog rather than the four hand-picked keys in
|
|
27
|
+
// tests/governance_require_permission.mjs.
|
|
28
|
+
// PART D — the deliberate fail-closed divergence governance_002 documents.
|
|
29
|
+
//
|
|
30
|
+
// A third oracle already exists and is NOT duplicated here: tests/governance_seed.mjs
|
|
31
|
+
// pins the catalog against the materialised migration SQL, so a floor edit that
|
|
32
|
+
// reaches no new migration reds there.
|
|
33
|
+
//
|
|
34
|
+
// SCOPE — read before trusting this file.
|
|
35
|
+
// PROVEN (DB-free, so it always runs in the unit gate): the STRUCTURAL parity.
|
|
36
|
+
// governance_002's backfill joins builders.rank → governance_roles.role_key, so
|
|
37
|
+
// every live builder holds exactly one rank-role and "what builder X resolves
|
|
38
|
+
// to" is fully determined by X's rank. Rank-by-rank parity is therefore the
|
|
39
|
+
// whole of "no more, no less" — enumerating rows would re-assert it per builder.
|
|
40
|
+
// NOT PROVEN here: that the LIVE roster contains no builder sitting on a rank
|
|
41
|
+
// OUTSIDE the four seeded ones. builders.rank's CHECK (migrations/023) still
|
|
42
|
+
// admits twelve legacy/divine ranks; such a builder resolves to ZERO permissions
|
|
43
|
+
// (asserted in Part D as the deliberate fail-closed divergence) while requireRank
|
|
44
|
+
// still admits the divine ones. Confirming the live roster needs Postgres and is
|
|
45
|
+
// left to R114's live demo.
|
|
46
|
+
import assert from 'node:assert/strict';
|
|
47
|
+
import { test } from 'node:test';
|
|
48
|
+
import { createRequire } from 'node:module';
|
|
49
|
+
import fs from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import { fileURLToPath } from 'node:url';
|
|
52
|
+
|
|
53
|
+
const require = createRequire(import.meta.url);
|
|
54
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
55
|
+
const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
|
|
56
|
+
const { RANKS } = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
|
|
57
|
+
const routeRank = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
58
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
59
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
60
|
+
const catalog = require(path.join(ROOT, 'modules', 'governance', 'catalog.js'));
|
|
61
|
+
const resolver = require(path.join(ROOT, 'modules', 'governance', 'resolver.js'));
|
|
62
|
+
|
|
63
|
+
const { PERMISSIONS, RANK_ORDER, seedForRank } = catalog;
|
|
64
|
+
const FLOORED = PERMISSIONS.filter((p) => p.floor !== null);
|
|
65
|
+
const PRINCIPALS = PERMISSIONS.filter((p) => p.floor === null);
|
|
66
|
+
// Every rank builders.rank may legally hold, minus the four that get a seeded role.
|
|
67
|
+
const UNSEEDED_RANKS = RANKS.filter((r) => !RANK_ORDER.includes(r));
|
|
68
|
+
|
|
69
|
+
// What a builder of `rank` actually resolves to today: the REAL resolver's pure
|
|
70
|
+
// core over the REAL seed — i.e. exactly the grant rows governance_002 gives the
|
|
71
|
+
// one rank-role that builder's backfill assigns them.
|
|
72
|
+
const resolvedFor = (rank) => resolver.effectivePermissions(seedForRank(rank));
|
|
73
|
+
|
|
74
|
+
// What today's rank gates admit for `rank`: a permission's `floor` IS the floor its
|
|
75
|
+
// route spells, so the live verdict is rankMeetsThreshold(rank, [floor]).
|
|
76
|
+
const admits = (rank, floor) => auth.rankMeetsThreshold(rank, [floor]);
|
|
77
|
+
|
|
78
|
+
// Failure messages must name the drifting key + rank — a parity test that fails with
|
|
79
|
+
// deepEqual on two 71-element arrays is useless when you are trying to ship.
|
|
80
|
+
const nameDrift = (rows) => rows.map(({ rank, key, floor, held, allowed }) =>
|
|
81
|
+
` ${rank.padEnd(9)} ${key} (floor ${floor}): seed ${held ? 'GRANTS' : 'withholds'} it, requireRank ${allowed ? 'ADMITS' : 'denies'}`,
|
|
82
|
+
).join('\n');
|
|
83
|
+
|
|
84
|
+
// ═══ PART A — the derivation, against auth.rankMeetsThreshold ═════════════════
|
|
85
|
+
// Both sides read p.floor, so these prove the DERIVATION is faithful to the live
|
|
86
|
+
// threshold function, not that any individual floor is the right value (Part B).
|
|
87
|
+
|
|
88
|
+
// The security direction: a seed that grants where the rank gate denies is an
|
|
89
|
+
// escalation, so this runs over every rank builders.rank can legally hold.
|
|
90
|
+
test('A1: no rank resolves to a permission the real threshold function would DENY', () => {
|
|
91
|
+
const drift = [];
|
|
92
|
+
for (const rank of RANKS) {
|
|
93
|
+
const held = new Set(resolvedFor(rank));
|
|
94
|
+
for (const p of FLOORED) {
|
|
95
|
+
if (held.has(p.key) && !admits(rank, p.floor)) {
|
|
96
|
+
drift.push({ rank, key: p.key, floor: p.floor, held: true, allowed: false });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
assert.equal(drift.length, 0, `the seed GRANTS power requireRank denies (escalation):\n${nameDrift(drift)}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The regression direction: a builder losing a power they hold today.
|
|
104
|
+
test('A2: every seeded rank resolves to every permission the real threshold ADMITS', () => {
|
|
105
|
+
const drift = [];
|
|
106
|
+
for (const rank of RANK_ORDER) {
|
|
107
|
+
const held = new Set(resolvedFor(rank));
|
|
108
|
+
for (const p of FLOORED) {
|
|
109
|
+
if (!held.has(p.key) && admits(rank, p.floor)) {
|
|
110
|
+
drift.push({ rank, key: p.key, floor: p.floor, held: false, allowed: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
assert.equal(drift.length, 0, `the seed WITHHOLDS power requireRank grants (regression):\n${nameDrift(drift)}`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('A3: each seeded rank\'s resolved set EQUALS its threshold set exactly, with a readable diff', () => {
|
|
118
|
+
for (const rank of RANK_ORDER) {
|
|
119
|
+
const held = resolvedFor(rank);
|
|
120
|
+
const expected = FLOORED.filter((p) => admits(rank, p.floor)).map((p) => p.key).sort();
|
|
121
|
+
const heldSet = new Set(held);
|
|
122
|
+
const expectedSet = new Set(expected);
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
{ rank, extra: held.filter((k) => !expectedSet.has(k)), missing: expected.filter((k) => !heldSet.has(k)) },
|
|
125
|
+
{ rank, extra: [], missing: [] },
|
|
126
|
+
`${rank}: resolved set diverges from what requireRank admits`,
|
|
127
|
+
);
|
|
128
|
+
assert.equal(held.length, expected.length, `${rank}: holds ${held.length}, gate admits ${expected.length}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Routes spell the gate cumulatively — requireRank('metic','archon') — while the
|
|
133
|
+
// catalog stores one floor. Threshold semantics make those identical; asserting it
|
|
134
|
+
// over the whole catalog and the whole ladder makes the catalog's compression
|
|
135
|
+
// provably information-preserving rather than merely conventional.
|
|
136
|
+
test('A4: a single floor reproduces the cumulative requireRank(...) spelling exactly', () => {
|
|
137
|
+
for (const p of FLOORED) {
|
|
138
|
+
const cumulative = RANK_ORDER.slice(RANK_ORDER.indexOf(p.floor));
|
|
139
|
+
for (const rank of RANKS) {
|
|
140
|
+
assert.equal(auth.rankMeetsThreshold(rank, [p.floor]), auth.rankMeetsThreshold(rank, cumulative),
|
|
141
|
+
`${p.key}: floor '${p.floor}' and spelling (${cumulative.join(',')}) disagree for rank ${rank}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('A5: every principal permission (floor null) is held by NO rank on the whole ladder', () => {
|
|
147
|
+
assert.ok(PRINCIPALS.length > 0, 'the catalog should define at least one principal permission');
|
|
148
|
+
for (const rank of RANKS) {
|
|
149
|
+
const held = new Set(resolvedFor(rank));
|
|
150
|
+
for (const p of PRINCIPALS) {
|
|
151
|
+
assert.equal(held.has(p.key), false, `${rank} must not hold principal ${p.key} (gated on system_role, never a rank)`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ═══ PART B — the floor VALUES, against the live route source ═════════════════
|
|
157
|
+
// The independent oracle. route-rank-check.js is the grader's own classifier: it
|
|
158
|
+
// reads src/bongos/routes/ + modules/*/routes/ and reports the rank middleware each
|
|
159
|
+
// route actually carries. Cross-referencing it with each permission's `guards` note
|
|
160
|
+
// pins the floor to the code that enforces it, so lowering a floor reds the build.
|
|
161
|
+
|
|
162
|
+
const normRoute = (key) => key.replace(/:[A-Za-z0-9_]+/g, ':p'); // :id vs :criterionId
|
|
163
|
+
// Leading `METHOD /path` of a guards note. Deliberately conservative: a note that
|
|
164
|
+
// lists several routes or wraps them in prose/braces simply doesn't participate.
|
|
165
|
+
const GUARD_ROUTE_RE = /^(GET|POST|PATCH|PUT|DELETE)\s+(\/[^\s,(]+)/;
|
|
166
|
+
// A route's middleware rank → the LOOSEST floor still consistent with it. Only the
|
|
167
|
+
// two rank-gated classifications constrain a floor; 'any-builder' / 'public' /
|
|
168
|
+
// 'unknown' / 'bfg-principal' carry no rank middleware to compare against.
|
|
169
|
+
const MIN_FLOOR_FOR_ROUTE = { archon: 'archon', 'metic+archon': 'metic' };
|
|
170
|
+
|
|
171
|
+
function liveRoutes() {
|
|
172
|
+
const map = new Map();
|
|
173
|
+
for (const f of routeRank.discoverRouteFiles()) {
|
|
174
|
+
for (const r of routeRank.classifyText(fs.readFileSync(f.abs, 'utf8'))) map.set(normRoute(r.key), r);
|
|
175
|
+
}
|
|
176
|
+
return map;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The cross-reference, split by WHICH ORACLE a route can still serve (BV1.R109).
|
|
180
|
+
//
|
|
181
|
+
// floorPinned — the route carries a RANK middleware. Its classification is
|
|
182
|
+
// independent of catalog.floor, so comparing the two is a real check (B1).
|
|
183
|
+
// atomPinned — the route has migrated to requirePermission (R104-R107). Its
|
|
184
|
+
// classification is DERIVED from catalog.floor via the catalog, so a floor
|
|
185
|
+
// comparison would move both sides together and prove nothing. Such a route is
|
|
186
|
+
// therefore dropped from B1 and checked by B3 instead, on the property that IS
|
|
187
|
+
// still independent: the atom the route names must be the permission whose
|
|
188
|
+
// guards note points at that route.
|
|
189
|
+
function crossReference() {
|
|
190
|
+
const live = liveRoutes();
|
|
191
|
+
const floorPinned = [];
|
|
192
|
+
const atomPinned = [];
|
|
193
|
+
for (const p of FLOORED) {
|
|
194
|
+
const m = GUARD_ROUTE_RE.exec(p.guards);
|
|
195
|
+
if (!m) continue;
|
|
196
|
+
const routeKey = `${m[1]} ${m[2]}`;
|
|
197
|
+
const r = live.get(normRoute(routeKey));
|
|
198
|
+
if (!r) continue;
|
|
199
|
+
if (r.perms) {
|
|
200
|
+
atomPinned.push({ permission: p.key, floor: p.floor, routeKey, perms: r.perms });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const minFloor = MIN_FLOOR_FOR_ROUTE[r.rank];
|
|
204
|
+
if (!minFloor) continue;
|
|
205
|
+
floorPinned.push({ permission: p.key, floor: p.floor, routeKey, routeRank: r.rank, minFloor });
|
|
206
|
+
}
|
|
207
|
+
return { floorPinned, atomPinned };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ONE-DIRECTIONAL, and deliberately so. A floor STRICTER than the route middleware
|
|
211
|
+
// is legitimate and common: the wall lives in the handler (task.confirm.grade_bypass
|
|
212
|
+
// on an any-builder POST /tasks/:id/confirm; goal.manage.own's owner|manager check;
|
|
213
|
+
// box.source.full's boxScopeForRank). A floor LOOSER than the middleware is never
|
|
214
|
+
// legitimate — it would tell the governance tab a permission is delegable below the
|
|
215
|
+
// rank the route in fact demands.
|
|
216
|
+
test('B1: no permission floor is LOOSER than the rank gate its route actually carries', () => {
|
|
217
|
+
const { floorPinned } = crossReference();
|
|
218
|
+
const violations = floorPinned
|
|
219
|
+
.filter((r) => RANK_ORDER.indexOf(r.floor) < RANK_ORDER.indexOf(r.minFloor))
|
|
220
|
+
.map((r) => ` ${r.permission}: floor '${r.floor}' but ${r.routeKey} gates at '${r.routeRank}' (needs '${r.minFloor}'+)`);
|
|
221
|
+
assert.equal(violations.length, 0,
|
|
222
|
+
`catalog floors are looser than the live routes enforce:\n${violations.join('\n')}`);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// A silent drop to zero coverage would make B1 vacuously green — e.g. if the guards
|
|
226
|
+
// note format changed, or route-rank-check stopped classifying. Pin the floor of the
|
|
227
|
+
// cross-reference itself, including that BOTH rank-gated classes are represented (an
|
|
228
|
+
// all-archon sample would not exercise the metic boundary at all).
|
|
229
|
+
//
|
|
230
|
+
// The CENSUS counts both oracles (B1's rank-gated routes + B3's migrated ones), so the
|
|
231
|
+
// R104-R107 migration moves permissions between them without ever dropping total
|
|
232
|
+
// coverage — the guard stays a guard instead of becoming a migration blocker. The
|
|
233
|
+
// per-class minimums are taken over the union and keyed on the CATALOG floor, since
|
|
234
|
+
// here they measure sample breadth, not authority.
|
|
235
|
+
test('B2: the route cross-reference actually covers a substantial slice of the catalog', () => {
|
|
236
|
+
const { floorPinned, atomPinned } = crossReference();
|
|
237
|
+
const covered = [...floorPinned, ...atomPinned];
|
|
238
|
+
assert.ok(covered.length >= 25,
|
|
239
|
+
`only ${covered.length} permissions cross-reference a gated route (${floorPinned.length} by rank, ${atomPinned.length} by atom; expected >= 25)`);
|
|
240
|
+
const archonCovered = covered.filter((r) => r.floor === 'archon');
|
|
241
|
+
const meticCovered = covered.filter((r) => r.floor === 'metic');
|
|
242
|
+
assert.ok(archonCovered.length >= 10, `only ${archonCovered.length} archon-floored routes cross-referenced`);
|
|
243
|
+
assert.ok(meticCovered.length >= 10, `only ${meticCovered.length} metic-floored routes cross-referenced`);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// B3 — the oracle that REPLACES B1 as routes migrate (BV1.R109). For a route gated by
|
|
247
|
+
// requirePermission, route-rank-check derives its rank from catalog.floor, so a floor
|
|
248
|
+
// comparison is circular. What is still independent is WHICH ATOM the route names: the
|
|
249
|
+
// catalog says permission P guards route R, so R's gate must actually name P.
|
|
250
|
+
//
|
|
251
|
+
// VACUOUS UNTIL R104-R107 LAND — zero routes carry a permission gate today, so this
|
|
252
|
+
// passing proves nothing yet. It grows into B1's replacement exactly as B1 shrinks;
|
|
253
|
+
// B2's census is what keeps the pair from silently emptying out.
|
|
254
|
+
test('B3: a MIGRATED route gates on the atom the catalog says guards it', () => {
|
|
255
|
+
const { atomPinned } = crossReference();
|
|
256
|
+
const violations = atomPinned
|
|
257
|
+
.filter((r) => !r.perms.includes(r.permission))
|
|
258
|
+
.map((r) => ` ${r.permission}: its guards note names ${r.routeKey}, but that route gates on '${r.perms.join(', ')}'`);
|
|
259
|
+
assert.equal(violations.length, 0,
|
|
260
|
+
`a migrated route gates on a different atom than the catalog claims guards it:\n${violations.join('\n')}`);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ═══ PART C — the two REAL middlewares, over the whole catalog ════════════════
|
|
264
|
+
|
|
265
|
+
function fakeRes() {
|
|
266
|
+
const res = {
|
|
267
|
+
_status: null,
|
|
268
|
+
_json: null,
|
|
269
|
+
status(code) { this._status = code; return this; },
|
|
270
|
+
json(body) { this._json = body; return this; },
|
|
271
|
+
};
|
|
272
|
+
attachFail({}, res, () => {});
|
|
273
|
+
return res;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Runs the real permission gate with the governance port stubbed to return what the
|
|
277
|
+
// live resolver returns for a builder of `rank`. The stub supplies the DB row set,
|
|
278
|
+
// not the logic — resolvedFor() is the real resolver over the real seed.
|
|
279
|
+
async function permissionAdmits(key, rank) {
|
|
280
|
+
seams._reset();
|
|
281
|
+
seams.registerProvider('governance', { resolveBuilderPermissions: async () => resolvedFor(rank) });
|
|
282
|
+
let nexted = false;
|
|
283
|
+
await auth.requirePermission(key)({ builder: { id: 7, rank } }, fakeRes(), () => { nexted = true; });
|
|
284
|
+
seams._reset();
|
|
285
|
+
return nexted;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function rankGateAdmits(floor, rank) {
|
|
289
|
+
let nexted = false;
|
|
290
|
+
auth.requireRank(floor)({ builder: { rank } }, fakeRes(), () => { nexted = true; });
|
|
291
|
+
return nexted;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
test('C1: requirePermission and requireRank agree on EVERY catalog key × every seeded rank', async () => {
|
|
295
|
+
const drift = [];
|
|
296
|
+
for (const p of FLOORED) {
|
|
297
|
+
for (const rank of RANK_ORDER) {
|
|
298
|
+
const viaPermission = await permissionAdmits(p.key, rank);
|
|
299
|
+
const viaRank = rankGateAdmits(p.floor, rank);
|
|
300
|
+
if (viaPermission !== viaRank) drift.push({ rank, key: p.key, floor: p.floor, held: viaPermission, allowed: viaRank });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
assert.equal(drift.length, 0, `requirePermission and requireRank disagree at the live gate:\n${nameDrift(drift)}`);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('C2: the permission gate denies every principal key for every seeded rank', async () => {
|
|
307
|
+
for (const p of PRINCIPALS) {
|
|
308
|
+
for (const rank of RANK_ORDER) {
|
|
309
|
+
assert.equal(await permissionAdmits(p.key, rank), false,
|
|
310
|
+
`${rank} passed a ${p.key} gate — principals are gated on an exact system_role`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ═══ PART D — the deliberate fail-closed divergence ═══════════════════════════
|
|
316
|
+
|
|
317
|
+
test('D1: a builder on a NON-seeded rank gets no role and resolves to ZERO permissions', () => {
|
|
318
|
+
assert.ok(UNSEEDED_RANKS.length > 0, 'the rank CHECK still reserves ranks with no seeded role');
|
|
319
|
+
for (const rank of UNSEEDED_RANKS) {
|
|
320
|
+
assert.deepEqual(resolvedFor(rank), [], `${rank} has no seeded rank-role, so it must resolve to nothing`);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('D2: that divergence is strictly a TIGHTENING — the divine ranks lose latent power, none gain', async () => {
|
|
325
|
+
// requireRank's threshold admits every rank ABOVE archon (#591), so a divine rank
|
|
326
|
+
// passes an archon gate today yet holds no seeded role tomorrow. That is the
|
|
327
|
+
// intended stricter posture, not a bug — pin it so a later "fix" that widens the
|
|
328
|
+
// backfill has to argue with a test instead of slipping through.
|
|
329
|
+
const divine = UNSEEDED_RANKS.filter((r) => admits(r, 'archon'));
|
|
330
|
+
assert.ok(divine.length > 0, 'expected at least one above-archon rank requireRank still admits');
|
|
331
|
+
for (const rank of divine) {
|
|
332
|
+
assert.equal(resolvedFor(rank).length, 0, `${rank} is admitted by an archon gate but holds no governance role`);
|
|
333
|
+
assert.equal(await permissionAdmits('builder.rank.set', rank), false,
|
|
334
|
+
`${rank} must be denied at the permission gate even though requireRank('archon') admits it`);
|
|
335
|
+
}
|
|
336
|
+
});
|