@bongos/core 1.18.0 → 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 +1740 -640
- 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 +5 -3
- 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 +11 -2
- 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/0142-project-context-is-prose-only.md +41 -0
- package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
- 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 +15 -2
- package/docs/api/openapi.json +2648 -483
- package/docs/api-reference.md +73 -13
- 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 +15 -7
- 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/packaging-the-core.md +13 -0
- 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/upgrading-the-core.md +5 -2
- 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/039_newcomer_friendly_column.sql +5 -0
- package/migrations/102_remove_box_first_provision_approval.sql +4 -0
- package/migrations/core_192_github_oauth_tokens.sql +33 -0
- 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/my-projects.html +78 -0
- package/modules/hall-ui/public/my-projects.js +142 -0
- 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/scouting.html +45 -0
- package/modules/hall-ui/public/scouting.js +73 -0
- 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/modules/status-ui/public/explore.html +54 -0
- package/modules/status-ui/public/explore.js +67 -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 +252 -62
- 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 -21
- 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/lint-migrations.js +102 -0
- package/scripts/gds/login.js +58 -11
- package/scripts/gds/mirror-redact.js +5 -0
- package/scripts/gds/package-core.js +263 -4
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +268 -19
- package/scripts/gds/publish-manifest.js +22 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +42 -16
- 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 +72 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +433 -59
- 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 +271 -7
- package/scripts/migrate.sh +10 -0
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +809 -107
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +105 -0
- package/src/bongos/github-repos.js +93 -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 +312 -13
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/github.js +90 -0
- package/src/bongos/routes/instance.js +15 -3
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/routes.js +2 -0
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +49 -2
- package/src/instance-config.js +46 -0
- package/src/module-api.js +140 -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 +41 -23
- 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/github_repos_helpers.mjs +91 -0
- package/tests/github_token_store.mjs +121 -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 +198 -0
- package/tests/idp_federation.mjs +100 -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/migrations_forward_safe.mjs +86 -0
- package/tests/module_api.mjs +1 -1
- 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/package_core.mjs +246 -1
- 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 +289 -7
- package/tests/pruned_lockfile_integrity.mjs +79 -0
- 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 +140 -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 +393 -5
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
- package/.gitignore +0 -91
- package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
- package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
- package/scripts/hall-preview/fixtures/access-requests.json +0 -20
- package/scripts/hall-preview/fixtures/achievements.json +0 -76
- package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
- package/scripts/hall-preview/fixtures/audit-log.json +0 -68
- package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
- package/scripts/hall-preview/fixtures/box__me.json +0 -5
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
- package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
- package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
- package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
- package/scripts/hall-preview/fixtures/goals.json +0 -502
- package/scripts/hall-preview/fixtures/goals__25.json +0 -96
- package/scripts/hall-preview/fixtures/goals__26.json +0 -67
- package/scripts/hall-preview/fixtures/goals__3.json +0 -103
- package/scripts/hall-preview/fixtures/goals__35.json +0 -49
- package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
- package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
- package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
- package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
- package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
- package/scripts/hall-preview/fixtures/inbox.json +0 -330
- package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
- package/scripts/hall-preview/fixtures/instance.json +0 -124
- package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
- package/scripts/hall-preview/fixtures/me.json +0 -476
- package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
- package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
- package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
- package/scripts/hall-preview/fixtures/me__render.json +0 -5
- package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
- package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
- package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
- package/scripts/hall-preview/fixtures/override-requests.json +0 -15
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
- package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
- package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
- package/scripts/hall-preview/fixtures/public__grades.json +0 -50
- package/scripts/hall-preview/fixtures/public__progress.json +0 -124
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
- package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
- package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
- package/scripts/hall-preview/fixtures/security__docs.json +0 -10
- package/scripts/hall-preview/fixtures/security__reports.json +0 -31
- package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
- package/scripts/hall-preview/fixtures/tasks.json +0 -2420
- package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
- package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
- package/scripts/hall-preview/fixtures/versions.json +0 -89
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// tests/governance_session_scope.mjs — session-scopes as a first-class axis
|
|
2
|
+
// (BV1.R99, ADR 0151 §3; the model behind ADR 0053). DB-free.
|
|
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.
|
|
7
|
+
//
|
|
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).
|
|
17
|
+
//
|
|
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
|
+
// governance.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.
|
|
33
|
+
import assert from 'node:assert/strict';
|
|
34
|
+
import { test } from 'node:test';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { fileURLToPath } from 'node:url';
|
|
38
|
+
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
const GOV = path.join(ROOT, 'modules', 'governance');
|
|
42
|
+
|
|
43
|
+
// auth.js destructures lookupSession at load, so the stub must be installed before
|
|
44
|
+
// ANYTHING pulls auth.js in (src/module-api does, via the governance route file).
|
|
45
|
+
const dbKernel = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
|
|
46
|
+
let nextSession = null;
|
|
47
|
+
dbKernel.lookupSession = async () => nextSession;
|
|
48
|
+
|
|
49
|
+
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
|
+
const scopes = require(path.join(GOV, 'session-scopes.js'));
|
|
54
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
55
|
+
|
|
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', 'governance.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/governance_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
|
+
}
|
|
88
|
+
|
|
89
|
+
function fakeRes() {
|
|
90
|
+
const res = {
|
|
91
|
+
_status: null,
|
|
92
|
+
_json: null,
|
|
93
|
+
status(code) { this._status = code; return this; },
|
|
94
|
+
json(body) { this._json = body; return this; },
|
|
95
|
+
};
|
|
96
|
+
attachFail({}, res, () => {});
|
|
97
|
+
return res;
|
|
98
|
+
}
|
|
99
|
+
|
|
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
|
+
});
|
|
157
|
+
|
|
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
|
+
});
|
|
168
|
+
|
|
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]);
|
|
187
|
+
});
|
|
188
|
+
|
|
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
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
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
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
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
|
+
});
|
|
256
|
+
|
|
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);
|
|
264
|
+
});
|
|
265
|
+
|
|
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));
|
|
285
|
+
});
|
|
286
|
+
|
|
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
|
+
});
|
|
296
|
+
|
|
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');
|
|
306
|
+
});
|
|
307
|
+
|
|
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
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('F2: an unscoped session reaches every route, declared or not', () => {
|
|
325
|
+
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, ['governance.manage']), true);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
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' } };
|
|
351
|
+
const res = fakeRes();
|
|
352
|
+
let nexted = false;
|
|
353
|
+
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/governance_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')));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ═══ I — the ONE governance port carries the axis ════════════════════════════
|
|
420
|
+
|
|
421
|
+
test('I1: the session-scope axis lands on the single governance port', () => {
|
|
422
|
+
const { governancePort } = require(path.join(GOV, 'routes', 'governance.js'));
|
|
423
|
+
for (const name of ['resolveSessionPermissions', 'narrowToSessionScope', 'sessionScopeFor', 'listSessionScopes']) {
|
|
424
|
+
assert.equal(typeof governancePort[name], 'function', `the port must expose ${name}`);
|
|
425
|
+
}
|
|
426
|
+
// still the same one object port the earlier slices registered
|
|
427
|
+
for (const name of ['resolveBuilderPermissions', 'resolvePrincipalPermissions', 'authorizeOwned']) {
|
|
428
|
+
assert.equal(typeof governancePort[name], 'function');
|
|
429
|
+
}
|
|
430
|
+
assert.deepEqual([...governancePort.sessionScopeFor('box').permissions], EXPECTED_BOX_SCOPE);
|
|
431
|
+
assert.equal(governancePort.sessionScopeFor('ghost'), null);
|
|
432
|
+
assert.deepEqual([...governancePort.narrowToSessionScope(seedForRank('archon'), 'box')], EXPECTED_BOX_SCOPE);
|
|
433
|
+
assert.ok(governancePort.listSessionScopes().length >= 2);
|
|
434
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// tests/governance_vanilla_boot.mjs — the VANILLA BOOT proof (BV1.R102, ADR 0151).
|
|
2
|
+
//
|
|
3
|
+
// The core/host split only holds if a FRESH instance — one that has never been
|
|
4
|
+
// branded, with no config/governance.json on disk and no rows in
|
|
5
|
+
// governance_builder_roles — comes up sane, anonymous, and powerless. This file is
|
|
6
|
+
// that proof. It is deliberately scoped to what R96's tests/governance_config.mjs
|
|
7
|
+
// does NOT cover: that file pins the config CONTRACT (precedence, per-field merge,
|
|
8
|
+
// the three fail-closed strips) driven by INJECTED fixtures; this one boots the real
|
|
9
|
+
// thing off disk and asserts the three vanilla properties:
|
|
10
|
+
//
|
|
11
|
+
// (a) SANE DEFAULTS — the four seeded rank-roles resolve with usable labels
|
|
12
|
+
// and descriptions from the neutral pack alone; an absent
|
|
13
|
+
// instance pack is the NORMAL case, not an error.
|
|
14
|
+
// (b) NO HOST IDENTITY — the neutral pack names no host brand, and the CI gate
|
|
15
|
+
// that keeps it that way is real rather than a no-op.
|
|
16
|
+
// (c) BOUNDARY INTACT — config cannot confer authority on a vanilla boot. The
|
|
17
|
+
// grants the four rank-roles hold come from catalog
|
|
18
|
+
// FLOORS materialised by governance_002; the file
|
|
19
|
+
// contributes exactly nothing, even if someone poisons it.
|
|
20
|
+
//
|
|
21
|
+
// (c) is the half that matters most. A vanilla instance that came up with a role
|
|
22
|
+
// holding `deploy.push_main` because a config file said so is the catastrophic
|
|
23
|
+
// outcome ADR 0016 exists to rule out, and this file is what would go red.
|
|
24
|
+
//
|
|
25
|
+
// DB-free throughout: the config layer is pure + disk, and the grant side is asserted
|
|
26
|
+
// against catalog.RANK_ROLE_SEED (code) rather than a live join. Crucially the two
|
|
27
|
+
// sides read DIFFERENT sources — the seed side reads catalog.js, the config side
|
|
28
|
+
// reads the neutral pack's raw bytes — so a change to either one moves only one side
|
|
29
|
+
// and the assertion goes red.
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
import { test } from 'node:test';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const require = createRequire(import.meta.url);
|
|
39
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
+
const GOV = path.join(ROOT, 'modules', 'governance');
|
|
41
|
+
const catalog = require(path.join(GOV, 'catalog.js'));
|
|
42
|
+
const { loadGovernanceConfig, resolveGovernanceConfig, NEUTRAL_PATH, INSTANCE_PATH } = require(path.join(GOV, 'config.js'));
|
|
43
|
+
const { effectivePermissions, resolveBuilderPermissions } = require(path.join(GOV, 'resolver.js'));
|
|
44
|
+
const fitness = require(path.join(ROOT, 'scripts', 'gds', 'fitness.js'));
|
|
45
|
+
const { envPrefix } = require(path.join(ROOT, 'src', 'instance-config'));
|
|
46
|
+
|
|
47
|
+
// Every precedence test injects exactly the env it asserts on — an ambient
|
|
48
|
+
// <PREFIX>_GOVERNANCE_ROLE_LABELS in the runner's shell must not reach the boot.
|
|
49
|
+
const NO_ENV = {};
|
|
50
|
+
const NEUTRAL_TEXT = fs.readFileSync(NEUTRAL_PATH, 'utf8');
|
|
51
|
+
|
|
52
|
+
// The vanilla boot, exactly as a fresh instance performs it: real neutral pack on
|
|
53
|
+
// disk, no instance pack, no env.
|
|
54
|
+
const vanilla = () => loadGovernanceConfig({ env: NO_ENV });
|
|
55
|
+
// Everything config hands the boot, flattened — the ONLY authority a file could
|
|
56
|
+
// possibly contribute.
|
|
57
|
+
const configUnion = (cfg) => [...new Set(Object.values(cfg.roles).flatMap((r) => r.permissions))].sort();
|
|
58
|
+
|
|
59
|
+
// Runs `fn(dir)` against a scratch dir, always cleaned up. Nothing under it exists
|
|
60
|
+
// until the test writes it, so `path.join(dir, 'absent.json')` is a genuine ENOENT.
|
|
61
|
+
const withScratchDir = (fn) => {
|
|
62
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-vanilla-'));
|
|
63
|
+
try { fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ── (a) sane defaults ───────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
test('a fresh instance boots the four rank-roles with usable labels and descriptions', () => {
|
|
69
|
+
assert.equal(fs.existsSync(INSTANCE_PATH), false, 'fixture guard: this checkout IS the vanilla case (no instance pack)');
|
|
70
|
+
const out = vanilla();
|
|
71
|
+
assert.deepEqual(Object.keys(out.roles).sort(), [...catalog.RANK_ORDER].sort());
|
|
72
|
+
for (const rank of catalog.RANK_ORDER) {
|
|
73
|
+
const role = out.roles[rank];
|
|
74
|
+
assert.equal(role.seeded, true, `${rank} must boot as a SEEDED rank-role, not a custom one`);
|
|
75
|
+
assert.ok(role.label && role.label !== rank, `${rank} must carry a human label from the neutral pack, got "${role.label}"`);
|
|
76
|
+
assert.ok(role.description.length > 10, `${rank} must carry a usable description, got "${role.description}"`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a core with NO config packs at all still boots four roles rather than none', () => {
|
|
81
|
+
// The extreme vanilla: not just a missing instance pack but a missing neutral one
|
|
82
|
+
// too. governance_002 seeds the four rank-roles unconditionally, so the display
|
|
83
|
+
// set must never shrink below them — a boot that dropped `archon` would leave the
|
|
84
|
+
// instance with grants no template describes.
|
|
85
|
+
withScratchDir((dir) => {
|
|
86
|
+
const out = loadGovernanceConfig({
|
|
87
|
+
neutralPath: path.join(dir, 'absent-neutral.json'),
|
|
88
|
+
instancePath: path.join(dir, 'absent-instance.json'),
|
|
89
|
+
env: NO_ENV,
|
|
90
|
+
});
|
|
91
|
+
assert.deepEqual(Object.keys(out.roles).sort(), [...catalog.RANK_ORDER].sort());
|
|
92
|
+
for (const rank of catalog.RANK_ORDER) assert.equal(out.roles[rank].label, rank, 'a role with no template falls back to its canonical rank name');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a malformed config pack fails LOUD — it never degrades into a silent grant', () => {
|
|
97
|
+
// Documents the loader's real behaviour: readJson() returns null only for ENOENT,
|
|
98
|
+
// so a syntactically broken pack throws. That is fail-CLOSED (no authority is
|
|
99
|
+
// produced), which is the property this proof cares about — but it does contradict
|
|
100
|
+
// the loader's own header note ("the loader fails to sane defaults rather than
|
|
101
|
+
// throwing"). Reported as a doc/behaviour mismatch in the R102 handoff, not fixed
|
|
102
|
+
// here: turning a corrupt authority-adjacent file into a silent fallback is a
|
|
103
|
+
// decision, not a typo.
|
|
104
|
+
withScratchDir((dir) => {
|
|
105
|
+
const broken = path.join(dir, 'broken.json');
|
|
106
|
+
fs.writeFileSync(broken, '{ not json');
|
|
107
|
+
assert.throws(() => loadGovernanceConfig({ instancePath: broken, env: NO_ENV }), SyntaxError);
|
|
108
|
+
assert.throws(() => loadGovernanceConfig({ neutralPath: broken, env: NO_ENV }), SyntaxError);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── (b) no host identity ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
test('the CI gate over neutral config packs is green, and it actually covers governance', () => {
|
|
115
|
+
// .json is outside fitness's IDENTITY_SCAN_EXTS (js/html/css), so Check 15 never
|
|
116
|
+
// opens a config pack. Check 15b (added by this task) is the guard; assert it is
|
|
117
|
+
// both green AND non-empty, so a future refactor that stops finding the packs
|
|
118
|
+
// cannot pass by scanning nothing.
|
|
119
|
+
const r = fitness.checkNeutralConfigsNoHostIdentity();
|
|
120
|
+
assert.equal(r.hardFail, false, 'neutral packs leak a host brand:\n' + r.violations.join('\n'));
|
|
121
|
+
assert.match(r.note, /^[1-9]\d* core-shipped/, 'the check must actually find packs to scan: ' + r.note);
|
|
122
|
+
assert.ok(fs.existsSync(NEUTRAL_PATH), 'governance.neutral.json must ship — it is what a vanilla instance boots from');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the identity guard is not a no-op — a planted host brand in the neutral pack is caught', () => {
|
|
126
|
+
// The mutation test, kept permanent. Both host literals fitness knows about must
|
|
127
|
+
// trip it, in the exact file this task is about.
|
|
128
|
+
for (const brand of ['Off the Boats', 'amazonprimea.com']) {
|
|
129
|
+
const poisoned = NEUTRAL_TEXT.replace('"roles"', `"instance": "${brand}",\n "roles"`);
|
|
130
|
+
const found = fitness.scanPromptIdentity('config/governance.neutral.json', poisoned, '.json');
|
|
131
|
+
assert.ok(found.length >= 1, `a "${brand}" literal in the neutral pack must be caught`);
|
|
132
|
+
}
|
|
133
|
+
assert.deepEqual(fitness.scanPromptIdentity('config/governance.neutral.json', NEUTRAL_TEXT, '.json'), [], 'the real pack is clean');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── (c) the trust boundary on a vanilla boot ────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
test('a vanilla boot grants ZERO permissions through config', () => {
|
|
139
|
+
// The single most important line in this file. Whatever the neutral pack says, the
|
|
140
|
+
// authority a fresh instance derives FROM A FILE is the empty set.
|
|
141
|
+
const out = vanilla();
|
|
142
|
+
assert.deepEqual(configUnion(out), [], 'config contributed authority to a vanilla boot');
|
|
143
|
+
for (const key of catalog.systemKeys()) {
|
|
144
|
+
for (const role of Object.values(out.roles)) {
|
|
145
|
+
assert.ok(!role.permissions.includes(key), `vanilla boot handed role "${role.key}" the system permission "${key}"`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a vanilla boot's config union survives the resolver as nothing", () => {
|
|
151
|
+
// Crosses the two layers: even if a caller took everything config produced and fed
|
|
152
|
+
// it to the grant path as if it were DB rows, the resolver yields no authority.
|
|
153
|
+
assert.deepEqual(effectivePermissions(configUnion(vanilla())), []);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('the rank-roles’ grants derive from catalog floors, never from the neutral pack', () => {
|
|
157
|
+
// The two sides read DIFFERENT sources on purpose: the seed side is catalog.js
|
|
158
|
+
// (code), the file side is the pack's raw bytes. Moving either one moves one side
|
|
159
|
+
// only, so this cannot pass vacuously.
|
|
160
|
+
for (const rank of catalog.RANK_ORDER) {
|
|
161
|
+
assert.ok(catalog.RANK_ROLE_SEED[rank].length > 0, `${rank} must hold a floor-derived grant set`);
|
|
162
|
+
}
|
|
163
|
+
assert.ok(catalog.RANK_ROLE_SEED.archon.includes('deploy.push_main'), 'fixture guard: archon holds the seed’s system power');
|
|
164
|
+
assert.ok(!catalog.RANK_ROLE_SEED.xenos.includes('deploy.push_main'), 'fixture guard: the floors are a real ladder');
|
|
165
|
+
for (const key of catalog.permissionKeys()) {
|
|
166
|
+
assert.ok(!NEUTRAL_TEXT.includes(key), `the neutral pack names the permission "${key}" — grants belong to governance_002's floors, never to a file`);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a POISONED neutral pack still cannot grant on a vanilla boot', () => {
|
|
171
|
+
// The adversarial vanilla. R96 proved the three strips against an INSTANCE pack;
|
|
172
|
+
// the neutral pack is the one the core itself ships, so a compromised or careless
|
|
173
|
+
// edit to it is the vanilla-boot threat. A seeded role may be relabelled and never
|
|
174
|
+
// re-granted; a custom template may hold operational keys and never a system one.
|
|
175
|
+
const poisoned = JSON.parse(NEUTRAL_TEXT);
|
|
176
|
+
for (const rank of catalog.RANK_ORDER) poisoned.roles[rank].permissions = [...catalog.permissionKeys()];
|
|
177
|
+
poisoned.roles.superuser = { label: 'Superuser', permissions: [...catalog.permissionKeys()] };
|
|
178
|
+
const out = resolveGovernanceConfig({ neutral: poisoned, env: NO_ENV });
|
|
179
|
+
for (const rank of catalog.RANK_ORDER) {
|
|
180
|
+
assert.deepEqual(out.roles[rank].permissions, [], `a poisoned neutral pack re-granted the seeded role "${rank}"`);
|
|
181
|
+
}
|
|
182
|
+
for (const key of catalog.systemKeys()) {
|
|
183
|
+
assert.ok(!out.roles.superuser.permissions.includes(key), `a neutral-pack template smuggled the system permission "${key}"`);
|
|
184
|
+
}
|
|
185
|
+
assert.deepEqual(out.roles.superuser.permissions, [...catalog.grantableKeys()].sort(), 'operational keys are all a template may ever propose');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('the env layer cannot widen authority on a vanilla boot', () => {
|
|
189
|
+
const env = { [`${envPrefix()}_GOVERNANCE_ROLE_LABELS`]: 'archon=Owner' };
|
|
190
|
+
const out = loadGovernanceConfig({ env });
|
|
191
|
+
assert.equal(out.roles.archon.label, 'Owner', 'env may relabel');
|
|
192
|
+
assert.deepEqual(configUnion(out), [], 'env must never add a permission');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a fresh instance with no role rows resolves every builder to the empty set', () => {
|
|
196
|
+
// A vanilla governance_builder_roles table is empty, so the grant side of the
|
|
197
|
+
// resolve returns nothing — and an unidentified caller short-circuits before the
|
|
198
|
+
// DB is touched at all. Both are the fail-closed default the boundary rests on.
|
|
199
|
+
assert.deepEqual(effectivePermissions([]), []);
|
|
200
|
+
return Promise.all([null, undefined, ''].map(async (id) => {
|
|
201
|
+
assert.deepEqual(await resolveBuilderPermissions(id), []);
|
|
202
|
+
}));
|
|
203
|
+
});
|