@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
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');
|
|
@@ -55,6 +56,20 @@ const { branding, userAgent } = require('../branding');
|
|
|
55
56
|
|
|
56
57
|
const SCOPE = 'read:user';
|
|
57
58
|
|
|
59
|
+
// Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
|
|
60
|
+
// and the CLI Device Flow ever need. The onboarding wizard
|
|
61
|
+
// (public-landing/create.html), however, lists + creates the signed-in user's own
|
|
62
|
+
// GitHub repos (task 1002099) — which `read:user` cannot do — so it OPTS IN to a
|
|
63
|
+
// broader scope. We add `public_repo`, NOT the all-or-nothing `repo`: it still
|
|
64
|
+
// lists/creates PUBLIC repos (the norm for open-source Cloud Bongos instances)
|
|
65
|
+
// but shows a far gentler consent screen and shrinks the blast radius of the
|
|
66
|
+
// token we then persist (ADR 0143). Only the wizard passes { repos:true }; every
|
|
67
|
+
// other sign-in stays identity-only. Pure + exported so the exact scope string
|
|
68
|
+
// (a security-critical value) is unit-testable.
|
|
69
|
+
function scopeForWebFlow({ repos = false } = {}) {
|
|
70
|
+
return repos ? `${SCOPE} public_repo` : SCOPE;
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
// In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
|
|
59
74
|
// Device flow is ephemeral; restart of the Node process invalidates pending flows
|
|
60
75
|
// (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
|
|
@@ -91,6 +106,116 @@ function authConfigured() {
|
|
|
91
106
|
return !!loadGithubCreds();
|
|
92
107
|
}
|
|
93
108
|
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
// Platform IdP federation (ADR 0141 §1) — when enabled, the instance's web +
|
|
111
|
+
// CLI sign-in federate through the Cloud Bongos hub instead of talking to GitHub
|
|
112
|
+
// directly. Identity is federated; authority is NOT — the instance still runs its
|
|
113
|
+
// OWN admission gate + upsertBuilder + createSession, so the hub only answers
|
|
114
|
+
// "who is this GitHub user" (ADR 0016). Direct-GitHub (auth.idp unset) stays the
|
|
115
|
+
// unchanged fallback for self-hosted instances.
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
// The switch is the branding auth.idp flag (committed, non-secret). Fail-soft to
|
|
119
|
+
// false so a branding hiccup degrades to the direct-GitHub path rather than
|
|
120
|
+
// breaking sign-in.
|
|
121
|
+
function idpEnabled() {
|
|
122
|
+
try { return branding().auth?.idp === true; } catch { return false; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resolve the hub federation config. The switch is the flag above; the hub origin
|
|
126
|
+
// + client credentials come from ENV (<PREFIX>_HUB_ORIGIN / _HUB_CLIENT_ID /
|
|
127
|
+
// _HUB_CLIENT_SECRET) — never a committed file, so the secret stays out of the
|
|
128
|
+
// branding pack (mirrors loadGithubCreds's env-first posture). Provisioning
|
|
129
|
+
// (ADR 0141 §4) writes these into the instance web.env. Returns null unless the
|
|
130
|
+
// flag is on AND all three resolve → callers fall back to direct GitHub.
|
|
131
|
+
function loadIdpConfig() {
|
|
132
|
+
if (!idpEnabled()) return null;
|
|
133
|
+
const origin = ic.resolveEnv('HUB_ORIGIN');
|
|
134
|
+
const clientId = ic.resolveEnv('HUB_CLIENT_ID');
|
|
135
|
+
const clientSecret = ic.resolveEnv('HUB_CLIENT_SECRET');
|
|
136
|
+
if (origin && clientId && clientSecret) {
|
|
137
|
+
return { origin: String(origin).replace(/\/+$/, ''), clientId, clientSecret };
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function idpConfigured() {
|
|
143
|
+
return !!loadIdpConfig();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
|
|
147
|
+
// builder is the instance's configured firstAdmin, else builder. NEVER a rank.
|
|
148
|
+
function membershipRole({ githubLogin } = {}) {
|
|
149
|
+
const fa = firstAdminLogin();
|
|
150
|
+
if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
|
|
151
|
+
return 'builder';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
|
|
155
|
+
// github_id builds on project <client_id>, coarse role <role>". Runs on every
|
|
156
|
+
// FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
|
|
157
|
+
// project still appears in the builder's My Projects with no hub browser session.
|
|
158
|
+
// Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
|
|
159
|
+
// the onboarding stage tick). No-op on a self-hosted instance (idp unset).
|
|
160
|
+
async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
|
|
161
|
+
const idp = loadIdpConfig();
|
|
162
|
+
if (!idp) return;
|
|
163
|
+
try {
|
|
164
|
+
await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
client_id: idp.clientId,
|
|
169
|
+
client_secret: idp.clientSecret,
|
|
170
|
+
github_id: githubId,
|
|
171
|
+
github_login: githubLogin,
|
|
172
|
+
display_name: displayName ?? null,
|
|
173
|
+
avatar_url: avatarUrl ?? null,
|
|
174
|
+
role: membershipRole({ githubLogin }),
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.log('[auth] hub membership check-in failed (non-blocking):', err.message);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
|
|
183
|
+
// builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines)
|
|
184
|
+
// for the internal scouting directory. Fires on every federated sign-in ("on key
|
|
185
|
+
// events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
|
|
186
|
+
// not federated — never fails a sign-in.
|
|
187
|
+
async function reportActivityRollup({ builderId, githubId }) {
|
|
188
|
+
const idp = loadIdpConfig();
|
|
189
|
+
if (!idp) return;
|
|
190
|
+
try {
|
|
191
|
+
// Snapshot the builder's talent signals directly from the DB (auth.js is a
|
|
192
|
+
// kernel file — it uses pool, never a non-kernel domain module like metrics.js).
|
|
193
|
+
const b = await pool.query(
|
|
194
|
+
'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
|
|
195
|
+
[builderId],
|
|
196
|
+
);
|
|
197
|
+
if (!b.rows[0]) return;
|
|
198
|
+
const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
|
|
199
|
+
const row = b.rows[0];
|
|
200
|
+
await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
client_id: idp.clientId,
|
|
205
|
+
client_secret: idp.clientSecret,
|
|
206
|
+
github_id: githubId,
|
|
207
|
+
credits: Number(row.total_credits) || 0,
|
|
208
|
+
tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
|
|
209
|
+
karma: Number(row.karma) || 0,
|
|
210
|
+
rank: row.rank || null,
|
|
211
|
+
disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.log('[auth] hub activity rollup failed (non-blocking):', err.message);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
94
219
|
// Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
|
|
95
220
|
// sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
|
|
96
221
|
// "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
|
|
@@ -126,6 +251,48 @@ function firstTimeAdmissionBlocked({ existing, approved, login }) {
|
|
|
126
251
|
return true;
|
|
127
252
|
}
|
|
128
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
|
+
|
|
129
296
|
// First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
|
|
130
297
|
// login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
|
|
131
298
|
// else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
|
|
@@ -160,6 +327,34 @@ async function maybeSeatFirstAdmin(builder) {
|
|
|
160
327
|
return builder;
|
|
161
328
|
}
|
|
162
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
|
+
|
|
163
358
|
function newSessionToken() {
|
|
164
359
|
return crypto.randomBytes(32).toString('hex');
|
|
165
360
|
}
|
|
@@ -201,7 +396,11 @@ async function exchangeCodeForToken(code, redirectUri) {
|
|
|
201
396
|
});
|
|
202
397
|
const data = await res.json();
|
|
203
398
|
if (data.error) throw new Error(`GitHub token exchange: ${data.error_description || data.error}`);
|
|
204
|
-
|
|
399
|
+
// Return the GRANTED scope alongside the token. GitHub echoes back the scopes
|
|
400
|
+
// the user actually consented to (space/comma-separated) — the caller checks it
|
|
401
|
+
// before persisting a repo-capable token, so a user who declined the broader
|
|
402
|
+
// consent never has one stored (task 1002099 / ADR 0143).
|
|
403
|
+
return { accessToken: data.access_token, scope: data.scope || '' };
|
|
205
404
|
}
|
|
206
405
|
|
|
207
406
|
// -------------------------------------------------------------------------
|
|
@@ -339,6 +538,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
|
|
|
339
538
|
avatarUrl: ghUser.avatar_url,
|
|
340
539
|
});
|
|
341
540
|
builder = await maybeSeatFirstAdmin(builder);
|
|
541
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
342
542
|
const token = newSessionToken();
|
|
343
543
|
await createSession({
|
|
344
544
|
builderId: builder.id,
|
|
@@ -360,19 +560,21 @@ async function pollDeviceFlow(deviceCode, userAgent) {
|
|
|
360
560
|
// Web Flow (browser)
|
|
361
561
|
// -------------------------------------------------------------------------
|
|
362
562
|
|
|
363
|
-
function buildAuthorizeUrl(state, redirectUri) {
|
|
563
|
+
function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
|
|
364
564
|
const creds = loadGithubCreds();
|
|
365
565
|
if (!creds) throw new Error('GitHub OAuth credentials not configured');
|
|
366
566
|
const u = new URL('https://github.com/login/oauth/authorize');
|
|
367
567
|
u.searchParams.set('client_id', creds.client_id);
|
|
368
568
|
u.searchParams.set('redirect_uri', redirectUri);
|
|
369
|
-
|
|
569
|
+
// `repos:true` (the onboarding wizard only) requests the broader repo scope; every
|
|
570
|
+
// other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
|
|
571
|
+
u.searchParams.set('scope', scopeForWebFlow({ repos }));
|
|
370
572
|
u.searchParams.set('state', state);
|
|
371
573
|
return u.toString();
|
|
372
574
|
}
|
|
373
575
|
|
|
374
|
-
async function completeWebFlow({ code, redirectUri, userAgent }) {
|
|
375
|
-
const accessToken = await exchangeCodeForToken(code, redirectUri);
|
|
576
|
+
async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
|
|
577
|
+
const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
|
|
376
578
|
const ghUser = await fetchGithubUser(accessToken);
|
|
377
579
|
// Approval gate (2026-05-30): the web hall sign-in no longer admits anyone
|
|
378
580
|
// with a GitHub account. A first-time visitor must have been approved by an
|
|
@@ -400,6 +602,13 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
|
|
|
400
602
|
if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
|
|
401
603
|
const err = new Error('not_approved');
|
|
402
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;
|
|
403
612
|
throw err;
|
|
404
613
|
}
|
|
405
614
|
}
|
|
@@ -410,6 +619,7 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
|
|
|
410
619
|
avatarUrl: ghUser.avatar_url,
|
|
411
620
|
});
|
|
412
621
|
builder = await maybeSeatFirstAdmin(builder);
|
|
622
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
413
623
|
const token = newSessionToken();
|
|
414
624
|
// Web sessions expire after 30 days; the user re-auths on the page
|
|
415
625
|
// when the cookie expires.
|
|
@@ -423,9 +633,285 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
|
|
|
423
633
|
});
|
|
424
634
|
// V3.R75 (#294): tick the onboarding state machine. Best-effort.
|
|
425
635
|
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
636
|
+
// Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
|
|
637
|
+
// repo access, hand the GitHub access token + granted scope back so the web
|
|
638
|
+
// callback can persist it (encrypted, short-TTL) for the repo picker/creator.
|
|
639
|
+
// auth.js stays kernel-clean — it never writes the token to the DB itself; the
|
|
640
|
+
// token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
|
|
641
|
+
// even returns it.
|
|
642
|
+
const captured = captureRepoToken ? { githubToken: accessToken, githubScope: grantedScope } : {};
|
|
643
|
+
return { token, builder, expiresAt, ...captured };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// -------------------------------------------------------------------------
|
|
647
|
+
// Platform IdP web flow (ADR 0141 §1) — the instance as an OAuth client of the hub
|
|
648
|
+
// -------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
// Build the hub's authorize URL. The instance redirects the browser here (instead
|
|
651
|
+
// of github.com) at /auth/web/start when idpConfigured(). redirectUri is the
|
|
652
|
+
// instance's OWN /auth/web/callback (registered in the hub's sso_clients allow-list
|
|
653
|
+
// by provisioning); state is the same CSRF nonce the callback validates.
|
|
654
|
+
// PURE — construct the hub authorize URL (testable without branding/env). Trailing
|
|
655
|
+
// slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
|
|
656
|
+
function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
|
|
657
|
+
const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/gds/sso/authorize`);
|
|
658
|
+
u.searchParams.set('client_id', clientId);
|
|
659
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
660
|
+
u.searchParams.set('state', state);
|
|
661
|
+
return u.toString();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function buildIdpAuthorizeUrl(state, redirectUri) {
|
|
665
|
+
const idp = loadIdpConfig();
|
|
666
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
667
|
+
return idpAuthorizeUrl(idp.origin, idp.clientId, state, redirectUri);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Server-to-server: redeem the hub authorization code for identity. Authenticates
|
|
671
|
+
// with the instance's client_id + client_secret; the hub returns IDENTITY ONLY
|
|
672
|
+
// (github_id, github_login, display_name, avatar_url) — never a rank (ADR 0141 §2).
|
|
673
|
+
async function exchangeIdpCode({ code, redirectUri, idp }) {
|
|
674
|
+
const res = await fetch(`${idp.origin}/api/gds/sso/token`, {
|
|
675
|
+
method: 'POST',
|
|
676
|
+
headers: {
|
|
677
|
+
'Content-Type': 'application/json',
|
|
678
|
+
Accept: 'application/json',
|
|
679
|
+
'User-Agent': userAgent(),
|
|
680
|
+
},
|
|
681
|
+
body: JSON.stringify({
|
|
682
|
+
client_id: idp.clientId,
|
|
683
|
+
client_secret: idp.clientSecret,
|
|
684
|
+
code,
|
|
685
|
+
redirect_uri: redirectUri,
|
|
686
|
+
}),
|
|
687
|
+
});
|
|
688
|
+
if (!res.ok) {
|
|
689
|
+
const text = await res.text().catch(() => '');
|
|
690
|
+
throw new Error(`hub /sso/token failed (${res.status}): ${text.slice(0, 200)}`);
|
|
691
|
+
}
|
|
692
|
+
const data = await res.json();
|
|
693
|
+
if (!data || data.github_id == null) {
|
|
694
|
+
throw new Error('hub /sso/token returned no identity');
|
|
695
|
+
}
|
|
696
|
+
return data;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Complete the instance web sign-in via the hub. Sources identity from the hub's
|
|
700
|
+
// /sso/token, then runs the EXACT SAME admission gate + upsertBuilder +
|
|
701
|
+
// maybeSeatFirstAdmin + createSession(source='web') as completeWebFlow — only the
|
|
702
|
+
// identity SOURCE differs (the hub, not github.com). The shared PURE gate
|
|
703
|
+
// (firstTimeAdmissionBlocked) is what keeps this path from drifting from the direct
|
|
704
|
+
// path (SR-19). Returns the same { token, builder, expiresAt } shape.
|
|
705
|
+
async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
|
|
706
|
+
const idp = loadIdpConfig();
|
|
707
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
708
|
+
const identity = await exchangeIdpCode({ code, redirectUri, idp });
|
|
709
|
+
const ghId = identity.github_id;
|
|
710
|
+
const ghLogin = identity.github_login;
|
|
711
|
+
const existing = await getBuilderByGithubId(ghId);
|
|
712
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
713
|
+
const approved = await hasInvitedAccessRequest(ghLogin);
|
|
714
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
|
|
715
|
+
const err = new Error('not_approved');
|
|
716
|
+
err.code = 'NOT_APPROVED';
|
|
717
|
+
throw err;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
let builder = await upsertBuilder({
|
|
721
|
+
githubId: ghId,
|
|
722
|
+
githubLogin: ghLogin,
|
|
723
|
+
displayName: identity.display_name || ghLogin,
|
|
724
|
+
avatarUrl: identity.avatar_url,
|
|
725
|
+
});
|
|
726
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
727
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
728
|
+
const token = newSessionToken();
|
|
729
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
730
|
+
await createSession({
|
|
731
|
+
builderId: builder.id,
|
|
732
|
+
token,
|
|
733
|
+
source: 'web',
|
|
734
|
+
userAgent: ua ?? null,
|
|
735
|
+
expiresAt,
|
|
736
|
+
});
|
|
737
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
738
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated web sign-in.
|
|
739
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url });
|
|
740
|
+
reportActivityRollup({ builderId: builder.id, githubId: ghId });
|
|
426
741
|
return { token, builder, expiresAt };
|
|
427
742
|
}
|
|
428
743
|
|
|
744
|
+
// -------------------------------------------------------------------------
|
|
745
|
+
// Platform IdP CLI/device flow (ADR 0141 §1) — the instance verifies a hub-signed
|
|
746
|
+
// assertion OFFLINE and mints its own cli session. The CLI runs the device flow
|
|
747
|
+
// against the HUB (GitHub device codes can't cross apps), the hub signs an
|
|
748
|
+
// audience-scoped Ed25519 assertion, and the instance verifies it here with the
|
|
749
|
+
// hub's PUBLIC key (provisioned into env) — no instance->hub call at exchange time.
|
|
750
|
+
// -------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
const HUB_ASSERTION_ISSUER = 'cloudbongos-hub';
|
|
753
|
+
|
|
754
|
+
// The hub's Ed25519 PUBLIC key (base64 of the SPKI DER), provisioned into the
|
|
755
|
+
// instance env (<PREFIX>_HUB_PUBLIC_KEY). Returns a KeyObject or null.
|
|
756
|
+
function loadHubPublicKey() {
|
|
757
|
+
const b64 = ic.resolveEnv('HUB_PUBLIC_KEY');
|
|
758
|
+
if (!b64) return null;
|
|
759
|
+
try {
|
|
760
|
+
return crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64'), format: 'der', type: 'spki' });
|
|
761
|
+
} catch (err) {
|
|
762
|
+
console.error('[auth] HUB_PUBLIC_KEY is set but unreadable:', err.message);
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Verify a hub-signed assertion (the compact-JWT format minted by the hub's
|
|
768
|
+
// platform-identity/hub-keys.js — kept in lockstep across the core<->module
|
|
769
|
+
// boundary, ADR 0091 §2). Verifies the Ed25519 signature FIRST (against the given
|
|
770
|
+
// public key), then the issuer, audience (must equal THIS instance's client_id),
|
|
771
|
+
// and expiry. The verify algorithm is HARDCODED to Ed25519 (`crypto.verify(null,…)`)
|
|
772
|
+
// regardless of the header's `alg` field, so there is no algorithm-confusion attack.
|
|
773
|
+
// Returns the validated payload, or throws. Pure over its inputs (testable).
|
|
774
|
+
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
775
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
776
|
+
const parts = String(token || '').split('.');
|
|
777
|
+
if (parts.length !== 3) throw new Error('malformed assertion');
|
|
778
|
+
const [h, p, s] = parts;
|
|
779
|
+
let header;
|
|
780
|
+
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
781
|
+
catch { throw new Error('malformed assertion header'); }
|
|
782
|
+
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected assertion alg');
|
|
783
|
+
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
784
|
+
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
785
|
+
if (!ok) throw new Error('assertion signature invalid');
|
|
786
|
+
let payload;
|
|
787
|
+
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
788
|
+
catch { throw new Error('malformed assertion payload'); }
|
|
789
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected assertion issuer');
|
|
790
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('assertion audience mismatch');
|
|
791
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('assertion expired');
|
|
792
|
+
if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
|
|
793
|
+
return payload;
|
|
794
|
+
}
|
|
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
|
+
|
|
835
|
+
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
836
|
+
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
837
|
+
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
838
|
+
// sessions even inside its ~120s life. A restart clears it — acceptable given the
|
|
839
|
+
// short window and that the aud binding is the primary control.
|
|
840
|
+
const _seenAssertionJti = new Map(); // jti -> expiry ms
|
|
841
|
+
function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
842
|
+
for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
|
|
843
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
844
|
+
if (_seenAssertionJti.has(jti)) return true;
|
|
845
|
+
_seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
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
|
+
|
|
868
|
+
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
869
|
+
// admission gate + upsertBuilder + createSession(source='cli') as the direct device
|
|
870
|
+
// flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
|
|
871
|
+
async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
872
|
+
const idp = loadIdpConfig();
|
|
873
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
874
|
+
const publicKey = loadHubPublicKey();
|
|
875
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
876
|
+
const payload = verifyHubAssertion(assertion, { publicKey, clientId: idp.clientId });
|
|
877
|
+
// Reject a replayed assertion (same jti) within its window.
|
|
878
|
+
if (_assertionJtiSeenAndRecord(payload.jti, payload.exp)) {
|
|
879
|
+
throw new Error('assertion already used');
|
|
880
|
+
}
|
|
881
|
+
const ghId = payload.sub;
|
|
882
|
+
const ghLogin = payload.github_login;
|
|
883
|
+
const existing = await getBuilderByGithubId(ghId);
|
|
884
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
885
|
+
const approved = await hasInvitedAccessRequest(ghLogin);
|
|
886
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
|
|
887
|
+
const err = new Error('not_approved');
|
|
888
|
+
err.code = 'NOT_APPROVED';
|
|
889
|
+
throw err;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
let builder = await upsertBuilder({
|
|
893
|
+
githubId: ghId,
|
|
894
|
+
githubLogin: ghLogin,
|
|
895
|
+
displayName: payload.display_name || ghLogin,
|
|
896
|
+
avatarUrl: payload.avatar_url,
|
|
897
|
+
});
|
|
898
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
899
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
900
|
+
const token = newSessionToken();
|
|
901
|
+
await createSession({
|
|
902
|
+
builderId: builder.id,
|
|
903
|
+
token,
|
|
904
|
+
source: 'cli',
|
|
905
|
+
userAgent: ua ?? null,
|
|
906
|
+
expiresAt: null,
|
|
907
|
+
});
|
|
908
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
909
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated CLI sign-in.
|
|
910
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url });
|
|
911
|
+
reportActivityRollup({ builderId: builder.id, githubId: ghId });
|
|
912
|
+
return { token, builder };
|
|
913
|
+
}
|
|
914
|
+
|
|
429
915
|
// -------------------------------------------------------------------------
|
|
430
916
|
// Discord OAuth (account-linking — Phase 3 F2, ADR 0033 §2)
|
|
431
917
|
// -------------------------------------------------------------------------
|
|
@@ -648,17 +1134,133 @@ async function resolveSession(req) {
|
|
|
648
1134
|
return null;
|
|
649
1135
|
}
|
|
650
1136
|
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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));
|
|
661
1241
|
}
|
|
1242
|
+
function sessionScopeAdmits(source, declared) {
|
|
1243
|
+
return scopeCovers(sessionScopeFor(source), declared);
|
|
1244
|
+
}
|
|
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));
|
|
662
1264
|
|
|
663
1265
|
async function requireBuilder(req, res, next) {
|
|
664
1266
|
const session = await resolveSession(req);
|
|
@@ -678,18 +1280,25 @@ async function requireBuilder(req, res, next) {
|
|
|
678
1280
|
res.fail('builder_inactive', 403);
|
|
679
1281
|
return;
|
|
680
1282
|
}
|
|
681
|
-
// SECURITY (#919, SR#2):
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
// (all of /tasks, /me, the rest of /box …) fails
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
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
|
+
});
|
|
693
1302
|
return;
|
|
694
1303
|
}
|
|
695
1304
|
// Anthropic-identity pin — REMOVED (task 1039, idea 258). This used to 403
|
|
@@ -729,17 +1338,6 @@ async function requireBuilder(req, res, next) {
|
|
|
729
1338
|
next();
|
|
730
1339
|
}
|
|
731
1340
|
|
|
732
|
-
// Page-level sign-in gate for the authenticated /builders HTML surface and its
|
|
733
|
-
// content assets (#488). Same identity check as requireBuilder — token from the
|
|
734
|
-
// gds_session cookie (or legacy pms_session / Bearer), looked up fresh against
|
|
735
|
-
// builder_sessions — but on failure it REDIRECTS the browser into the web
|
|
736
|
-
// sign-in flow (302 → /api/gds/auth/web/start) instead of returning a JSON 401,
|
|
737
|
-
// because these requests are page navigations and asset loads, not API calls.
|
|
738
|
-
//
|
|
739
|
-
// This is the *actual* access control for the citizen primer + system-maps:
|
|
740
|
-
// server.js mounts it ahead of the static handlers so an unauthenticated
|
|
741
|
-
// request is bounced before the file is ever served (curl-proof, JS-disabled-
|
|
742
|
-
// proof). The client-side link-hiding in builders.js is only cosmetic UX.
|
|
743
1341
|
// Build a `?return=<absolute current URL>` for a page-gate sign-in redirect
|
|
744
1342
|
// (#734) so a builder bounced into sign-in from the builders subdomain lands
|
|
745
1343
|
// back on the page they wanted. The web flow validates it (open-redirect guard)
|
|
@@ -753,87 +1351,114 @@ function signInWithReturn(req) {
|
|
|
753
1351
|
return `${base}?return=${encodeURIComponent(`${proto}://${host}${req.originalUrl}`)}`;
|
|
754
1352
|
}
|
|
755
1353
|
|
|
756
|
-
|
|
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;
|
|
757
1406
|
try {
|
|
758
|
-
|
|
759
|
-
if (!session) {
|
|
760
|
-
res.redirect(302, signInWithReturn(req));
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
next();
|
|
1407
|
+
return new Set((await governance.resolveBuilderPermissions(builderId)) || []);
|
|
764
1408
|
} catch (err) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
// gate protects is the priority here, not availability.
|
|
768
|
-
console.error('[gds] requireBuilderPage lookup failed', err);
|
|
769
|
-
res.redirect(302, signInWithReturn(req));
|
|
1409
|
+
console.error('[gds] permission resolve failed', err);
|
|
1410
|
+
return null;
|
|
770
1411
|
}
|
|
771
1412
|
}
|
|
772
1413
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
// - no session → 302 into the web sign-in flow (page navigation)
|
|
777
|
-
// - signed in, not Archon → 302 back to the hall (/builders) — they ARE a
|
|
778
|
-
// citizen, just not the Ruling Hand; bouncing to
|
|
779
|
-
// sign-in would loop, so send them home instead.
|
|
780
|
-
// - Archon → next()
|
|
781
|
-
// This is defense-in-depth: every data endpoint the page calls is ALSO
|
|
782
|
-
// requireRank('archon') server-side, so no cross-builder data can leak even if
|
|
783
|
-
// this gate were bypassed. But it keeps a non-Archon from ever seeing the page
|
|
784
|
-
// shell, honoring "only Archons have access".
|
|
785
|
-
async function requireArchonPage(req, res, next) {
|
|
1414
|
+
async function runPageGate(name, req, res, next) {
|
|
1415
|
+
const { permission, deniedTo } = PAGE_GATES[name];
|
|
1416
|
+
let session;
|
|
786
1417
|
try {
|
|
787
|
-
|
|
788
|
-
if (!session) {
|
|
789
|
-
res.redirect(302, signInWithReturn(req));
|
|
790
|
-
return;
|
|
791
|
-
}
|
|
792
|
-
if (session.rank !== 'archon') {
|
|
793
|
-
res.redirect(302, '/builders');
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
next();
|
|
1418
|
+
session = await resolveSession(req);
|
|
797
1419
|
} catch (err) {
|
|
798
|
-
// Fail CLOSED
|
|
799
|
-
// leaking the gated page
|
|
800
|
-
|
|
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);
|
|
801
1424
|
res.redirect(302, signInWithReturn(req));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (!session) {
|
|
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;
|
|
802
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);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
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);
|
|
803
1454
|
}
|
|
804
1455
|
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
// (Thetes+). Same identity resolution as requireBuilderPage, plus a live-rank
|
|
810
|
-
// check:
|
|
811
|
-
// - no session → 302 into the web sign-in flow (page navigation)
|
|
812
|
-
// - Xenos → 302 to /builders/not-ready — they ARE a citizen, just not
|
|
813
|
-
// ready for the reading rooms; the not-ready page offers the
|
|
814
|
-
// rank ladder + a way back to the hall. Bouncing to sign-in
|
|
815
|
-
// would loop, so send them to the friendly notice.
|
|
816
|
-
// - Thetes+ → next()
|
|
817
|
-
// Defense-in-depth: this is the curl-proof / no-JS-proof gate. The link-hiding
|
|
818
|
-
// in builders.js is only cosmetic.
|
|
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+).
|
|
819
1460
|
async function requireNonXenosPage(req, res, next) {
|
|
820
|
-
|
|
821
|
-
const session = await resolveSession(req);
|
|
822
|
-
if (!session) {
|
|
823
|
-
res.redirect(302, signInWithReturn(req));
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
if (session.rank === 'xenos') {
|
|
827
|
-
res.redirect(302, '/builders/not-ready');
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
next();
|
|
831
|
-
} catch (err) {
|
|
832
|
-
// Fail CLOSED — a lookup error sends the visitor to sign-in rather than
|
|
833
|
-
// leaking the gated page shell.
|
|
834
|
-
console.error('[gds] requireNonXenosPage lookup failed', err);
|
|
835
|
-
res.redirect(302, signInWithReturn(req));
|
|
836
|
-
}
|
|
1461
|
+
return runPageGate('requireNonXenosPage', req, res, next);
|
|
837
1462
|
}
|
|
838
1463
|
|
|
839
1464
|
// ---------------------------------------------------------------------------
|
|
@@ -931,6 +1556,63 @@ function requireRank(...allowed) {
|
|
|
931
1556
|
};
|
|
932
1557
|
}
|
|
933
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
|
+
|
|
934
1616
|
// requireBfgPrincipal — the identity gate for the cross-builder memory WRITE
|
|
935
1617
|
// path (ADR 0026 §6C / task #446). Runs AFTER requireBuilder. This is
|
|
936
1618
|
// DELIBERATELY NOT a rank gate: the trust-boundary exception is bound to ONE
|
|
@@ -969,23 +1651,43 @@ function requireDiscordBot(req, res, next) {
|
|
|
969
1651
|
|
|
970
1652
|
module.exports = {
|
|
971
1653
|
authConfigured,
|
|
1654
|
+
idpEnabled,
|
|
1655
|
+
loadIdpConfig,
|
|
1656
|
+
idpConfigured,
|
|
972
1657
|
openEnrollmentEnabled,
|
|
973
1658
|
firstTimeAdmissionBlocked,
|
|
1659
|
+
webAdmissionStatus,
|
|
974
1660
|
firstAdminLogin,
|
|
975
1661
|
maybeSeatFirstAdmin,
|
|
976
1662
|
startDeviceFlow,
|
|
977
1663
|
pollDeviceFlow,
|
|
978
1664
|
buildAuthorizeUrl,
|
|
1665
|
+
scopeForWebFlow,
|
|
1666
|
+
buildIdpAuthorizeUrl,
|
|
1667
|
+
idpAuthorizeUrl,
|
|
979
1668
|
completeWebFlow,
|
|
1669
|
+
completeIdpFlow,
|
|
1670
|
+
loadHubPublicKey,
|
|
1671
|
+
verifyHubAssertion,
|
|
1672
|
+
verifyHubLogoutToken,
|
|
1673
|
+
logoutTokenJtiSeenAndRecord,
|
|
1674
|
+
completeDeviceExchange,
|
|
1675
|
+
membershipRole,
|
|
980
1676
|
extractTokens,
|
|
981
1677
|
resolveSession,
|
|
982
1678
|
allowBoxScope,
|
|
1679
|
+
sessionScope,
|
|
1680
|
+
sessionScopeFor,
|
|
1681
|
+
sessionScopeAdmits,
|
|
1682
|
+
parseSessionScopes,
|
|
1683
|
+
SESSION_SCOPES,
|
|
983
1684
|
requireBuilder,
|
|
984
1685
|
requireBuilderPage,
|
|
985
1686
|
requireArchonPage,
|
|
986
1687
|
requireNonXenosPage,
|
|
987
1688
|
requireRank,
|
|
988
1689
|
rankMeetsThreshold,
|
|
1690
|
+
requirePermission,
|
|
989
1691
|
requireBfgPrincipal,
|
|
990
1692
|
requireDiscordBot,
|
|
991
1693
|
// Discord OAuth (account-linking — Phase 3 F2, ADR 0033)
|