@bongos/core 1.18.3 → 1.18.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +1575 -285
- package/.claude/skills/builder-box/SKILL.md +6 -0
- package/.claude/skills/builder-sequence/SKILL.md +97 -0
- package/.claude/skills/builder-ship/SKILL.md +8 -2
- package/.claude/skills/builder-start/SKILL.md +11 -0
- package/.claude/skills/builder-sync/SKILL.md +41 -0
- package/.claude/skills/fix-task/SKILL.md +64 -0
- package/.claude/skills/goal-create/SKILL.md +158 -0
- package/.claude/skills/goal-review/SKILL.md +3 -1
- package/.claude/skills/idea-triage/SKILL.md +14 -8
- package/.claude/skills/merge-mode/SKILL.md +2 -2
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +45 -18
- package/.env.local.example +4 -3
- package/README.md +4 -2
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +2 -2
- package/clients/bongos-client/bongos-client.global.js +82 -18
- package/clients/bongos-client/index.cjs +82 -18
- package/clients/bongos-client/index.d.ts +131 -29
- package/clients/bongos-client/index.mjs +82 -18
- package/config/branding.neutral.json +6 -1
- package/config/governance.neutral.json +21 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
- package/docs/adr/0090-metic-task-authoring.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
- package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
- package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
- package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
- package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
- package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
- package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
- package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
- package/docs/adr/0152-metic-task-abandonment.md +67 -0
- package/docs/adr/0153-sequential-chain-runner.md +51 -0
- package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
- package/docs/adr/README.md +10 -0
- package/docs/api/openapi.json +843 -17
- package/docs/api-reference.md +23 -10
- package/docs/architecture.md +4 -2
- package/docs/branding-contract.md +3 -1
- package/docs/canonical-permissions.md +21 -8
- package/docs/file-map.md +8 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
- package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
- package/docs/onboarding/diagrams/assertions.json +4 -7
- package/docs/onboarding/drachmae-and-karma.md +3 -3
- package/docs/onboarding/primer.md +17 -16
- package/docs/onboarding/slash-commands.md +124 -0
- package/docs/recipes/builder-box-lifecycle.md +23 -0
- package/docs/recipes/devbox-app-release.md +14 -4
- package/docs/recipes/instance-first-run.md +16 -0
- package/docs/recipes/local-dev.md +81 -0
- package/docs/recipes/ops-gotchas.md +19 -1
- package/docs/recipes/overnight-builder.md +1 -1
- package/docs/recipes/parallel-goal-waves.md +174 -0
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/standalone-live-docs.md +48 -0
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
- package/migrations/003_pms.sql +6 -7
- package/migrations/006_pms_v2.sql +3 -31
- package/migrations/core_193_task_needs_rebase.sql +28 -0
- package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
- package/migrations/core_195_idea_triage_provenance.sql +57 -0
- package/migrations/core_196_session_reward_topup.sql +69 -0
- package/modules/autonomy/pollers/routine-timers.js +9 -8
- package/modules/builder-settings/wandering-prefs.js +61 -18
- package/modules/dev-box/app/README.md +73 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
- package/modules/dev-box/app/electron-builder.yml +95 -0
- package/modules/dev-box/app/package-lock.json +5291 -0
- package/modules/dev-box/app/package.json +30 -0
- package/modules/dev-box/app/renderer/app.js +319 -0
- package/modules/dev-box/app/renderer/index.html +45 -0
- package/modules/dev-box/app/renderer/styles.css +246 -0
- package/modules/dev-box/app/renderer/theme.generated.css +16 -0
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
- package/modules/dev-box/app/scripts/gen-brand.js +138 -0
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
- package/modules/dev-box/app/scripts/gen-icons.js +286 -0
- package/modules/dev-box/app/scripts/vendor-client.js +23 -0
- package/modules/dev-box/app/src/box-controller.js +287 -0
- package/modules/dev-box/app/src/brand.generated.json +13 -0
- package/modules/dev-box/app/src/brand.js +16 -0
- package/modules/dev-box/app/src/config.js +19 -0
- package/modules/dev-box/app/src/gds-client.js +90 -0
- package/modules/dev-box/app/src/main.js +610 -0
- package/modules/dev-box/app/src/pairing.js +57 -0
- package/modules/dev-box/app/src/preload.js +29 -0
- package/modules/dev-box/app/src/session-store.js +58 -0
- package/modules/dev-box/app/src/ssh-setup.js +569 -0
- package/modules/dev-box/app/src/task-picker.js +70 -0
- package/modules/dev-box/app/src/tray.js +99 -0
- package/modules/dev-box/app/src/updater.js +55 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
- package/modules/dev-box/app/test/unit.mjs +526 -0
- package/modules/dev-box/box-access.js +121 -4
- package/modules/dev-box/boxes.js +20 -0
- package/modules/dev-box/module.json +1 -1
- package/modules/dev-box/routes/box.js +87 -3
- package/modules/economy/credits.js +116 -25
- package/modules/economy/reward.js +8 -0
- package/modules/economy/streak.js +2 -1
- package/modules/governance/CLAUDE.md +15 -0
- package/modules/governance/catalog.js +419 -0
- package/modules/governance/config.js +174 -0
- package/modules/governance/db.js +125 -0
- package/modules/governance/migrations/governance_001_tables.sql +81 -0
- package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
- package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
- package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
- package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
- package/modules/governance/module.json +10 -0
- package/modules/governance/protected-surfaces.js +88 -0
- package/modules/governance/protected-surfaces.json +80 -0
- package/modules/governance/rank-roles.js +83 -0
- package/modules/governance/resolver.js +221 -0
- package/modules/governance/routes/governance.js +89 -0
- package/modules/governance/routes/role-sync.js +49 -0
- package/modules/governance/session-scopes.js +127 -0
- package/modules/governance/session-scopes.json +38 -0
- package/modules/hall-ui/public/atlas.css +117 -0
- package/modules/hall-ui/public/atlas.html +3 -114
- package/modules/hall-ui/public/brand-apply.js +34 -6
- package/modules/hall-ui/public/builders.js +187 -219
- package/modules/hall-ui/public/diagrams.html +2 -0
- package/modules/hall-ui/public/dom-utils.js +27 -0
- package/modules/hall-ui/public/drachmae.html +2 -0
- package/modules/hall-ui/public/drachmae.js +54 -1
- package/modules/hall-ui/public/gate.css +98 -0
- package/modules/hall-ui/public/gate.html +3 -95
- package/modules/hall-ui/public/goal-inbox.js +4 -1
- package/modules/hall-ui/public/goals-page.js +626 -114
- package/modules/hall-ui/public/goals.css +79 -1
- package/modules/hall-ui/public/goals.html +38 -11
- package/modules/hall-ui/public/hall-widgets.js +62 -52
- package/modules/hall-ui/public/harbor.css +123 -0
- package/modules/hall-ui/public/harbor.html +3 -120
- package/modules/hall-ui/public/index.html +11 -4
- package/modules/hall-ui/public/instances-panel.js +4 -1
- package/modules/hall-ui/public/modules.css +63 -0
- package/modules/hall-ui/public/modules.html +3 -60
- package/modules/hall-ui/public/new-project-wizard.js +7 -3
- package/modules/hall-ui/public/not-ready.html +2 -0
- package/modules/hall-ui/public/pair.css +73 -0
- package/modules/hall-ui/public/pair.html +3 -70
- package/modules/hall-ui/public/palette.js +302 -0
- package/modules/hall-ui/public/primer.css +13 -0
- package/modules/hall-ui/public/primer.html +2 -0
- package/modules/hall-ui/public/profile.html +2 -0
- package/modules/hall-ui/public/ranks.html +2 -0
- package/modules/hall-ui/public/roadmap.css +179 -0
- package/modules/hall-ui/public/roadmap.html +9 -172
- package/modules/hall-ui/public/roadmap.js +329 -316
- package/modules/hall-ui/public/sessions.css +16 -0
- package/modules/hall-ui/public/sessions.html +3 -13
- package/modules/hall-ui/public/settings.html +2 -0
- package/modules/hall-ui/public/settings.js +4 -4
- package/modules/hall-ui/public/shell.js +143 -18
- package/modules/hall-ui/public/style.css +231 -39
- package/modules/hall-ui/public/task-detail.js +347 -26
- package/modules/hall-ui/public/task.html +6 -1
- package/modules/hall-ui/public/watch.html +2 -0
- package/modules/hall-ui/public/work.html +8 -6
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/ideas/inbox.js +61 -11
- package/modules/ideas/routes/inbox.js +23 -0
- package/modules/lifecycle/claim-eligibility.js +72 -0
- package/modules/lifecycle/db.js +519 -139
- package/modules/lifecycle/done-when.js +18 -3
- package/modules/lifecycle/github-push.js +19 -2
- package/modules/lifecycle/goal-advisory.js +114 -0
- package/modules/lifecycle/goal-authz.js +62 -0
- package/modules/lifecycle/lifecycle.js +9 -0
- package/modules/lifecycle/publish-reconciler.js +109 -6
- package/modules/lifecycle/routes/claims.js +68 -23
- package/modules/lifecycle/routes/done-when.js +110 -15
- package/modules/lifecycle/routes/goals.js +8 -38
- package/modules/lifecycle/routes/tasks.js +38 -6
- package/modules/lifecycle/ship-card.js +6 -0
- package/modules/onboarding/onboarding-state.js +1 -1
- package/modules/platform-identity/CLAUDE.md +15 -0
- package/modules/platform-identity/hub-device.js +147 -0
- package/modules/platform-identity/hub-keys.js +151 -0
- package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
- package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
- package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
- package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
- package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
- package/modules/platform-identity/module.json +18 -0
- package/modules/platform-identity/platform-identity.js +531 -0
- package/modules/platform-identity/pollers/code-sweep.js +43 -0
- package/modules/platform-identity/routes/my-projects.js +140 -0
- package/modules/platform-identity/routes/projects.js +107 -0
- package/modules/platform-identity/routes/scouting.js +35 -0
- package/modules/platform-identity/routes/sso.js +402 -0
- package/modules/platform-identity/tests/platform-identity.mjs +388 -0
- package/modules/platform-identity/ui/.gitkeep +0 -0
- package/modules/security/routes/reports.js +10 -0
- package/modules/sessions/db.js +166 -21
- package/modules/sessions/module.json +1 -1
- package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
- package/modules/sessions/routes/sessions.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/anim/idle/frame_00.webp +0 -0
- package/public-landing/assets/anim/idle/frame_01.webp +0 -0
- package/public-landing/assets/anim/idle/frame_02.webp +0 -0
- package/public-landing/assets/anim/idle/frame_03.webp +0 -0
- package/public-landing/assets/anim/idle/frame_04.webp +0 -0
- package/public-landing/assets/anim/idle/frame_05.webp +0 -0
- package/public-landing/assets/anim/idle/frame_06.webp +0 -0
- package/public-landing/assets/anim/idle/frame_07.webp +0 -0
- package/public-landing/assets/anim/idle/frame_08.webp +0 -0
- package/public-landing/assets/anim/idle/frame_09.webp +0 -0
- package/public-landing/assets/anim/idle/frame_10.webp +0 -0
- package/public-landing/assets/anim/idle/frame_11.webp +0 -0
- package/public-landing/assets/anim/idle/frame_12.webp +0 -0
- package/public-landing/assets/anim/idle/frame_13.webp +0 -0
- package/public-landing/assets/anim/idle/frame_14.webp +0 -0
- package/public-landing/assets/anim/idle/frame_15.webp +0 -0
- package/public-landing/assets/anim/idle/frame_16.webp +0 -0
- package/public-landing/assets/anim/idle/frame_17.webp +0 -0
- package/public-landing/assets/anim/idle/frame_18.webp +0 -0
- package/public-landing/assets/anim/idle/frame_19.webp +0 -0
- package/public-landing/assets/anim/idle/frame_20.webp +0 -0
- package/public-landing/assets/anim/idle/frame_21.webp +0 -0
- package/public-landing/assets/anim/idle/frame_22.webp +0 -0
- package/public-landing/assets/anim/idle/frame_23.webp +0 -0
- package/public-landing/assets/anim/idle/frame_24.webp +0 -0
- package/public-landing/assets/anim/idle/frame_25.webp +0 -0
- package/public-landing/assets/anim/idle/frame_26.webp +0 -0
- package/public-landing/assets/anim/idle/frame_27.webp +0 -0
- package/public-landing/assets/anim/idle/frame_28.webp +0 -0
- package/public-landing/assets/anim/idle/frame_29.webp +0 -0
- package/public-landing/assets/anim/idle/frame_30.webp +0 -0
- package/public-landing/assets/anim/idle/frame_31.webp +0 -0
- package/public-landing/assets/anim/idle/frame_32.webp +0 -0
- package/public-landing/assets/anim/idle/frame_33.webp +0 -0
- package/public-landing/assets/anim/idle/frame_34.webp +0 -0
- package/public-landing/assets/anim/idle/frame_35.webp +0 -0
- package/public-landing/assets/anim/idle/frame_36.webp +0 -0
- package/public-landing/assets/anim/idle/frame_37.webp +0 -0
- package/public-landing/assets/anim/idle/frame_38.webp +0 -0
- package/public-landing/assets/anim/idle/frame_39.webp +0 -0
- package/public-landing/assets/anim/idle/frame_40.webp +0 -0
- package/public-landing/assets/anim/idle/frame_41.webp +0 -0
- package/public-landing/assets/anim/idle/frame_42.webp +0 -0
- package/public-landing/assets/anim/idle/frame_43.webp +0 -0
- package/public-landing/create.html +51 -58
- package/public-landing/gate/README.md +137 -0
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +587 -0
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +799 -0
- package/public-landing/gate/space.js +1826 -0
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
- package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
- package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
- package/public-landing/world/assets/plates/scene-establishing.png +0 -0
- package/scripts/gds/abandon.js +6 -4
- package/scripts/gds/api.js +10 -5
- package/scripts/gds/api.ps1 +26 -0
- package/scripts/gds/api.sh +6 -0
- package/scripts/gds/audit-ships.js +7 -1
- package/scripts/gds/backfill-sessions.js +78 -18
- package/scripts/gds/backup.js +23 -1
- package/scripts/gds/box-connect-lib.js +124 -31
- package/scripts/gds/box-sync.js +66 -0
- package/scripts/gds/box.js +225 -53
- package/scripts/gds/cf-tunnel.js +145 -6
- package/scripts/gds/claim.js +34 -3
- package/scripts/gds/cli-lib.js +161 -27
- package/scripts/gds/code.js +6 -2
- package/scripts/gds/deploy-divergence.js +209 -0
- package/scripts/gds/dev-lib.js +309 -0
- package/scripts/gds/dev.js +322 -0
- package/scripts/gds/doctor.js +90 -61
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/fetch-sound-prefs.js +2 -2
- package/scripts/gds/fitness.js +228 -12
- package/scripts/gds/gds-db-backup.service +16 -1
- package/scripts/gds/gds-db-offsite.service +9 -1
- package/scripts/gds/gen-api-client.js +1 -1
- package/scripts/gds/gen-api-docs.js +36 -14
- package/scripts/gds/gen-diagrams.js +45 -28
- package/scripts/gds/gen-file-map.js +2 -1
- package/scripts/gds/gen-freshness.js +32 -0
- package/scripts/gds/gen-repo-map.js +3 -2
- package/scripts/gds/gen-session-index.js +14 -5
- package/scripts/gds/hooks/pre-push.js +2 -2
- package/scripts/gds/init.js +60 -3
- package/scripts/gds/install-git-hooks.js +1 -1
- package/scripts/gds/key-card.js +15 -6
- package/scripts/gds/key-status.js +1 -1
- package/scripts/gds/linkify-refs.js +35 -9
- package/scripts/gds/login.js +16 -1
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +123 -12
- package/scripts/gds/publish-manifest.js +7 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +4 -2
- package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
- package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
- package/scripts/gds/sequence.js +425 -0
- package/scripts/gds/session-cost.js +10 -1
- package/scripts/gds/setup.js +31 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +319 -29
- package/scripts/gds/smoke-gds.js +139 -8
- package/scripts/gds/start.js +171 -22
- package/scripts/gds/status.js +1 -1
- package/scripts/gds/triage.js +10 -9
- package/scripts/gds/upgrade.js +124 -2
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +447 -102
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +39 -0
- package/src/bongos/module-scope-map.js +27 -1
- package/src/bongos/permission-path-check.js +136 -102
- package/src/bongos/repo-info.js +47 -11
- package/src/bongos/route-rank-check.js +429 -46
- package/src/bongos/routes/_helpers.js +12 -1
- package/src/bongos/routes/auth.js +219 -2
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +32 -0
- package/src/instance-config.js +30 -0
- package/src/module-api.js +112 -1
- package/src/platform-server.js +86 -9
- package/tests/abandon_task.mjs +59 -3
- package/tests/api_cli_flags.mjs +53 -0
- package/tests/auth_page_gate.mjs +13 -0
- package/tests/backfill_sessions.mjs +127 -1
- package/tests/backup_cli.mjs +54 -0
- package/tests/bongos_box_connect.mjs +77 -0
- package/tests/box_access.mjs +130 -1
- package/tests/box_credential.mjs +125 -0
- package/tests/box_host_keys.mjs +24 -0
- package/tests/box_infra_resolve.mjs +149 -0
- package/tests/box_scope_session.mjs +6 -2
- package/tests/box_sync.mjs +55 -0
- package/tests/box_task_scope.mjs +99 -0
- package/tests/branding.mjs +12 -0
- package/tests/cf_ruleset_preflight.mjs +142 -0
- package/tests/cf_tunnel.mjs +37 -0
- package/tests/claim_gate_rebase.mjs +98 -0
- package/tests/clawback_confirmed_backlog.mjs +103 -0
- package/tests/cli_lib_formatters.mjs +20 -0
- package/tests/confirm_attribution.mjs +30 -46
- package/tests/credit_grant.mjs +198 -10
- package/tests/criterion_authoring_gate.mjs +272 -0
- package/tests/criterion_progress.mjs +33 -1
- package/tests/dev_lib.mjs +253 -0
- package/tests/devbox_artifact_gate.mjs +75 -0
- package/tests/devbox_dmg_backdrop.mjs +137 -0
- package/tests/devbox_icons.mjs +304 -0
- package/tests/devbox_installer_brand.mjs +232 -0
- package/tests/devbox_key_register.mjs +234 -0
- package/tests/docs_openapi_route.mjs +110 -0
- package/tests/doctor_hooks.mjs +141 -0
- package/tests/extract_json.mjs +140 -0
- package/tests/fitness.mjs +89 -0
- package/tests/gen_freshness.mjs +96 -0
- package/tests/goal_advisory.mjs +251 -0
- package/tests/goal_criterion_confirm_ui.mjs +269 -0
- package/tests/goal_routes.mjs +59 -4
- package/tests/goal_tier.mjs +12 -3
- package/tests/governance_catalog.mjs +129 -0
- package/tests/governance_claim_eligibility.mjs +262 -0
- package/tests/governance_config.mjs +187 -0
- package/tests/governance_ownership_scope.mjs +410 -0
- package/tests/governance_page_gates.mjs +324 -0
- package/tests/governance_parity.mjs +336 -0
- package/tests/governance_principals.mjs +232 -0
- package/tests/governance_protected_surfaces.mjs +208 -0
- package/tests/governance_require_permission.mjs +223 -0
- package/tests/governance_resolver.mjs +84 -0
- package/tests/governance_role_sync.mjs +231 -0
- package/tests/governance_seed.mjs +127 -0
- package/tests/governance_session_scope.mjs +434 -0
- package/tests/governance_vanilla_boot.mjs +203 -0
- package/tests/grade_attribution.mjs +7 -7
- package/tests/hall_audit.mjs +223 -0
- package/tests/hall_nav.mjs +191 -0
- package/tests/hall_palette.mjs +206 -0
- package/tests/hall_signout.mjs +124 -0
- package/tests/hook_user_agents.mjs +10 -0
- package/tests/idea_triage.mjs +162 -0
- package/tests/idp_device_exchange.mjs +93 -0
- package/tests/init.mjs +51 -0
- package/tests/instance_config.mjs +65 -0
- package/tests/instance_mismatch_guard.mjs +23 -6
- package/tests/instance_release_config.mjs +104 -0
- package/tests/linkify_refs.mjs +15 -2
- package/tests/module_contributions.mjs +55 -0
- package/tests/module_loader.mjs +10 -5
- package/tests/module_scope_active_claims.mjs +94 -0
- package/tests/needs_rebase.mjs +92 -0
- package/tests/one_active_claim_per_session.mjs +76 -0
- package/tests/override_attribution.mjs +10 -8
- package/tests/override_request_ownership.mjs +8 -4
- package/tests/parse_cookie_decode.mjs +42 -0
- package/tests/pay_on_land.mjs +129 -0
- package/tests/platform_boot.mjs +100 -1
- package/tests/post_login_landing.mjs +42 -0
- package/tests/preflight_checks.mjs +72 -0
- package/tests/provision.mjs +132 -7
- package/tests/publish_branch_route.mjs +53 -0
- package/tests/publish_reconciler.mjs +187 -2
- package/tests/repo_info_binding.mjs +248 -0
- package/tests/reward_gate.mjs +11 -2
- package/tests/reward_mode_cost_plus_only.mjs +116 -0
- package/tests/reward_mode_surfacing.mjs +168 -0
- package/tests/route_permission_check.mjs +301 -0
- package/tests/route_rank_check.mjs +254 -12
- package/tests/route_validation.mjs +30 -0
- package/tests/sequence.mjs +267 -0
- package/tests/session_digest.mjs +44 -0
- package/tests/session_index.mjs +18 -6
- package/tests/session_records.mjs +39 -1
- package/tests/session_reward_on_land.mjs +381 -0
- package/tests/session_reward_reconcile.mjs +229 -0
- package/tests/ship_api_client_pathspec.mjs +100 -0
- package/tests/ship_base_flag.mjs +93 -0
- package/tests/ship_ci_deploy.mjs +80 -3
- package/tests/ship_upstream_deletion.mjs +57 -0
- package/tests/smoke_target.mjs +233 -0
- package/tests/start_env_guard.mjs +87 -0
- package/tests/start_rebase_warning.mjs +103 -0
- package/tests/status_mirror_sync.mjs +122 -0
- package/tests/stop_refcheck.mjs +19 -1
- package/tests/task_goal_link.mjs +15 -6
- package/tests/upgrade.mjs +139 -0
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
package/src/bongos/auth.js
CHANGED
|
@@ -41,6 +41,7 @@ const {
|
|
|
41
41
|
getBuilderById,
|
|
42
42
|
getBuilderByGithubId,
|
|
43
43
|
hasInvitedAccessRequest,
|
|
44
|
+
accessStateByLogin,
|
|
44
45
|
RANKS,
|
|
45
46
|
} = require('./db-kernel');
|
|
46
47
|
const { pool } = require('./pool');
|
|
@@ -250,6 +251,48 @@ function firstTimeAdmissionBlocked({ existing, approved, login }) {
|
|
|
250
251
|
return true;
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
// webAdmissionStatus — "would this login get in RIGHT NOW, and is it worth
|
|
255
|
+
// waiting?" (task 1002097). Backs the waiting page that replaced the red
|
|
256
|
+
// "not approved" dead-end a firstAdmin hits during the bootstrap window.
|
|
257
|
+
//
|
|
258
|
+
// `admitted` deliberately mirrors firstTimeAdmissionBlocked's own terms —
|
|
259
|
+
// member OR open-enrollment OR invited OR configured-firstAdmin — so the probe
|
|
260
|
+
// can never say "come in" while the real gate says otherwise. It reads state;
|
|
261
|
+
// it decides nothing. firstTimeAdmissionBlocked remains the one gate both flows
|
|
262
|
+
// call (SR-19); if that logic changes, this must change with it.
|
|
263
|
+
// tests/web_admission_status.mjs sweeps the input space asserting the two agree.
|
|
264
|
+
//
|
|
265
|
+
// `expectedSoon` is the broader "keep polling" signal: admitted, or waiting on
|
|
266
|
+
// an Archon (pending), or the configured firstAdmin whose instance is still
|
|
267
|
+
// booting. A visitor who is none of these is a genuine stranger and gets the
|
|
268
|
+
// existing request-access page rather than a spinner that never resolves.
|
|
269
|
+
//
|
|
270
|
+
// NB the onboarding module's public GET /access-requests/status (used by the
|
|
271
|
+
// CLI) reports member|invited|pending|none but knows nothing about the
|
|
272
|
+
// firstAdmin bypass or open enrollment — so it reports the OWNER as 'none'
|
|
273
|
+
// forever. That is exactly why this auth-domain probe exists.
|
|
274
|
+
async function webAdmissionStatus(login) {
|
|
275
|
+
const name = String(login || '').trim();
|
|
276
|
+
if (!name) return { admitted: false, expectedSoon: false };
|
|
277
|
+
|
|
278
|
+
const fa = firstAdminLogin();
|
|
279
|
+
const isFirstAdmin = !!fa && name.toLowerCase() === fa.toLowerCase();
|
|
280
|
+
|
|
281
|
+
let state = { member: false, invited: false, pending: false };
|
|
282
|
+
try {
|
|
283
|
+
state = await accessStateByLogin(name);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// A DB hiccup must not turn the waiting page into a false "you're in".
|
|
286
|
+
// Fail closed on `admitted`; still let a firstAdmin keep polling, since
|
|
287
|
+
// that is the bootstrap window this page exists for.
|
|
288
|
+
console.error('[auth] webAdmissionStatus', err);
|
|
289
|
+
return { admitted: false, expectedSoon: isFirstAdmin };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const admitted = state.member || openEnrollmentEnabled() || state.invited || isFirstAdmin;
|
|
293
|
+
return { admitted, expectedSoon: admitted || state.pending || isFirstAdmin };
|
|
294
|
+
}
|
|
295
|
+
|
|
253
296
|
// First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
|
|
254
297
|
// login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
|
|
255
298
|
// else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
|
|
@@ -284,6 +327,34 @@ async function maybeSeatFirstAdmin(builder) {
|
|
|
284
327
|
return builder;
|
|
285
328
|
}
|
|
286
329
|
|
|
330
|
+
// safeSyncRankRole — give a builder the seeded rank-role matching their rank at
|
|
331
|
+
// REGISTRATION (BV1.R95b, ADR 0151). governance_002's builder→role backfill is
|
|
332
|
+
// run-once, so before this every builder admitted after that migration had no role
|
|
333
|
+
// row at all and resolved to the EMPTY permission set — denied on every gated route.
|
|
334
|
+
//
|
|
335
|
+
// Called at each sign-in rather than only on first insert, and deliberately so: the
|
|
336
|
+
// call is idempotent (two SELECTs and no write once the role already matches), it
|
|
337
|
+
// also covers the rank write maybeSeatFirstAdmin just made (the firstAdmin bootstrap
|
|
338
|
+
// flips xenos→archon outside any transaction), and it quietly repairs a builder whose
|
|
339
|
+
// assignment drifted for any other reason.
|
|
340
|
+
//
|
|
341
|
+
// Resolved through the `governance.roleSync` kernel port — auth.js is a KERNEL_FILE
|
|
342
|
+
// and may not require a module (ADR 0083 / ADR 0091 §1). Runs on the pool: there is
|
|
343
|
+
// no surrounding transaction here, unlike the two rank-change paths, which pass their
|
|
344
|
+
// own client so the rotation is atomic with the rank write.
|
|
345
|
+
//
|
|
346
|
+
// BEST-EFFORT: a governance hiccup must never fail a sign-in, and the failure mode is
|
|
347
|
+
// fail-closed anyway — no role means zero permissions, never extra ones.
|
|
348
|
+
async function safeSyncRankRole(builder) {
|
|
349
|
+
if (!builder || builder.id == null) return;
|
|
350
|
+
try {
|
|
351
|
+
await seams.resolveOptional('governance.roleSync')
|
|
352
|
+
?.syncRankRole(null, { builderId: builder.id, rank: builder.rank });
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.error('[auth] rank-role sync failed (non-fatal):', err.message);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
287
358
|
function newSessionToken() {
|
|
288
359
|
return crypto.randomBytes(32).toString('hex');
|
|
289
360
|
}
|
|
@@ -467,6 +538,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
|
|
|
467
538
|
avatarUrl: ghUser.avatar_url,
|
|
468
539
|
});
|
|
469
540
|
builder = await maybeSeatFirstAdmin(builder);
|
|
541
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
470
542
|
const token = newSessionToken();
|
|
471
543
|
await createSession({
|
|
472
544
|
builderId: builder.id,
|
|
@@ -530,6 +602,13 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
|
|
|
530
602
|
if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
|
|
531
603
|
const err = new Error('not_approved');
|
|
532
604
|
err.code = 'NOT_APPROVED';
|
|
605
|
+
// Carry the login so the route can tell an imminent admission (the
|
|
606
|
+
// configured firstAdmin mid-bootstrap, or someone with a pending request)
|
|
607
|
+
// from a genuine stranger, and show the "confirming access" waiting page
|
|
608
|
+
// instead of a red dead-end (task 1002097). Attaching it here — rather
|
|
609
|
+
// than re-deriving it in the route — keeps the login the gate actually
|
|
610
|
+
// judged. It is NOT a decision: the route re-checks via webAdmissionStatus.
|
|
611
|
+
err.login = ghUser.login;
|
|
533
612
|
throw err;
|
|
534
613
|
}
|
|
535
614
|
}
|
|
@@ -540,6 +619,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
|
|
|
540
619
|
avatarUrl: ghUser.avatar_url,
|
|
541
620
|
});
|
|
542
621
|
builder = await maybeSeatFirstAdmin(builder);
|
|
622
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
543
623
|
const token = newSessionToken();
|
|
544
624
|
// Web sessions expire after 30 days; the user re-auths on the page
|
|
545
625
|
// when the cookie expires.
|
|
@@ -644,6 +724,7 @@ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
|
|
|
644
724
|
avatarUrl: identity.avatar_url,
|
|
645
725
|
});
|
|
646
726
|
builder = await maybeSeatFirstAdmin(builder);
|
|
727
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
647
728
|
const token = newSessionToken();
|
|
648
729
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
649
730
|
await createSession({
|
|
@@ -712,6 +793,45 @@ function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } =
|
|
|
712
793
|
return payload;
|
|
713
794
|
}
|
|
714
795
|
|
|
796
|
+
// The purpose marker on a hub back-channel logout token — kept in lockstep with
|
|
797
|
+
// the hub minter (platform-identity/hub-keys.js LOGOUT_PURPOSE) across the
|
|
798
|
+
// boundary (ADR 0091 §2). Task 1002238.
|
|
799
|
+
const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
|
|
800
|
+
|
|
801
|
+
// Verify a hub-signed BACK-CHANNEL LOGOUT token (ADR 0144, task 1002238) — the
|
|
802
|
+
// logout sibling of verifyHubAssertion, same compact-EdDSA-JWT envelope, kept in
|
|
803
|
+
// lockstep with the hub minter (hub-keys.js mintHubLogoutToken). Verifies the
|
|
804
|
+
// Ed25519 signature FIRST (alg HARDCODED to Ed25519 regardless of the header, so
|
|
805
|
+
// no algorithm-confusion), then issuer, audience (must equal THIS instance's
|
|
806
|
+
// client_id, so a token minted for another project can't be replayed here),
|
|
807
|
+
// expiry, and that purpose === 'backchannel-logout'. Requiring `purpose` is what
|
|
808
|
+
// makes login assertions and logout tokens mutually non-interchangeable: a login
|
|
809
|
+
// assertion has no purpose (rejected here), and a logout token has no
|
|
810
|
+
// github_login (rejected by verifyHubAssertion). Returns the validated payload
|
|
811
|
+
// ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
|
|
812
|
+
function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
813
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
814
|
+
const parts = String(token || '').split('.');
|
|
815
|
+
if (parts.length !== 3) throw new Error('malformed logout token');
|
|
816
|
+
const [h, p, s] = parts;
|
|
817
|
+
let header;
|
|
818
|
+
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
819
|
+
catch { throw new Error('malformed logout token header'); }
|
|
820
|
+
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected logout token alg');
|
|
821
|
+
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
822
|
+
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
823
|
+
if (!ok) throw new Error('logout token signature invalid');
|
|
824
|
+
let payload;
|
|
825
|
+
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
826
|
+
catch { throw new Error('malformed logout token payload'); }
|
|
827
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected logout token issuer');
|
|
828
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('logout token audience mismatch');
|
|
829
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('logout token expired');
|
|
830
|
+
if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
|
|
831
|
+
if (payload.sub == null) throw new Error('logout token missing subject');
|
|
832
|
+
return payload;
|
|
833
|
+
}
|
|
834
|
+
|
|
715
835
|
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
716
836
|
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
717
837
|
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
@@ -726,6 +846,25 @@ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
|
726
846
|
return false;
|
|
727
847
|
}
|
|
728
848
|
|
|
849
|
+
// Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
|
|
850
|
+
// assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
|
|
851
|
+
// a replay within the ~120s TTL is a no-op. Without this, a captured token stays a
|
|
852
|
+
// repeatable "log this builder out" primitive for its whole life — it would keep
|
|
853
|
+
// killing sessions the builder legitimately creates AFTER the token was minted
|
|
854
|
+
// (the one non-idempotent replay effect; revoking already-dead sessions is
|
|
855
|
+
// otherwise harmless). Separate map from assertions so the two token types never
|
|
856
|
+
// share dedupe state. In-memory per process; a restart clears it (acceptable — the
|
|
857
|
+
// window is short and the aud binding is the primary control). Exported for the
|
|
858
|
+
// route (routes/auth.js) since the DB revoke lives there, not in the kernel.
|
|
859
|
+
const _seenLogoutJti = new Map(); // jti -> expiry ms
|
|
860
|
+
function logoutTokenJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
861
|
+
for (const [k, exp] of _seenLogoutJti) if (exp < nowMs) _seenLogoutJti.delete(k);
|
|
862
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
863
|
+
if (_seenLogoutJti.has(jti)) return true;
|
|
864
|
+
_seenLogoutJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
|
|
729
868
|
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
730
869
|
// admission gate + upsertBuilder + createSession(source='cli') as the direct device
|
|
731
870
|
// flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
|
|
@@ -757,6 +896,7 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
|
757
896
|
avatarUrl: payload.avatar_url,
|
|
758
897
|
});
|
|
759
898
|
builder = await maybeSeatFirstAdmin(builder);
|
|
899
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
760
900
|
const token = newSessionToken();
|
|
761
901
|
await createSession({
|
|
762
902
|
builderId: builder.id,
|
|
@@ -994,18 +1134,134 @@ async function resolveSession(req) {
|
|
|
994
1134
|
return null;
|
|
995
1135
|
}
|
|
996
1136
|
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1137
|
+
// ── SESSION-SCOPES — capability attached to the TOKEN (BV1.R99, ADR 0151 §3) ──
|
|
1138
|
+
//
|
|
1139
|
+
// A session's SCOPE is orthogonal to its builder's roles: a dev-box session
|
|
1140
|
+
// (source='box') is baked onto a machine OUTSIDE the ADR 0016 trust boundary
|
|
1141
|
+
// (#919/SR#2, ADR 0053), so it must stay limited even when its builder is an
|
|
1142
|
+
// Archon. Before R99 that was an ad-hoc boolean (`req.allowBoxScope`) plus a
|
|
1143
|
+
// hardcoded `source === 'box'` test; it is now an explicit, declared model.
|
|
1144
|
+
//
|
|
1145
|
+
// A SESSION-SCOPE CAN ONLY EVER NARROW. Effective authority is (what the builder's
|
|
1146
|
+
// roles confer) ∩ (what this session's scope allows), with the allow-list
|
|
1147
|
+
// DENY-BY-DEFAULT. The intersection itself lives in the governance resolver
|
|
1148
|
+
// (resolveSessionPermissions — the composed answer the tab and permission gates
|
|
1149
|
+
// read); THIS file carries its route-reachability projection: "is this route
|
|
1150
|
+
// inside the session's scope at all". That projection is deliberately the ROLE-FREE
|
|
1151
|
+
// half — folding the builder's roles in here would change who can reach the
|
|
1152
|
+
// bootstrap routes (a builder on a rank with no seeded governance role resolves to
|
|
1153
|
+
// zero permissions by design — see tests/governance_parity.mjs Part D), and R99 is
|
|
1154
|
+
// a behaviour-identical remodelling of the #919 gate, not a re-gating of it.
|
|
1155
|
+
//
|
|
1156
|
+
// GOVERNANCE OWNS THE POLICY, THE KERNEL STAYS THE ENFORCER. The declaration is
|
|
1157
|
+
// governance-owned data in modules/governance/session-scopes.json, validated
|
|
1158
|
+
// against the permission catalog at load by modules/governance/session-scopes.js.
|
|
1159
|
+
// Core reads it as DATA with fs — never `require` (ADR 0083's one-way rule: a
|
|
1160
|
+
// module's code and enable/disable state must never be something the kernel
|
|
1161
|
+
// depends on). The sibling trust primitive permission-path-check.js reads the
|
|
1162
|
+
// protected-surface registry exactly this way (R101). Reading it statically rather
|
|
1163
|
+
// than through the `governance` kernel port is the deliberate call: a request must
|
|
1164
|
+
// not need a module to be mounted to stay deny-by-default, and the alternative
|
|
1165
|
+
// fails badly in both directions — denying on an absent port would take every
|
|
1166
|
+
// cli/web session down, admitting would hand a box token full authority.
|
|
1167
|
+
//
|
|
1168
|
+
// FAIL-CLOSED both ways: an unreadable or malformed registry THROWS (it never
|
|
1169
|
+
// degrades to "every session is unscoped"), and a session whose `source` the
|
|
1170
|
+
// registry does not declare is DENIED rather than defaulting to full authority. A
|
|
1171
|
+
// null/absent source is a DECLARED row (rows minted before migration 090 added the
|
|
1172
|
+
// column, which predate the 'box' source existing), not an implicit default.
|
|
1173
|
+
const SESSION_SCOPE_REGISTRY_PATH = path.join(__dirname, '..', '..', 'modules', 'governance', 'session-scopes.json');
|
|
1174
|
+
|
|
1175
|
+
// Pure: validate + normalize the parsed registry. Split from the file read so the
|
|
1176
|
+
// shipped assertions are what the tests exercise. `label` identifies the file in
|
|
1177
|
+
// the error text (the fs wrapper passes the path).
|
|
1178
|
+
function parseSessionScopes(raw, label = 'session-scope registry') {
|
|
1179
|
+
const rows = Array.isArray(raw && raw.scopes) ? raw.scopes : null;
|
|
1180
|
+
if (!rows || rows.length === 0) {
|
|
1181
|
+
throw new Error(`${label} declares no session scopes. Refusing to run with no session-scope model (ADR 0053/0151).`);
|
|
1182
|
+
}
|
|
1183
|
+
const bySource = new Map();
|
|
1184
|
+
for (const row of rows) {
|
|
1185
|
+
const source = row && row.source !== undefined ? row.source : null;
|
|
1186
|
+
if (source !== null && (typeof source !== 'string' || !source)) {
|
|
1187
|
+
throw new Error(`${label}: every scope needs a non-empty session source string, or null for the legacy sourceless row`);
|
|
1188
|
+
}
|
|
1189
|
+
if (bySource.has(source)) throw new Error(`${label}: duplicate session source "${source}"`);
|
|
1190
|
+
const unscoped = !!(row && row.unscoped === true);
|
|
1191
|
+
const permissions = row && Array.isArray(row.permissions) ? [...row.permissions] : [];
|
|
1192
|
+
// unscoped XOR an allow-list. Both would be ambiguous (does it narrow?);
|
|
1193
|
+
// neither would be a scope that can never admit anything — an authoring slip
|
|
1194
|
+
// either way, and both must fail loud rather than pick a default.
|
|
1195
|
+
if (unscoped === (permissions.length > 0)) {
|
|
1196
|
+
throw new Error(`${label}: "${source}" must declare EITHER unscoped:true OR a non-empty permissions allow-list, never both/neither`);
|
|
1197
|
+
}
|
|
1198
|
+
if (permissions.some((key) => typeof key !== 'string' || !key)) {
|
|
1199
|
+
throw new Error(`${label}: "${source}" lists a non-string permission key`);
|
|
1200
|
+
}
|
|
1201
|
+
bySource.set(source, Object.freeze({
|
|
1202
|
+
source,
|
|
1203
|
+
label: row && typeof row.label === 'string' ? row.label : null,
|
|
1204
|
+
unscoped,
|
|
1205
|
+
permissions: Object.freeze(permissions),
|
|
1206
|
+
}));
|
|
1207
|
+
}
|
|
1208
|
+
return bySource;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function loadSessionScopes(file) {
|
|
1212
|
+
let raw;
|
|
1213
|
+
try {
|
|
1214
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1215
|
+
} catch (e) {
|
|
1216
|
+
throw new Error(`session-scope registry unreadable at ${file}: ${e.message}. Refusing to run with no session-scope model (ADR 0053/0151).`);
|
|
1217
|
+
}
|
|
1218
|
+
return parseSessionScopes(raw, `session-scope registry ${file}`);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const _sessionScopes = loadSessionScopes(SESSION_SCOPE_REGISTRY_PATH);
|
|
1222
|
+
const SESSION_SCOPES = Object.freeze([..._sessionScopes.values()]);
|
|
1223
|
+
|
|
1224
|
+
// The scope a session's `source` declares, or null when the registry does not
|
|
1225
|
+
// declare it (⇒ deny; never "full").
|
|
1226
|
+
function sessionScopeFor(source) {
|
|
1227
|
+
const key = source === undefined ? null : source;
|
|
1228
|
+
if (key !== null && typeof key !== 'string') return null;
|
|
1229
|
+
return _sessionScopes.get(key) || null;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Route reachability, deny-by-default. An unscoped session reaches everything its
|
|
1233
|
+
// builder's gates admit; a SCOPED session reaches only a route that declared
|
|
1234
|
+
// capability its allow-list names — so a route that declares nothing (every route
|
|
1235
|
+
// that has not opted in, including any added later) is out of reach.
|
|
1236
|
+
function scopeCovers(scope, declared) {
|
|
1237
|
+
if (!scope) return false; // unrecognised source
|
|
1238
|
+
if (scope.unscoped) return true;
|
|
1239
|
+
const needs = Array.isArray(declared) ? declared : [];
|
|
1240
|
+
return needs.length > 0 && needs.every((key) => scope.permissions.includes(key));
|
|
1241
|
+
}
|
|
1242
|
+
function sessionScopeAdmits(source, declared) {
|
|
1243
|
+
return scopeCovers(sessionScopeFor(source), declared);
|
|
1007
1244
|
}
|
|
1008
1245
|
|
|
1246
|
+
// The route-side DECLARATION: "reaching this route needs these capabilities from
|
|
1247
|
+
// the session's scope." Mount it BEFORE requireBuilder. Inert for an unscoped
|
|
1248
|
+
// session — requireBuilder compares the declaration only against a narrowing scope.
|
|
1249
|
+
function sessionScope(...keys) {
|
|
1250
|
+
const needs = Object.freeze([...keys]);
|
|
1251
|
+
return function declareSessionScope(req, _res, next) {
|
|
1252
|
+
req.sessionScopeNeeds = needs;
|
|
1253
|
+
next();
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// The dev-box bootstrap declaration (#919) — the name modules/dev-box mounts
|
|
1258
|
+
// through the plugin API (docs/modules-contract.md 1.2.0), kept as the module-
|
|
1259
|
+
// facing spelling. It is no longer a bare boolean: it declares the box scope's OWN
|
|
1260
|
+
// allow-list, read from the registry, so the declaration cannot drift from the
|
|
1261
|
+
// scope it declares. If the registry stopped declaring a box scope it would
|
|
1262
|
+
// declare nothing and every box session would be denied — fail closed.
|
|
1263
|
+
const allowBoxScope = sessionScope(...((sessionScopeFor('box') || { permissions: [] }).permissions));
|
|
1264
|
+
|
|
1009
1265
|
async function requireBuilder(req, res, next) {
|
|
1010
1266
|
const session = await resolveSession(req);
|
|
1011
1267
|
if (!session) {
|
|
@@ -1024,18 +1280,25 @@ async function requireBuilder(req, res, next) {
|
|
|
1024
1280
|
res.fail('builder_inactive', 403);
|
|
1025
1281
|
return;
|
|
1026
1282
|
}
|
|
1027
|
-
// SECURITY (#919, SR#2):
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
// (all of /tasks, /me, the rest of /box …) fails
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1038
|
-
|
|
1283
|
+
// SECURITY (#919, SR#2): SESSION-SCOPE, deny-by-default. The dev-box provisioner
|
|
1284
|
+
// (scripts/gds/box.js) bakes a session into cloud-init so the box can bootstrap
|
|
1285
|
+
// (#818 — box-source-fetch clones the rank-scoped repo before /builder-setup
|
|
1286
|
+
// exists). That token lives ON the box, which is OUTSIDE the ADR 0016 trust
|
|
1287
|
+
// boundary, so it must NOT carry the builder's full authority. Since R99 the rule
|
|
1288
|
+
// is DATA (the session-scope registry above), not a hardcoded source test: a
|
|
1289
|
+
// narrowing session reaches ONLY a route that declared capability its allow-list
|
|
1290
|
+
// names, so EVERY other route (all of /tasks, /me, the rest of /box …) fails
|
|
1291
|
+
// closed here, including any route added later; and a source the registry does not
|
|
1292
|
+
// declare at all is denied rather than treated as unscoped. A builder who wants to
|
|
1293
|
+
// do real work FROM the box runs /builder-reauth, minting a normal cli/web session.
|
|
1294
|
+
const scope = sessionScopeFor(session.source);
|
|
1295
|
+
if (!scopeCovers(scope, req.sessionScopeNeeds)) {
|
|
1296
|
+
res.fail('box_scope', {
|
|
1297
|
+
status: 403,
|
|
1298
|
+
message: scope
|
|
1299
|
+
? 'This is a box-scoped session; it may only call dev-box bootstrap endpoints.'
|
|
1300
|
+
: 'This session carries an unrecognised scope and may not call authenticated endpoints.',
|
|
1301
|
+
});
|
|
1039
1302
|
return;
|
|
1040
1303
|
}
|
|
1041
1304
|
// Anthropic-identity pin — REMOVED (task 1039, idea 258). This used to 403
|
|
@@ -1075,17 +1338,6 @@ async function requireBuilder(req, res, next) {
|
|
|
1075
1338
|
next();
|
|
1076
1339
|
}
|
|
1077
1340
|
|
|
1078
|
-
// Page-level sign-in gate for the authenticated /builders HTML surface and its
|
|
1079
|
-
// content assets (#488). Same identity check as requireBuilder — token from the
|
|
1080
|
-
// gds_session cookie (or legacy pms_session / Bearer), looked up fresh against
|
|
1081
|
-
// builder_sessions — but on failure it REDIRECTS the browser into the web
|
|
1082
|
-
// sign-in flow (302 → /api/gds/auth/web/start) instead of returning a JSON 401,
|
|
1083
|
-
// because these requests are page navigations and asset loads, not API calls.
|
|
1084
|
-
//
|
|
1085
|
-
// This is the *actual* access control for the citizen primer + system-maps:
|
|
1086
|
-
// server.js mounts it ahead of the static handlers so an unauthenticated
|
|
1087
|
-
// request is bounced before the file is ever served (curl-proof, JS-disabled-
|
|
1088
|
-
// proof). The client-side link-hiding in builders.js is only cosmetic UX.
|
|
1089
1341
|
// Build a `?return=<absolute current URL>` for a page-gate sign-in redirect
|
|
1090
1342
|
// (#734) so a builder bounced into sign-in from the builders subdomain lands
|
|
1091
1343
|
// back on the page they wanted. The web flow validates it (open-redirect guard)
|
|
@@ -1099,87 +1351,114 @@ function signInWithReturn(req) {
|
|
|
1099
1351
|
return `${base}?return=${encodeURIComponent(`${proto}://${host}${req.originalUrl}`)}`;
|
|
1100
1352
|
}
|
|
1101
1353
|
|
|
1102
|
-
|
|
1354
|
+
// ── the three PAGE GATES (BV1.R107, ADR 0151) ────────────────────────────────
|
|
1355
|
+
//
|
|
1356
|
+
// A page gate is the *actual* access control for an HTML surface and its content
|
|
1357
|
+
// assets (#488): serve-internal.js mounts it ahead of the static handlers, so an
|
|
1358
|
+
// unauthorized request is bounced before the file is ever served (curl-proof,
|
|
1359
|
+
// JS-disabled-proof). The client-side link-hiding in the hall is cosmetic UX.
|
|
1360
|
+
//
|
|
1361
|
+
// Each gate asks TWO questions, in order:
|
|
1362
|
+
// 1. IDENTITY — is there a live session? (token from the gds_session cookie /
|
|
1363
|
+
// legacy pms_session / Bearer, looked up fresh against builder_sessions).
|
|
1364
|
+
// No → 302 into the web sign-in flow.
|
|
1365
|
+
// 2. AUTHORITY — does the builder HOLD the page's catalog permission, resolved
|
|
1366
|
+
// per request through the `governance` port exactly as requirePermission
|
|
1367
|
+
// does? No → 302 to an UNGATED hall page.
|
|
1368
|
+
//
|
|
1369
|
+
// They REDIRECT rather than res.fail() because these are page navigations and
|
|
1370
|
+
// asset loads, not API calls — a JSON 403 renders as a blank error body in the
|
|
1371
|
+
// browser instead of sending the visitor somewhere they can act.
|
|
1372
|
+
//
|
|
1373
|
+
// R107 replaced each gate's hand-rolled rank test with the catalog key sitting at
|
|
1374
|
+
// that gate's EXISTING rank floor, so the audience is unchanged over the four live
|
|
1375
|
+
// ranks (catalog.RANK_ORDER):
|
|
1376
|
+
// requireBuilderPage was "any session" floor xenos → page.view.public
|
|
1377
|
+
// requireNonXenosPage was rank !== 'xenos' floor thetes → page.view.builder
|
|
1378
|
+
// requireArchonPage was rank === 'archon' floor archon → page.view.governance
|
|
1379
|
+
// The one accepted divergence is the one governance_002 already documents
|
|
1380
|
+
// (tests/governance_parity.mjs Part D): a builder on a reserved/divine rank holds
|
|
1381
|
+
// no seeded role, so they now fail a page gate a bare rank test would have
|
|
1382
|
+
// admitted. That is a TIGHTENING, never a widening.
|
|
1383
|
+
//
|
|
1384
|
+
// `deniedTo` is NEVER the sign-in flow. A visitor on that branch already HAS a
|
|
1385
|
+
// session, so bouncing them into sign-in returns them to the same gate — an
|
|
1386
|
+
// infinite loop. Every destination below is an ungated hall page:
|
|
1387
|
+
// - /builders the hall home. A signed-in non-Archon IS a citizen, just
|
|
1388
|
+
// not the Ruling Hand, so send them home.
|
|
1389
|
+
// - /builders/not-ready the friendly notice for the orientation reading rooms;
|
|
1390
|
+
// it shows the rank ladder and a way back to the hall.
|
|
1391
|
+
const PAGE_GATES = Object.freeze({
|
|
1392
|
+
requireBuilderPage: Object.freeze({ permission: 'page.view.public', deniedTo: '/builders' }),
|
|
1393
|
+
requireNonXenosPage: Object.freeze({ permission: 'page.view.builder', deniedTo: '/builders/not-ready' }),
|
|
1394
|
+
requireArchonPage: Object.freeze({ permission: 'page.view.governance', deniedTo: '/builders' }),
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// A builder's effective permission set, or NULL when authority cannot be
|
|
1398
|
+
// ESTABLISHED (the `governance` port is absent or malformed, or the resolver
|
|
1399
|
+
// threw). Every caller must read null as a DENIAL: admitting when the authority
|
|
1400
|
+
// system is missing would make disabling a module a privilege escalation — the
|
|
1401
|
+
// inversion ADR 0016 exists to prevent. Resolved per request, never memoised.
|
|
1402
|
+
async function resolveHeldPermissions(builderId) {
|
|
1403
|
+
if (builderId === null || builderId === undefined || builderId === '') return null;
|
|
1404
|
+
const governance = seams.resolveOptional('governance');
|
|
1405
|
+
if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
|
|
1103
1406
|
try {
|
|
1104
|
-
|
|
1105
|
-
if (!session) {
|
|
1106
|
-
res.redirect(302, signInWithReturn(req));
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
next();
|
|
1407
|
+
return new Set((await governance.resolveBuilderPermissions(builderId)) || []);
|
|
1110
1408
|
} catch (err) {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
// gate protects is the priority here, not availability.
|
|
1114
|
-
console.error('[gds] requireBuilderPage lookup failed', err);
|
|
1115
|
-
res.redirect(302, signInWithReturn(req));
|
|
1409
|
+
console.error('[gds] permission resolve failed', err);
|
|
1410
|
+
return null;
|
|
1116
1411
|
}
|
|
1117
1412
|
}
|
|
1118
1413
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
// - no session → 302 into the web sign-in flow (page navigation)
|
|
1123
|
-
// - signed in, not Archon → 302 back to the hall (/builders) — they ARE a
|
|
1124
|
-
// citizen, just not the Ruling Hand; bouncing to
|
|
1125
|
-
// sign-in would loop, so send them home instead.
|
|
1126
|
-
// - Archon → next()
|
|
1127
|
-
// This is defense-in-depth: every data endpoint the page calls is ALSO
|
|
1128
|
-
// requireRank('archon') server-side, so no cross-builder data can leak even if
|
|
1129
|
-
// this gate were bypassed. But it keeps a non-Archon from ever seeing the page
|
|
1130
|
-
// shell, honoring "only Archons have access".
|
|
1131
|
-
async function requireArchonPage(req, res, next) {
|
|
1414
|
+
async function runPageGate(name, req, res, next) {
|
|
1415
|
+
const { permission, deniedTo } = PAGE_GATES[name];
|
|
1416
|
+
let session;
|
|
1132
1417
|
try {
|
|
1133
|
-
|
|
1134
|
-
if (!session) {
|
|
1135
|
-
res.redirect(302, signInWithReturn(req));
|
|
1136
|
-
return;
|
|
1137
|
-
}
|
|
1138
|
-
if (session.rank !== 'archon') {
|
|
1139
|
-
res.redirect(302, '/builders');
|
|
1140
|
-
return;
|
|
1141
|
-
}
|
|
1142
|
-
next();
|
|
1418
|
+
session = await resolveSession(req);
|
|
1143
1419
|
} catch (err) {
|
|
1144
|
-
// Fail CLOSED
|
|
1145
|
-
// leaking the gated page
|
|
1146
|
-
|
|
1420
|
+
// Fail CLOSED: a session-lookup blip sends the visitor to sign-in rather than
|
|
1421
|
+
// leaking the gated page. The content the gate protects is the priority, not
|
|
1422
|
+
// availability — and at this point we cannot tell whether they were signed in.
|
|
1423
|
+
console.error(`[gds] ${name} lookup failed`, err);
|
|
1424
|
+
res.redirect(302, signInWithReturn(req));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (!session) {
|
|
1147
1428
|
res.redirect(302, signInWithReturn(req));
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
const held = await resolveHeldPermissions(session.builder_id);
|
|
1432
|
+
if (held === null || !held.has(permission)) {
|
|
1433
|
+
res.redirect(302, deniedTo);
|
|
1434
|
+
return;
|
|
1148
1435
|
}
|
|
1436
|
+
next();
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// The authenticated /builders HTML surface — the Work Board, Roadmap, Goals, the
|
|
1440
|
+
// task-detail page, the Repo Atlas, the Dev Box pair page and Settings. A Xenos IS
|
|
1441
|
+
// allowed (page.view.public floors at xenos); an unauthenticated visitor is bounced
|
|
1442
|
+
// into sign-in rather than served the page shell.
|
|
1443
|
+
async function requireBuilderPage(req, res, next) {
|
|
1444
|
+
return runPageGate('requireBuilderPage', req, res, next);
|
|
1149
1445
|
}
|
|
1150
1446
|
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
// in builders.js is only cosmetic.
|
|
1447
|
+
// The /watch monitoring surface, the /harbor dev-box page and the /gate page
|
|
1448
|
+
// (#726, idea 257, task 1179) — Archon-only. Defense-in-depth: every data endpoint
|
|
1449
|
+
// these pages call is independently gated server-side, so no cross-builder data
|
|
1450
|
+
// leaks even if this gate were bypassed; it keeps a non-Archon from ever seeing
|
|
1451
|
+
// the page shell.
|
|
1452
|
+
async function requireArchonPage(req, res, next) {
|
|
1453
|
+
return runPageGate('requireArchonPage', req, res, next);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// The orientation reading rooms (the citizen's primer, the system-maps/diagrams)
|
|
1457
|
+
// that #766 closed to newcomers and #770 enforces at the URL. A Xenos's path is
|
|
1458
|
+
// action-only — find work, claim, ship three — so these pages are reserved for
|
|
1459
|
+
// graduated citizens (Thetes+).
|
|
1165
1460
|
async function requireNonXenosPage(req, res, next) {
|
|
1166
|
-
|
|
1167
|
-
const session = await resolveSession(req);
|
|
1168
|
-
if (!session) {
|
|
1169
|
-
res.redirect(302, signInWithReturn(req));
|
|
1170
|
-
return;
|
|
1171
|
-
}
|
|
1172
|
-
if (session.rank === 'xenos') {
|
|
1173
|
-
res.redirect(302, '/builders/not-ready');
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
next();
|
|
1177
|
-
} catch (err) {
|
|
1178
|
-
// Fail CLOSED — a lookup error sends the visitor to sign-in rather than
|
|
1179
|
-
// leaking the gated page shell.
|
|
1180
|
-
console.error('[gds] requireNonXenosPage lookup failed', err);
|
|
1181
|
-
res.redirect(302, signInWithReturn(req));
|
|
1182
|
-
}
|
|
1461
|
+
return runPageGate('requireNonXenosPage', req, res, next);
|
|
1183
1462
|
}
|
|
1184
1463
|
|
|
1185
1464
|
// ---------------------------------------------------------------------------
|
|
@@ -1277,6 +1556,63 @@ function requireRank(...allowed) {
|
|
|
1277
1556
|
};
|
|
1278
1557
|
}
|
|
1279
1558
|
|
|
1559
|
+
// requirePermission(...perms) — the PERMISSION gate (ADR 0151, BV1.R97).
|
|
1560
|
+
//
|
|
1561
|
+
// The permissions-as-atom sibling of requireRank. Instead of asking "is this
|
|
1562
|
+
// builder's rank at-or-above a floor", it asks "does this builder HOLD these
|
|
1563
|
+
// permission atoms", resolved from the roles they hold. R93's compat seed derives
|
|
1564
|
+
// the four rank-roles from each permission's `floor`, so on day one the two gates
|
|
1565
|
+
// are EQUIVALENT — requirePermission('idea.triage') admits exactly who
|
|
1566
|
+
// requireRank('metic','archon') admits. R104-R107 migrate the ~81 existing call
|
|
1567
|
+
// sites; requireRank itself is untouched and keeps working throughout.
|
|
1568
|
+
//
|
|
1569
|
+
// AND, not OR — every named permission must be held. requireRank(...allowed) is
|
|
1570
|
+
// OR-ish only because its arguments are points on ONE ladder (the gate's floor is
|
|
1571
|
+
// the lowest of them); unordered atoms have no such ordering to collapse. The
|
|
1572
|
+
// governance port's builderHasPermissions() already reads "holds EVERY one", and
|
|
1573
|
+
// AND is the stricter, less surprising reading of requirePermission('a','b'): a
|
|
1574
|
+
// gate naming two authorities demands both. A genuine "either of these" gate
|
|
1575
|
+
// should be spelled as its own permission key, not smuggled into the gate's arity.
|
|
1576
|
+
//
|
|
1577
|
+
// FAIL-CLOSED at every branch — 403, never an admit:
|
|
1578
|
+
// • no req.builder (gate mounted without requireBuilder) → 403
|
|
1579
|
+
// • no perms named (a mis-authored gate) → 403
|
|
1580
|
+
// • the `governance` port is not registered (module off) → 403
|
|
1581
|
+
// • the resolver throws (DB blip) → 403
|
|
1582
|
+
// The port-absent branch is the deliberate one. `governance` is a core-domain,
|
|
1583
|
+
// default:true module, so its absence means authority cannot be ESTABLISHED —
|
|
1584
|
+
// and admitting on "the authority system is missing" would make disabling a
|
|
1585
|
+
// module a privilege escalation, the exact inversion ADR 0016 exists to prevent.
|
|
1586
|
+
// A misconfigured instance loses its gated routes; it never gives them away.
|
|
1587
|
+
// (Hence resolveOptional + an explicit 403 rather than seams.resolve()'s throw,
|
|
1588
|
+
// which would surface as a 500 and read as an outage instead of a denial.)
|
|
1589
|
+
//
|
|
1590
|
+
// UNCACHED — resolved on the request that needs it, exactly as requireRank reads
|
|
1591
|
+
// the live rank. No memo here and none in resolver.js (ADR 0016).
|
|
1592
|
+
//
|
|
1593
|
+
// The 403 body mirrors requireRank's: `required` is the literal permission list
|
|
1594
|
+
// the gate names, and `held` is the subset of THOSE the builder actually holds —
|
|
1595
|
+
// or null when the set could not be resolved at all (the requireRank `actual ??
|
|
1596
|
+
// null` analogue). Reporting only the intersection explains the denial without
|
|
1597
|
+
// dumping the builder's entire permission set into an error body.
|
|
1598
|
+
//
|
|
1599
|
+
// Usage: router.post('/roles', auth.requireBuilder, auth.requirePermission('governance.manage'), handler)
|
|
1600
|
+
function requirePermission(...perms) {
|
|
1601
|
+
const required = Object.freeze([...perms]);
|
|
1602
|
+
return async function permissionGate(req, res, next) {
|
|
1603
|
+
const deny = (held) => res.fail('permission_forbidden', 403, { required, held });
|
|
1604
|
+
const builderId = req.builder && req.builder.id;
|
|
1605
|
+
if (!builderId) return deny(null);
|
|
1606
|
+
if (required.length === 0) return deny(null);
|
|
1607
|
+
// Shared with the page gates (R107): null = authority could not be established
|
|
1608
|
+
// (no port / malformed port / a throwing resolver) and is always a denial.
|
|
1609
|
+
const held = await resolveHeldPermissions(builderId);
|
|
1610
|
+
if (held === null) return deny(null);
|
|
1611
|
+
if (required.every((key) => held.has(key))) return next();
|
|
1612
|
+
return deny(required.filter((key) => held.has(key)));
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1280
1616
|
// requireBfgPrincipal — the identity gate for the cross-builder memory WRITE
|
|
1281
1617
|
// path (ADR 0026 §6C / task #446). Runs AFTER requireBuilder. This is
|
|
1282
1618
|
// DELIBERATELY NOT a rank gate: the trust-boundary exception is bound to ONE
|
|
@@ -1320,6 +1656,7 @@ module.exports = {
|
|
|
1320
1656
|
idpConfigured,
|
|
1321
1657
|
openEnrollmentEnabled,
|
|
1322
1658
|
firstTimeAdmissionBlocked,
|
|
1659
|
+
webAdmissionStatus,
|
|
1323
1660
|
firstAdminLogin,
|
|
1324
1661
|
maybeSeatFirstAdmin,
|
|
1325
1662
|
startDeviceFlow,
|
|
@@ -1332,17 +1669,25 @@ module.exports = {
|
|
|
1332
1669
|
completeIdpFlow,
|
|
1333
1670
|
loadHubPublicKey,
|
|
1334
1671
|
verifyHubAssertion,
|
|
1672
|
+
verifyHubLogoutToken,
|
|
1673
|
+
logoutTokenJtiSeenAndRecord,
|
|
1335
1674
|
completeDeviceExchange,
|
|
1336
1675
|
membershipRole,
|
|
1337
1676
|
extractTokens,
|
|
1338
1677
|
resolveSession,
|
|
1339
1678
|
allowBoxScope,
|
|
1679
|
+
sessionScope,
|
|
1680
|
+
sessionScopeFor,
|
|
1681
|
+
sessionScopeAdmits,
|
|
1682
|
+
parseSessionScopes,
|
|
1683
|
+
SESSION_SCOPES,
|
|
1340
1684
|
requireBuilder,
|
|
1341
1685
|
requireBuilderPage,
|
|
1342
1686
|
requireArchonPage,
|
|
1343
1687
|
requireNonXenosPage,
|
|
1344
1688
|
requireRank,
|
|
1345
1689
|
rankMeetsThreshold,
|
|
1690
|
+
requirePermission,
|
|
1346
1691
|
requireBfgPrincipal,
|
|
1347
1692
|
requireDiscordBot,
|
|
1348
1693
|
// Discord OAuth (account-linking — Phase 3 F2, ADR 0033)
|