@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,187 @@
|
|
|
1
|
+
// tests/governance_config.mjs — the governance config contract (BV1.R96, ADR 0151).
|
|
2
|
+
//
|
|
3
|
+
// DB-free: exercises the PURE resolver (all inputs injected) plus the disk loader
|
|
4
|
+
// against the real neutral starter. The invariants that matter here are trust-boundary
|
|
5
|
+
// invariants — a config file is editable by anyone who can write the checkout, so it
|
|
6
|
+
// must never be able to grant a system permission, invent a permission the catalog
|
|
7
|
+
// does not define, or re-grant a seeded rank-role.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const GOV = path.join(ROOT, 'modules', 'governance');
|
|
18
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
19
|
+
const catalog = require(path.join(GOV, 'catalog.js'));
|
|
20
|
+
const { envPrefix } = require(path.join(ROOT, 'src', 'instance-config'));
|
|
21
|
+
const { resolveGovernanceConfig, loadGovernanceConfig, grantableOnly, NEUTRAL_PATH, INSTANCE_PATH } = config;
|
|
22
|
+
|
|
23
|
+
const NEUTRAL = JSON.parse(fs.readFileSync(NEUTRAL_PATH, 'utf8'));
|
|
24
|
+
// No env by default — every precedence test injects exactly the env it is asserting on.
|
|
25
|
+
const NO_ENV = {};
|
|
26
|
+
|
|
27
|
+
// ── precedence: neutral -> instance -> env ──────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
test('neutral-only resolution yields sane defaults (the vanilla boot R102 proves)', () => {
|
|
30
|
+
const out = resolveGovernanceConfig({ neutral: NEUTRAL, env: NO_ENV });
|
|
31
|
+
assert.deepEqual(Object.keys(out.roles).sort(), [...catalog.RANK_ORDER].sort());
|
|
32
|
+
assert.equal(out.roles.metic.label, 'Metic');
|
|
33
|
+
assert.equal(out.roles.archon.seeded, true);
|
|
34
|
+
for (const rank of catalog.RANK_ORDER) {
|
|
35
|
+
assert.ok(out.roles[rank].description, `${rank} template must carry a description`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('an empty config still resolves the four rank-roles to their canonical names', () => {
|
|
40
|
+
// governance_002 seeds them unconditionally, so a missing/mangled config must fall
|
|
41
|
+
// back rather than drop a role out of the set.
|
|
42
|
+
const out = resolveGovernanceConfig({ env: NO_ENV });
|
|
43
|
+
assert.deepEqual(Object.keys(out.roles).sort(), [...catalog.RANK_ORDER].sort());
|
|
44
|
+
assert.equal(out.roles.xenos.label, 'xenos');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the instance file overrides the neutral starter, per role and per field', () => {
|
|
48
|
+
const out = resolveGovernanceConfig({
|
|
49
|
+
neutral: NEUTRAL,
|
|
50
|
+
instance: { roles: { metic: { label: 'Trusted' } } },
|
|
51
|
+
env: NO_ENV,
|
|
52
|
+
});
|
|
53
|
+
assert.equal(out.roles.metic.label, 'Trusted');
|
|
54
|
+
// The untouched field survives the merge, and so do the other three roles.
|
|
55
|
+
assert.equal(out.roles.metic.description, NEUTRAL.roles.metic.description);
|
|
56
|
+
assert.equal(out.roles.archon.label, 'Archon');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the instance file may add a custom role template the neutral starter has not', () => {
|
|
60
|
+
const out = resolveGovernanceConfig({
|
|
61
|
+
neutral: NEUTRAL,
|
|
62
|
+
instance: { roles: { triager: { label: 'Triager', permissions: ['idea.triage', 'blocker.review'] } } },
|
|
63
|
+
env: NO_ENV,
|
|
64
|
+
});
|
|
65
|
+
assert.equal(out.roles.triager.seeded, false);
|
|
66
|
+
assert.deepEqual(out.roles.triager.permissions, ['blocker.review', 'idea.triage']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an env override beats both the instance file and the neutral starter', () => {
|
|
70
|
+
const env = { [`${envPrefix()}_GOVERNANCE_ROLE_LABELS`]: 'metic=Crew,archon=Captain' };
|
|
71
|
+
const out = resolveGovernanceConfig({
|
|
72
|
+
neutral: NEUTRAL,
|
|
73
|
+
instance: { roles: { metic: { label: 'Trusted' } } },
|
|
74
|
+
env,
|
|
75
|
+
});
|
|
76
|
+
assert.equal(out.roles.metic.label, 'Crew');
|
|
77
|
+
assert.equal(out.roles.archon.label, 'Captain');
|
|
78
|
+
assert.equal(out.roles.xenos.label, 'Xenos', 'an unnamed role keeps its resolved label');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('the env layer can relabel a role but can never grant it a permission', () => {
|
|
82
|
+
// The whole point of restricting env to labels: an env var must not widen authority.
|
|
83
|
+
const env = { [`${envPrefix()}_GOVERNANCE_ROLE_LABELS`]: 'triager=Renamed' };
|
|
84
|
+
const out = resolveGovernanceConfig({
|
|
85
|
+
neutral: NEUTRAL,
|
|
86
|
+
instance: { roles: { triager: { label: 'Triager', permissions: ['idea.triage'] } } },
|
|
87
|
+
env,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(out.roles.triager.label, 'Renamed');
|
|
90
|
+
assert.deepEqual(out.roles.triager.permissions, ['idea.triage']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('an env label naming a role no config defines is inert (a typo cannot grow the role set)', () => {
|
|
94
|
+
const env = { [`${envPrefix()}_GOVERNANCE_ROLE_LABELS`]: 'metics=Typo' };
|
|
95
|
+
const out = resolveGovernanceConfig({ neutral: NEUTRAL, env });
|
|
96
|
+
assert.deepEqual(Object.keys(out.roles).sort(), [...catalog.RANK_ORDER].sort());
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── fail-closed: config is not a trust boundary ─────────────────────────────────
|
|
100
|
+
|
|
101
|
+
test('a template naming a system permission is stripped (config cannot grant system power)', () => {
|
|
102
|
+
const systemKeys = catalog.systemKeys();
|
|
103
|
+
assert.ok(systemKeys.includes('deploy.push_main'), 'fixture guard: the catalog still flags deploy.push_main');
|
|
104
|
+
const out = resolveGovernanceConfig({
|
|
105
|
+
neutral: NEUTRAL,
|
|
106
|
+
instance: { roles: { sneaky: { label: 'Sneaky', permissions: ['deploy.push_main', 'builder.rank.set', 'idea.triage'] } } },
|
|
107
|
+
env: NO_ENV,
|
|
108
|
+
});
|
|
109
|
+
assert.deepEqual(out.roles.sneaky.permissions, ['idea.triage']);
|
|
110
|
+
// Belt and braces: NO system key survives a template, whichever one is named.
|
|
111
|
+
const all = resolveGovernanceConfig({
|
|
112
|
+
neutral: NEUTRAL,
|
|
113
|
+
instance: { roles: { everything: { permissions: catalog.permissionKeys() } } },
|
|
114
|
+
env: NO_ENV,
|
|
115
|
+
});
|
|
116
|
+
for (const k of systemKeys) {
|
|
117
|
+
assert.ok(!all.roles.everything.permissions.includes(k), `config must not be able to grant system permission "${k}"`);
|
|
118
|
+
}
|
|
119
|
+
assert.deepEqual(all.roles.everything.permissions, [...catalog.grantableKeys()].sort());
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('a template naming an unknown permission key is dropped (the catalog is the truth)', () => {
|
|
123
|
+
const out = resolveGovernanceConfig({
|
|
124
|
+
neutral: NEUTRAL,
|
|
125
|
+
instance: { roles: { odd: { permissions: ['idea.triage', 'not.a.real.permission', 'deploy.everything'] } } },
|
|
126
|
+
env: NO_ENV,
|
|
127
|
+
});
|
|
128
|
+
assert.deepEqual(out.roles.odd.permissions, ['idea.triage']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('permissions on a SEEDED rank-role are dropped wholesale (grants derive from floors)', () => {
|
|
132
|
+
const out = resolveGovernanceConfig({
|
|
133
|
+
neutral: NEUTRAL,
|
|
134
|
+
instance: { roles: { xenos: { label: 'Newcomer', permissions: ['task.create', 'audit_log.read'] } } },
|
|
135
|
+
env: NO_ENV,
|
|
136
|
+
});
|
|
137
|
+
assert.equal(out.roles.xenos.label, 'Newcomer', 'a seeded role may be relabelled');
|
|
138
|
+
assert.deepEqual(out.roles.xenos.permissions, [], 'a seeded role may never be re-granted from config');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('grantableOnly de-duplicates, sorts, and rejects non-string entries', () => {
|
|
142
|
+
assert.deepEqual(grantableOnly(['cost.log', 'idea.triage', 'cost.log', 42, null]), ['cost.log', 'idea.triage']);
|
|
143
|
+
assert.deepEqual(grantableOnly(undefined), []);
|
|
144
|
+
assert.deepEqual(grantableOnly('idea.triage'), [], 'a bare string is not a permission list');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the resolved config is frozen — a caller cannot mutate it into a grant', () => {
|
|
148
|
+
const out = resolveGovernanceConfig({ neutral: NEUTRAL, env: NO_ENV });
|
|
149
|
+
assert.ok(Object.isFrozen(out) && Object.isFrozen(out.roles) && Object.isFrozen(out.roles.metic));
|
|
150
|
+
assert.throws(() => out.roles.metic.permissions.push('deploy.push_main'), TypeError);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── the neutral starter itself ──────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('the neutral starter carries no host-specific identity (a vanilla instance boots from it alone)', () => {
|
|
156
|
+
// Reuses fitness.js's own definition of "host identity" so this cannot drift from
|
|
157
|
+
// the CI gate (which does not scan .json itself — IDENTITY_SCAN_EXTS is js/html/css).
|
|
158
|
+
const { scanPromptIdentity } = require(path.join(ROOT, 'scripts', 'gds', 'fitness.js'));
|
|
159
|
+
const raw = fs.readFileSync(NEUTRAL_PATH, 'utf8');
|
|
160
|
+
assert.deepEqual(scanPromptIdentity('config/governance.neutral.json', raw, '.json'), []);
|
|
161
|
+
// And it carries only templates — never a live per-builder assignment.
|
|
162
|
+
assert.deepEqual(Object.keys(NEUTRAL).filter((k) => k !== '_note'), ['roles']);
|
|
163
|
+
for (const role of Object.values(NEUTRAL.roles)) {
|
|
164
|
+
assert.ok(!('permissions' in role), 'the neutral seeded roles carry labels only, never grants');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('the two config paths are the branding-shaped neutral/instance pair under config/', () => {
|
|
169
|
+
assert.equal(path.relative(ROOT, NEUTRAL_PATH).split(path.sep).join('/'), 'config/governance.neutral.json');
|
|
170
|
+
assert.equal(path.relative(ROOT, INSTANCE_PATH).split(path.sep).join('/'), 'config/governance.json');
|
|
171
|
+
assert.ok(fs.existsSync(NEUTRAL_PATH), 'the neutral starter must ship — it is the vanilla default');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('loadGovernanceConfig reads the real neutral starter and tolerates a missing instance pack', () => {
|
|
175
|
+
// A vanilla instance has no config/governance.json at all — the default INSTANCE_PATH
|
|
176
|
+
// is genuinely absent in this checkout, so the default call exercises that path.
|
|
177
|
+
assert.equal(fs.existsSync(INSTANCE_PATH), false, 'fixture guard: this checkout is the vanilla case');
|
|
178
|
+
const out = loadGovernanceConfig({ env: NO_ENV });
|
|
179
|
+
assert.equal(out.roles.archon.label, 'Archon');
|
|
180
|
+
assert.deepEqual(Object.keys(out.roles).sort(), [...catalog.RANK_ORDER].sort());
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('the neutral starter publishes with the core (a packaged vanilla instance needs it)', () => {
|
|
184
|
+
const { isPublishable } = require(path.join(ROOT, 'scripts', 'gds', 'publish-manifest.js'));
|
|
185
|
+
assert.equal(isPublishable('config/governance.neutral.json'), true);
|
|
186
|
+
assert.equal(isPublishable('config/governance.json'), false, "a host's filled-in pack is instance content");
|
|
187
|
+
});
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// tests/governance_ownership_scope.mjs — ownership-scoped permissions
|
|
2
|
+
// ("act on OWN X") as a first-class axis (BV1.R100, ADR 0151 §3). DB-free.
|
|
3
|
+
//
|
|
4
|
+
// The thing being proved is a CONJUNCTION. `box.manage.own` is not a grant that
|
|
5
|
+
// happens to be named "own" — it is half a decision:
|
|
6
|
+
//
|
|
7
|
+
// 1. the ROLE fact — the builder holds the key (resolved from the DB, uncached);
|
|
8
|
+
// 2. the OWNER fact — the concrete row is theirs (a REQUEST-scoped fact the
|
|
9
|
+
// resolver structurally cannot know: it is handed a builder
|
|
10
|
+
// id, never a box).
|
|
11
|
+
//
|
|
12
|
+
// The security failure mode this file exists to catch is (2) quietly disappearing,
|
|
13
|
+
// leaving `.own` as an unconditional grant — i.e. `X.manage.own` silently becoming
|
|
14
|
+
// `X.manage.any`. So the load-bearing tests are: `.own` never implies `.any`, and a
|
|
15
|
+
// builder holding EVERY permission in the catalog still cannot touch a row that
|
|
16
|
+
// belongs to someone else.
|
|
17
|
+
//
|
|
18
|
+
// ORACLE DISCIPLINE. A test whose two sides read the same field cannot fail — a bad
|
|
19
|
+
// value moves both together. So the "own ≠ any" assertions are driven by `floor`
|
|
20
|
+
// (a PRE-R100 field) run through auth.rankMeetsThreshold (the REAL production rank
|
|
21
|
+
// gate). The new `scope`/`resource` fields are used only to ENUMERATE which pairs
|
|
22
|
+
// to check, never to decide whether a pair is separated.
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const GOV = path.join(ROOT, 'modules', 'governance');
|
|
34
|
+
const catalog = require(path.join(GOV, 'catalog.js'));
|
|
35
|
+
const resolver = require(path.join(GOV, 'resolver.js'));
|
|
36
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
37
|
+
const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
PERMISSIONS, RANK_ORDER, RANK_ROLE_SEED, RESOURCE_FAMILIES, permissionKeys, byKey,
|
|
41
|
+
scopeOf, resourceOf, isOwnershipScoped, isCrossOwner, ownershipScopedKeys, crossOwnerKeys,
|
|
42
|
+
crossOwnerKeysFor, isPrincipalKey, seedForRank,
|
|
43
|
+
} = catalog;
|
|
44
|
+
const {
|
|
45
|
+
sameOwner, authorizeOwned, authorizeOwnedOrAny, builderHasPermissions, effectivePermissions,
|
|
46
|
+
} = resolver;
|
|
47
|
+
|
|
48
|
+
const ME = 42;
|
|
49
|
+
const SOMEONE_ELSE = 99;
|
|
50
|
+
|
|
51
|
+
// Stub the ONE DB read the resolver makes, so the ownership logic is exercised
|
|
52
|
+
// end-to-end without Postgres. resolver.js looks the method up on the db module
|
|
53
|
+
// object at call time, so replacing it here bites the real code path.
|
|
54
|
+
const realGetPermissionKeys = db.getPermissionKeysForBuilder;
|
|
55
|
+
async function holding(keys, fn) {
|
|
56
|
+
db.getPermissionKeysForBuilder = async () => keys;
|
|
57
|
+
try {
|
|
58
|
+
return await fn();
|
|
59
|
+
} finally {
|
|
60
|
+
db.getPermissionKeysForBuilder = realGetPermissionKeys;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const EVERYTHING = permissionKeys();
|
|
64
|
+
|
|
65
|
+
// Families with BOTH sides declared — the pairs "own must not imply any" is about.
|
|
66
|
+
const PAIRED = Object.entries(RESOURCE_FAMILIES).filter(([, f]) => f.own.length && f.any.length);
|
|
67
|
+
|
|
68
|
+
// ═══ A — the axis is explicit, well-formed catalog DATA ═══════════════════════
|
|
69
|
+
|
|
70
|
+
test('A1: scope and resource come together, with a valid scope and a real rank floor', () => {
|
|
71
|
+
// byKey() returns the NORMALIZED record (absent scope/resource read back as
|
|
72
|
+
// null), the same shape R98's principal axis normalizes to.
|
|
73
|
+
for (const p of permissionKeys().map(byKey)) {
|
|
74
|
+
assert.equal(p.scope === null, p.resource === null,
|
|
75
|
+
`${p.key}: an ownership scope is always scoped OVER a named resource`);
|
|
76
|
+
if (p.scope === null) continue;
|
|
77
|
+
assert.ok(['own', 'any'].includes(p.scope), `${p.key}: invalid scope "${p.scope}"`);
|
|
78
|
+
assert.equal(typeof p.resource, 'string');
|
|
79
|
+
assert.ok(p.resource.length > 0, `${p.key}: empty resource family`);
|
|
80
|
+
assert.notEqual(p.floor, null,
|
|
81
|
+
`${p.key}: the ownership axis narrows a ROLE permission — it never composes with an identity`);
|
|
82
|
+
assert.equal(isPrincipalKey(p.key), false, `${p.key}: a principal has no owner axis`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('A2: every ".own"-suffixed key DECLARES scope own — the namespace never outruns the data', () => {
|
|
87
|
+
const suffixed = PERMISSIONS.filter((p) => p.key.endsWith('.own'));
|
|
88
|
+
assert.ok(suffixed.length >= 4, `expected the own-scoped keys, saw ${suffixed.length}`);
|
|
89
|
+
for (const p of suffixed) {
|
|
90
|
+
assert.equal(p.scope, 'own', `${p.key} looks ownership-scoped but does not declare it`);
|
|
91
|
+
}
|
|
92
|
+
// ...and the converse is deliberately NOT asserted: `me.wandering.set` is
|
|
93
|
+
// own-scoped with no suffix, and `task.claim.any` ends in `.any` while meaning
|
|
94
|
+
// "any task in the pool", not "any owner's resource". Suffix inference would get
|
|
95
|
+
// both wrong — which is the whole reason the axis is data.
|
|
96
|
+
assert.equal(scopeOf('me.wandering.set'), 'own');
|
|
97
|
+
assert.equal(scopeOf('task.claim.any'), null, 'task.claim.any is a POOL filter, not an owner scope');
|
|
98
|
+
assert.equal(isCrossOwner('task.claim.any'), false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('A3: the modelled families are the ones the ownership sweep names', () => {
|
|
102
|
+
assert.deepEqual(Object.keys(RESOURCE_FAMILIES).sort(),
|
|
103
|
+
['box', 'builder_prefs', 'claim', 'credential', 'goal', 'memory']);
|
|
104
|
+
assert.deepEqual([...ownershipScopedKeys()].sort(),
|
|
105
|
+
['box.manage.own', 'credential.manage.own', 'goal.manage.own', 'me.wandering.set', 'memory.manage.own', 'task.act.own']);
|
|
106
|
+
// The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`.
|
|
107
|
+
assert.deepEqual([...crossOwnerKeys()].sort(),
|
|
108
|
+
['box.fleet.manage', 'claim.release.any', 'memory.read.any']);
|
|
109
|
+
assert.deepEqual(crossOwnerKeysFor('task.act.own'), ['claim.release.any']);
|
|
110
|
+
assert.deepEqual(crossOwnerKeysFor('box.manage.own'), ['box.fleet.manage']);
|
|
111
|
+
// Some resources have NO cross-owner form by design — nobody manages another
|
|
112
|
+
// builder's credential, Archon included.
|
|
113
|
+
assert.deepEqual(crossOwnerKeysFor('credential.manage.own'), []);
|
|
114
|
+
assert.deepEqual(crossOwnerKeysFor('nope.not.a.key'), []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('A4: RESOURCE_FAMILIES is frozen and agrees with each row it lists', () => {
|
|
118
|
+
assert.ok(Object.isFrozen(RESOURCE_FAMILIES));
|
|
119
|
+
for (const [resource, fam] of Object.entries(RESOURCE_FAMILIES)) {
|
|
120
|
+
assert.ok(Object.isFrozen(fam) && Object.isFrozen(fam.own) && Object.isFrozen(fam.any));
|
|
121
|
+
for (const key of fam.own) {
|
|
122
|
+
assert.equal(resourceOf(key), resource);
|
|
123
|
+
assert.equal(scopeOf(key), 'own');
|
|
124
|
+
}
|
|
125
|
+
for (const key of fam.any) {
|
|
126
|
+
assert.equal(resourceOf(key), resource);
|
|
127
|
+
assert.equal(scopeOf(key), 'any');
|
|
128
|
+
}
|
|
129
|
+
assert.ok(fam.own.length > 0, `${resource}: a cross-owner key with no own counterpart is a mis-declaration`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ═══ B — `.own` does NOT imply `.any` (oracle: floors × the real rank gate) ════
|
|
134
|
+
|
|
135
|
+
test('B1: for every paired family some rank reaches its OWN row but not everyone else\'s', () => {
|
|
136
|
+
assert.ok(PAIRED.length >= 3, `expected the box/claim/memory pairs, saw ${PAIRED.length}`);
|
|
137
|
+
for (const [resource, fam] of PAIRED) {
|
|
138
|
+
for (const ownKey of fam.own) {
|
|
139
|
+
for (const anyKey of fam.any) {
|
|
140
|
+
// Independent oracle: the floors (a pre-R100 field) run through the REAL
|
|
141
|
+
// production threshold function. A separation asserted this way cannot be
|
|
142
|
+
// faked by anything R100 added.
|
|
143
|
+
const separating = RANK_ORDER.filter((rank) =>
|
|
144
|
+
auth.rankMeetsThreshold(rank, [byKey(ownKey).floor]) &&
|
|
145
|
+
!auth.rankMeetsThreshold(rank, [byKey(anyKey).floor]));
|
|
146
|
+
assert.ok(separating.length > 0,
|
|
147
|
+
`${resource}: ${ownKey} and ${anyKey} are held by exactly the same ranks — "own" collapses into "any"`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('B2: the seed grants the own key to ranks it withholds the cross-owner key from', () => {
|
|
154
|
+
for (const [resource, fam] of PAIRED) {
|
|
155
|
+
for (const ownKey of fam.own) {
|
|
156
|
+
const ownOnly = RANK_ORDER.filter((r) => RANK_ROLE_SEED[r].includes(ownKey) && !fam.any.some((a) => RANK_ROLE_SEED[r].includes(a)));
|
|
157
|
+
assert.ok(ownOnly.length > 0, `${resource}: no rank holds ${ownKey} without also holding ${fam.any.join('/')}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// The concrete claim, spelled out: a xenos may act on their own claim and their
|
|
161
|
+
// own box; they may not release another builder's claim or close another's box.
|
|
162
|
+
assert.ok(RANK_ROLE_SEED.xenos.includes('task.act.own'));
|
|
163
|
+
assert.ok(!RANK_ROLE_SEED.xenos.includes('claim.release.any'));
|
|
164
|
+
assert.ok(RANK_ROLE_SEED.xenos.includes('box.manage.own'));
|
|
165
|
+
assert.ok(!RANK_ROLE_SEED.xenos.includes('box.fleet.manage'));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('B3: cross-owner authority is never CHEAPER than own-only authority', () => {
|
|
169
|
+
for (const [resource, fam] of PAIRED) {
|
|
170
|
+
for (const anyKey of fam.any) {
|
|
171
|
+
for (const ownKey of fam.own) {
|
|
172
|
+
assert.ok(RANK_ORDER.indexOf(byKey(anyKey).floor) >= RANK_ORDER.indexOf(byKey(ownKey).floor),
|
|
173
|
+
`${resource}: ${anyKey} sits below ${ownKey} — reaching every owner must not be easier than reaching your own`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ═══ C — THE key test: `.own` still fails against someone else's row ══════════
|
|
180
|
+
|
|
181
|
+
test('C1: holding EVERY permission in the catalog still cannot reach another builder\'s row', async () => {
|
|
182
|
+
// The degradation this whole axis exists to prevent. Even maximal role authority
|
|
183
|
+
// supplies only fact (1); fact (2) is false, so the answer must be no.
|
|
184
|
+
await holding(EVERYTHING, async () => {
|
|
185
|
+
for (const key of ownershipScopedKeys()) {
|
|
186
|
+
assert.equal(await authorizeOwned(ME, key, { ownerId: SOMEONE_ELSE }), false,
|
|
187
|
+
`${key} admitted against another builder's row — ".own" has degraded into an unconditional grant`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('C2: the same key on the caller\'s OWN row admits — but only while they hold it', async () => {
|
|
193
|
+
await holding(EVERYTHING, async () => {
|
|
194
|
+
for (const key of ownershipScopedKeys()) {
|
|
195
|
+
assert.equal(await authorizeOwned(ME, key, { ownerId: ME }), true, `${key} on own row`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
// Owning the row is not authority either — the conjunction cuts both ways.
|
|
199
|
+
await holding([], async () => {
|
|
200
|
+
for (const key of ownershipScopedKeys()) {
|
|
201
|
+
assert.equal(await authorizeOwned(ME, key, { ownerId: ME }), false,
|
|
202
|
+
`${key} admitted without the grant — owning a row is not a permission`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('C3: no owner supplied = no admit, however the caller spells the omission', async () => {
|
|
208
|
+
// The structural bit: `ownerId` is not an option, it is the second half of the
|
|
209
|
+
// question. A handler that forgets it gets a denial, never a pass-through.
|
|
210
|
+
await holding(EVERYTHING, async () => {
|
|
211
|
+
for (const context of [undefined, null, {}, { ownerId: null }, { ownerId: undefined }, { ownerId: '' }, { ownerId: {} }, { ownerId: [] }, { ownerId: true }, { ownerId: NaN }]) {
|
|
212
|
+
assert.equal(await authorizeOwned(ME, 'box.manage.own', context), false,
|
|
213
|
+
`an unidentifiable owner (${JSON.stringify(context)}) must deny`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('C4: authorizeOwned is the OWN entry point only — other keys fail closed', async () => {
|
|
219
|
+
await holding(EVERYTHING, async () => {
|
|
220
|
+
// A cross-owner key is not an ownership-scoped one; it has its own answer via
|
|
221
|
+
// the plain resolve, and must not acquire a second, owner-shaped route in.
|
|
222
|
+
assert.equal(await authorizeOwned(ME, 'claim.release.any', { ownerId: ME }), false);
|
|
223
|
+
// Nor an unscoped operational key, a system key, a principal key, or a typo.
|
|
224
|
+
assert.equal(await authorizeOwned(ME, 'idea.triage', { ownerId: ME }), false);
|
|
225
|
+
assert.equal(await authorizeOwned(ME, 'deploy.push_main', { ownerId: ME }), false);
|
|
226
|
+
assert.equal(await authorizeOwned(ME, 'principal.bfg', { ownerId: ME }), false);
|
|
227
|
+
assert.equal(await authorizeOwned(ME, 'nope.not.a.key', { ownerId: ME }), false);
|
|
228
|
+
// A blank actor cannot own anything, even matching a blank owner.
|
|
229
|
+
assert.equal(await authorizeOwned(null, 'box.manage.own', { ownerId: null }), false);
|
|
230
|
+
assert.equal(await authorizeOwned('', 'box.manage.own', { ownerId: '' }), false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('C5: builderHasPermissions REFUSES an ownership-scoped key rather than answering half of it', async () => {
|
|
235
|
+
await holding(EVERYTHING, async () => {
|
|
236
|
+
for (const key of ownershipScopedKeys()) {
|
|
237
|
+
assert.equal(await builderHasPermissions(ME, [key]), false,
|
|
238
|
+
`${key}: "do you hold it" has no unconditional answer — the caller wants authorizeOwned()`);
|
|
239
|
+
}
|
|
240
|
+
// One own key poisons the whole AND-list; unscoped keys still answer normally.
|
|
241
|
+
assert.equal(await builderHasPermissions(ME, ['idea.triage', 'box.manage.own']), false);
|
|
242
|
+
assert.equal(await builderHasPermissions(ME, ['idea.triage', 'cost.log']), true);
|
|
243
|
+
assert.equal(await builderHasPermissions(ME, ['claim.release.any']), true);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ═══ D — `.any` DOES reach another builder's resource ═════════════════════════
|
|
248
|
+
|
|
249
|
+
test('D1: the cross-owner key reaches a row the caller does not own', async () => {
|
|
250
|
+
await holding(['task.act.own', 'claim.release.any'], async () => {
|
|
251
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), true,
|
|
252
|
+
'claim.release.any is exactly the authority to act on someone else\'s claim');
|
|
253
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: ME }), true);
|
|
254
|
+
});
|
|
255
|
+
await holding(['memory.manage.own', 'memory.read.any'], async () => {
|
|
256
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), true);
|
|
257
|
+
});
|
|
258
|
+
await holding(['box.manage.own', 'box.fleet.manage'], async () => {
|
|
259
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('D2: holding only the OWN key never reaches someone else, in either entry point', async () => {
|
|
264
|
+
for (const [, fam] of PAIRED) {
|
|
265
|
+
for (const ownKey of fam.own) {
|
|
266
|
+
await holding([ownKey], async () => {
|
|
267
|
+
assert.equal(await authorizeOwnedOrAny(ME, ownKey, { ownerId: SOMEONE_ELSE }), false,
|
|
268
|
+
`${ownKey} alone must not reach another owner's row`);
|
|
269
|
+
assert.equal(await authorizeOwnedOrAny(ME, ownKey, { ownerId: ME }), true);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('D3: a family with no cross-owner form has no escalation, even at maximal authority', async () => {
|
|
276
|
+
await holding(EVERYTHING, async () => {
|
|
277
|
+
for (const key of ['credential.manage.own', 'me.wandering.set', 'goal.manage.own']) {
|
|
278
|
+
assert.equal(await authorizeOwnedOrAny(ME, key, { ownerId: SOMEONE_ELSE }), false,
|
|
279
|
+
`${key} has no declared cross-owner counterpart — nothing may escalate past it`);
|
|
280
|
+
}
|
|
281
|
+
// and the entry point still refuses a non-own key
|
|
282
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'claim.release.any', { ownerId: ME }), false);
|
|
283
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'nope.not.a.key', { ownerId: ME }), false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ═══ E — the owner comparison itself ══════════════════════════════════════════
|
|
288
|
+
|
|
289
|
+
test('E1: sameOwner matches scalar ids across the number/string boundary, nothing else', () => {
|
|
290
|
+
// Postgres hands bigints back as strings while JS code carries them as numbers,
|
|
291
|
+
// so 7 and '7' are the same owner.
|
|
292
|
+
assert.equal(sameOwner(7, '7'), true);
|
|
293
|
+
assert.equal(sameOwner('7', 7), true);
|
|
294
|
+
assert.equal(sameOwner(7n, '7'), true);
|
|
295
|
+
assert.equal(sameOwner(7, 7), true);
|
|
296
|
+
assert.equal(sameOwner('abc', 'abc'), true);
|
|
297
|
+
assert.equal(sameOwner(7, 8), false);
|
|
298
|
+
assert.equal(sameOwner('7', '07'), false);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('E2: nothing without a scalar identity ever matches — including two of the same', () => {
|
|
302
|
+
const noIdentity = [null, undefined, '', {}, [], true, false, NaN, Infinity, -Infinity, () => {}];
|
|
303
|
+
for (const v of noIdentity) {
|
|
304
|
+
assert.equal(sameOwner(v, 7), false, `${String(v)} must not match a real id`);
|
|
305
|
+
assert.equal(sameOwner(7, v), false, `${String(v)} must not match a real id`);
|
|
306
|
+
assert.equal(sameOwner(v, v), false, `two unidentifiable values must not "match" each other (${String(v)})`);
|
|
307
|
+
}
|
|
308
|
+
// No trimming, no case-folding: normalizing harder than the data warrants is how
|
|
309
|
+
// a comparison quietly widens.
|
|
310
|
+
assert.equal(sameOwner(' 7', 7), false);
|
|
311
|
+
assert.equal(sameOwner('7 ', '7'), false);
|
|
312
|
+
assert.equal(sameOwner('ABC', 'abc'), false);
|
|
313
|
+
// type confusion via toString/valueOf gets no purchase
|
|
314
|
+
assert.equal(sameOwner({ toString: () => '7' }, 7), false);
|
|
315
|
+
assert.equal(sameOwner([7], 7), false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ═══ F — the load-time invariants actually throw ══════════════════════════════
|
|
319
|
+
// Proven by loading a MUTATED COPY of the real catalog, so what is under test is
|
|
320
|
+
// the shipped assertion block, not a re-implementation of it.
|
|
321
|
+
|
|
322
|
+
function loadMutatedCatalog(label, mutate) {
|
|
323
|
+
const source = fs.readFileSync(path.join(GOV, 'catalog.js'), 'utf8');
|
|
324
|
+
const mutated = mutate(source);
|
|
325
|
+
assert.notEqual(mutated, source, `${label}: the fixture mutation did not apply — the test would be vacuous`);
|
|
326
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-catalog-'));
|
|
327
|
+
const file = path.join(dir, 'catalog.js');
|
|
328
|
+
fs.writeFileSync(file, mutated);
|
|
329
|
+
try {
|
|
330
|
+
require(file);
|
|
331
|
+
} finally {
|
|
332
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test('F1: a ".own" key that lost its declared scope throws at module load', () => {
|
|
337
|
+
assert.throws(
|
|
338
|
+
() => loadMutatedCatalog('missing scope', (s) => s.replace(
|
|
339
|
+
`{ key: 'box.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'box',`,
|
|
340
|
+
`{ key: 'box.manage.own', system: false, floor: 'xenos',`)),
|
|
341
|
+
/does not declare scope/,
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('F2: an ownership scope with no resource family throws at module load', () => {
|
|
346
|
+
assert.throws(
|
|
347
|
+
() => loadMutatedCatalog('scope without resource', (s) => s.replace(
|
|
348
|
+
`scope: 'own', resource: 'box',`, `scope: 'own',`)),
|
|
349
|
+
/scope and resource must come together/,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('F3: a cross-owner key cheaper than its own-scoped counterpart throws at module load', () => {
|
|
354
|
+
assert.throws(
|
|
355
|
+
() => loadMutatedCatalog('inverted floors', (s) => s.replace(
|
|
356
|
+
` { key: 'goal.manage.own',`,
|
|
357
|
+
` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
358
|
+
/must never be cheaper than own-only/,
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('F4: a cross-owner key with no ownership-scoped counterpart throws at module load', () => {
|
|
363
|
+
assert.throws(
|
|
364
|
+
() => loadMutatedCatalog('lone any', (s) => s.replace(
|
|
365
|
+
` { key: 'goal.manage.own',`,
|
|
366
|
+
` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
367
|
+
/with no ownership-scoped counterpart/,
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ═══ G — the R94/R95/R98 invariants this slice must not have disturbed ════════
|
|
372
|
+
|
|
373
|
+
test('G1: the seed is still floor-derived and cumulative — the ownership axis adds no grants', () => {
|
|
374
|
+
for (const rank of RANK_ORDER) {
|
|
375
|
+
const held = new Set(RANK_ROLE_SEED[rank]);
|
|
376
|
+
const tier = RANK_ORDER.indexOf(rank);
|
|
377
|
+
for (const p of PERMISSIONS) {
|
|
378
|
+
assert.equal(held.has(p.key), p.floor !== null && RANK_ORDER.indexOf(p.floor) <= tier,
|
|
379
|
+
`${rank} vs ${p.key}: scope must not touch the floor derivation`);
|
|
380
|
+
}
|
|
381
|
+
assert.deepEqual([...seedForRank(rank)].sort(), [...RANK_ROLE_SEED[rank]].sort());
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('G2: the role resolve is untouched — own-scoped keys still resolve, principals still drop', () => {
|
|
386
|
+
// Deliberate: resolveBuilderPermissions stays the raw ROLE-axis answer, which is
|
|
387
|
+
// what R103's parity proof pins. Ownership narrows at the DECISION, not by
|
|
388
|
+
// deleting the key from the resolved set.
|
|
389
|
+
const resolved = effectivePermissions(EVERYTHING);
|
|
390
|
+
for (const key of ownershipScopedKeys()) {
|
|
391
|
+
assert.ok(resolved.includes(key), `${key} must still resolve — parity depends on it`);
|
|
392
|
+
}
|
|
393
|
+
assert.deepEqual(effectivePermissions(['principal.bfg', 'box.manage.own']), ['box.manage.own']);
|
|
394
|
+
assert.deepEqual(effectivePermissions(['idea.triage', 'not.a.real.permission']), ['idea.triage']);
|
|
395
|
+
assert.deepEqual(effectivePermissions([]), []);
|
|
396
|
+
assert.ok(Object.isFrozen(resolved));
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('G3: the kernel port carries the ownership axis on the ONE governance port', () => {
|
|
400
|
+
const { governancePort } = require(path.join(GOV, 'routes', 'governance.js'));
|
|
401
|
+
for (const name of ['authorizeOwned', 'authorizeOwnedOrAny', 'sameOwner', 'isOwnershipScoped', 'crossOwnerKeysFor']) {
|
|
402
|
+
assert.equal(typeof governancePort[name], 'function', `the port must expose ${name}`);
|
|
403
|
+
}
|
|
404
|
+
// still the same one object port the earlier slices registered
|
|
405
|
+
assert.equal(typeof governancePort.resolveBuilderPermissions, 'function');
|
|
406
|
+
assert.equal(typeof governancePort.resolvePrincipalPermissions, 'function');
|
|
407
|
+
assert.equal(governancePort.isOwnershipScoped('box.manage.own'), true);
|
|
408
|
+
assert.equal(governancePort.isOwnershipScoped('claim.release.any'), false);
|
|
409
|
+
assert.equal(governancePort.isOwnershipScoped('nope.not.a.key'), false);
|
|
410
|
+
});
|