@bongos/core 1.18.3 → 1.18.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +1575 -285
- package/.claude/skills/builder-box/SKILL.md +6 -0
- package/.claude/skills/builder-sequence/SKILL.md +97 -0
- package/.claude/skills/builder-ship/SKILL.md +8 -2
- package/.claude/skills/builder-start/SKILL.md +11 -0
- package/.claude/skills/builder-sync/SKILL.md +41 -0
- package/.claude/skills/fix-task/SKILL.md +64 -0
- package/.claude/skills/goal-create/SKILL.md +158 -0
- package/.claude/skills/goal-review/SKILL.md +3 -1
- package/.claude/skills/idea-triage/SKILL.md +14 -8
- package/.claude/skills/merge-mode/SKILL.md +2 -2
- package/.claude/skills/new-project/SKILL.md +1 -0
- package/.claude/skills/planning-session/SKILL.md +45 -18
- package/.env.local.example +4 -3
- package/README.md +4 -2
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +2 -2
- package/clients/bongos-client/bongos-client.global.js +82 -18
- package/clients/bongos-client/index.cjs +82 -18
- package/clients/bongos-client/index.d.ts +131 -29
- package/clients/bongos-client/index.mjs +82 -18
- package/config/branding.neutral.json +6 -1
- package/config/governance.neutral.json +21 -0
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
- package/docs/adr/0090-metic-task-authoring.md +1 -1
- package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
- package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
- package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
- package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
- package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
- package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
- package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
- package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
- package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
- package/docs/adr/0152-metic-task-abandonment.md +67 -0
- package/docs/adr/0153-sequential-chain-runner.md +51 -0
- package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
- package/docs/adr/README.md +10 -0
- package/docs/api/openapi.json +843 -17
- package/docs/api-reference.md +23 -10
- package/docs/architecture.md +4 -2
- package/docs/branding-contract.md +3 -1
- package/docs/canonical-permissions.md +21 -8
- package/docs/file-map.md +8 -0
- package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
- package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
- package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
- package/docs/onboarding/diagrams/assertions.json +4 -7
- package/docs/onboarding/drachmae-and-karma.md +3 -3
- package/docs/onboarding/primer.md +17 -16
- package/docs/onboarding/slash-commands.md +124 -0
- package/docs/recipes/builder-box-lifecycle.md +23 -0
- package/docs/recipes/devbox-app-release.md +14 -4
- package/docs/recipes/instance-first-run.md +16 -0
- package/docs/recipes/local-dev.md +81 -0
- package/docs/recipes/ops-gotchas.md +19 -1
- package/docs/recipes/overnight-builder.md +1 -1
- package/docs/recipes/parallel-goal-waves.md +174 -0
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/standalone-live-docs.md +48 -0
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
- package/migrations/003_pms.sql +6 -7
- package/migrations/006_pms_v2.sql +3 -31
- package/migrations/core_193_task_needs_rebase.sql +28 -0
- package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
- package/migrations/core_195_idea_triage_provenance.sql +57 -0
- package/migrations/core_196_session_reward_topup.sql +69 -0
- package/modules/autonomy/pollers/routine-timers.js +9 -8
- package/modules/builder-settings/wandering-prefs.js +61 -18
- package/modules/dev-box/app/README.md +73 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
- package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
- package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
- package/modules/dev-box/app/electron-builder.yml +95 -0
- package/modules/dev-box/app/package-lock.json +5291 -0
- package/modules/dev-box/app/package.json +30 -0
- package/modules/dev-box/app/renderer/app.js +319 -0
- package/modules/dev-box/app/renderer/index.html +45 -0
- package/modules/dev-box/app/renderer/styles.css +246 -0
- package/modules/dev-box/app/renderer/theme.generated.css +16 -0
- package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
- package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
- package/modules/dev-box/app/scripts/gen-brand.js +138 -0
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
- package/modules/dev-box/app/scripts/gen-icons.js +286 -0
- package/modules/dev-box/app/scripts/vendor-client.js +23 -0
- package/modules/dev-box/app/src/box-controller.js +287 -0
- package/modules/dev-box/app/src/brand.generated.json +13 -0
- package/modules/dev-box/app/src/brand.js +16 -0
- package/modules/dev-box/app/src/config.js +19 -0
- package/modules/dev-box/app/src/gds-client.js +90 -0
- package/modules/dev-box/app/src/main.js +610 -0
- package/modules/dev-box/app/src/pairing.js +57 -0
- package/modules/dev-box/app/src/preload.js +29 -0
- package/modules/dev-box/app/src/session-store.js +58 -0
- package/modules/dev-box/app/src/ssh-setup.js +569 -0
- package/modules/dev-box/app/src/task-picker.js +70 -0
- package/modules/dev-box/app/src/tray.js +99 -0
- package/modules/dev-box/app/src/updater.js +55 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
- package/modules/dev-box/app/test/unit.mjs +526 -0
- package/modules/dev-box/box-access.js +121 -4
- package/modules/dev-box/boxes.js +20 -0
- package/modules/dev-box/module.json +1 -1
- package/modules/dev-box/routes/box.js +87 -3
- package/modules/economy/credits.js +116 -25
- package/modules/economy/reward.js +8 -0
- package/modules/economy/streak.js +2 -1
- package/modules/governance/CLAUDE.md +15 -0
- package/modules/governance/catalog.js +419 -0
- package/modules/governance/config.js +174 -0
- package/modules/governance/db.js +125 -0
- package/modules/governance/migrations/governance_001_tables.sql +81 -0
- package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
- package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
- package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
- package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
- package/modules/governance/module.json +10 -0
- package/modules/governance/protected-surfaces.js +88 -0
- package/modules/governance/protected-surfaces.json +80 -0
- package/modules/governance/rank-roles.js +83 -0
- package/modules/governance/resolver.js +221 -0
- package/modules/governance/routes/governance.js +89 -0
- package/modules/governance/routes/role-sync.js +49 -0
- package/modules/governance/session-scopes.js +127 -0
- package/modules/governance/session-scopes.json +38 -0
- package/modules/hall-ui/public/atlas.css +117 -0
- package/modules/hall-ui/public/atlas.html +3 -114
- package/modules/hall-ui/public/brand-apply.js +34 -6
- package/modules/hall-ui/public/builders.js +187 -219
- package/modules/hall-ui/public/diagrams.html +2 -0
- package/modules/hall-ui/public/dom-utils.js +27 -0
- package/modules/hall-ui/public/drachmae.html +2 -0
- package/modules/hall-ui/public/drachmae.js +54 -1
- package/modules/hall-ui/public/gate.css +98 -0
- package/modules/hall-ui/public/gate.html +3 -95
- package/modules/hall-ui/public/goal-inbox.js +4 -1
- package/modules/hall-ui/public/goals-page.js +626 -114
- package/modules/hall-ui/public/goals.css +79 -1
- package/modules/hall-ui/public/goals.html +38 -11
- package/modules/hall-ui/public/hall-widgets.js +62 -52
- package/modules/hall-ui/public/harbor.css +123 -0
- package/modules/hall-ui/public/harbor.html +3 -120
- package/modules/hall-ui/public/index.html +11 -4
- package/modules/hall-ui/public/instances-panel.js +4 -1
- package/modules/hall-ui/public/modules.css +63 -0
- package/modules/hall-ui/public/modules.html +3 -60
- package/modules/hall-ui/public/new-project-wizard.js +7 -3
- package/modules/hall-ui/public/not-ready.html +2 -0
- package/modules/hall-ui/public/pair.css +73 -0
- package/modules/hall-ui/public/pair.html +3 -70
- package/modules/hall-ui/public/palette.js +302 -0
- package/modules/hall-ui/public/primer.css +13 -0
- package/modules/hall-ui/public/primer.html +2 -0
- package/modules/hall-ui/public/profile.html +2 -0
- package/modules/hall-ui/public/ranks.html +2 -0
- package/modules/hall-ui/public/roadmap.css +179 -0
- package/modules/hall-ui/public/roadmap.html +9 -172
- package/modules/hall-ui/public/roadmap.js +329 -316
- package/modules/hall-ui/public/sessions.css +16 -0
- package/modules/hall-ui/public/sessions.html +3 -13
- package/modules/hall-ui/public/settings.html +2 -0
- package/modules/hall-ui/public/settings.js +4 -4
- package/modules/hall-ui/public/shell.js +143 -18
- package/modules/hall-ui/public/style.css +231 -39
- package/modules/hall-ui/public/task-detail.js +347 -26
- package/modules/hall-ui/public/task.html +6 -1
- package/modules/hall-ui/public/watch.html +2 -0
- package/modules/hall-ui/public/work.html +8 -6
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/ideas/inbox.js +61 -11
- package/modules/ideas/routes/inbox.js +23 -0
- package/modules/lifecycle/claim-eligibility.js +72 -0
- package/modules/lifecycle/db.js +519 -139
- package/modules/lifecycle/done-when.js +18 -3
- package/modules/lifecycle/github-push.js +19 -2
- package/modules/lifecycle/goal-advisory.js +114 -0
- package/modules/lifecycle/goal-authz.js +62 -0
- package/modules/lifecycle/lifecycle.js +9 -0
- package/modules/lifecycle/publish-reconciler.js +109 -6
- package/modules/lifecycle/routes/claims.js +68 -23
- package/modules/lifecycle/routes/done-when.js +110 -15
- package/modules/lifecycle/routes/goals.js +8 -38
- package/modules/lifecycle/routes/tasks.js +38 -6
- package/modules/lifecycle/ship-card.js +6 -0
- package/modules/onboarding/onboarding-state.js +1 -1
- package/modules/platform-identity/CLAUDE.md +15 -0
- package/modules/platform-identity/hub-device.js +147 -0
- package/modules/platform-identity/hub-keys.js +151 -0
- package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
- package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
- package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
- package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
- package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
- package/modules/platform-identity/module.json +18 -0
- package/modules/platform-identity/platform-identity.js +531 -0
- package/modules/platform-identity/pollers/code-sweep.js +43 -0
- package/modules/platform-identity/routes/my-projects.js +140 -0
- package/modules/platform-identity/routes/projects.js +107 -0
- package/modules/platform-identity/routes/scouting.js +35 -0
- package/modules/platform-identity/routes/sso.js +402 -0
- package/modules/platform-identity/tests/platform-identity.mjs +388 -0
- package/modules/platform-identity/ui/.gitkeep +0 -0
- package/modules/security/routes/reports.js +10 -0
- package/modules/sessions/db.js +166 -21
- package/modules/sessions/module.json +1 -1
- package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
- package/modules/sessions/routes/sessions.js +33 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/anim/idle/frame_00.webp +0 -0
- package/public-landing/assets/anim/idle/frame_01.webp +0 -0
- package/public-landing/assets/anim/idle/frame_02.webp +0 -0
- package/public-landing/assets/anim/idle/frame_03.webp +0 -0
- package/public-landing/assets/anim/idle/frame_04.webp +0 -0
- package/public-landing/assets/anim/idle/frame_05.webp +0 -0
- package/public-landing/assets/anim/idle/frame_06.webp +0 -0
- package/public-landing/assets/anim/idle/frame_07.webp +0 -0
- package/public-landing/assets/anim/idle/frame_08.webp +0 -0
- package/public-landing/assets/anim/idle/frame_09.webp +0 -0
- package/public-landing/assets/anim/idle/frame_10.webp +0 -0
- package/public-landing/assets/anim/idle/frame_11.webp +0 -0
- package/public-landing/assets/anim/idle/frame_12.webp +0 -0
- package/public-landing/assets/anim/idle/frame_13.webp +0 -0
- package/public-landing/assets/anim/idle/frame_14.webp +0 -0
- package/public-landing/assets/anim/idle/frame_15.webp +0 -0
- package/public-landing/assets/anim/idle/frame_16.webp +0 -0
- package/public-landing/assets/anim/idle/frame_17.webp +0 -0
- package/public-landing/assets/anim/idle/frame_18.webp +0 -0
- package/public-landing/assets/anim/idle/frame_19.webp +0 -0
- package/public-landing/assets/anim/idle/frame_20.webp +0 -0
- package/public-landing/assets/anim/idle/frame_21.webp +0 -0
- package/public-landing/assets/anim/idle/frame_22.webp +0 -0
- package/public-landing/assets/anim/idle/frame_23.webp +0 -0
- package/public-landing/assets/anim/idle/frame_24.webp +0 -0
- package/public-landing/assets/anim/idle/frame_25.webp +0 -0
- package/public-landing/assets/anim/idle/frame_26.webp +0 -0
- package/public-landing/assets/anim/idle/frame_27.webp +0 -0
- package/public-landing/assets/anim/idle/frame_28.webp +0 -0
- package/public-landing/assets/anim/idle/frame_29.webp +0 -0
- package/public-landing/assets/anim/idle/frame_30.webp +0 -0
- package/public-landing/assets/anim/idle/frame_31.webp +0 -0
- package/public-landing/assets/anim/idle/frame_32.webp +0 -0
- package/public-landing/assets/anim/idle/frame_33.webp +0 -0
- package/public-landing/assets/anim/idle/frame_34.webp +0 -0
- package/public-landing/assets/anim/idle/frame_35.webp +0 -0
- package/public-landing/assets/anim/idle/frame_36.webp +0 -0
- package/public-landing/assets/anim/idle/frame_37.webp +0 -0
- package/public-landing/assets/anim/idle/frame_38.webp +0 -0
- package/public-landing/assets/anim/idle/frame_39.webp +0 -0
- package/public-landing/assets/anim/idle/frame_40.webp +0 -0
- package/public-landing/assets/anim/idle/frame_41.webp +0 -0
- package/public-landing/assets/anim/idle/frame_42.webp +0 -0
- package/public-landing/assets/anim/idle/frame_43.webp +0 -0
- package/public-landing/create.html +51 -58
- package/public-landing/gate/README.md +137 -0
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +587 -0
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +799 -0
- package/public-landing/gate/space.js +1826 -0
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
- package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
- package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
- package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
- package/public-landing/world/assets/plates/scene-establishing.png +0 -0
- package/scripts/gds/abandon.js +6 -4
- package/scripts/gds/api.js +10 -5
- package/scripts/gds/api.ps1 +26 -0
- package/scripts/gds/api.sh +6 -0
- package/scripts/gds/audit-ships.js +7 -1
- package/scripts/gds/backfill-sessions.js +78 -18
- package/scripts/gds/backup.js +23 -1
- package/scripts/gds/box-connect-lib.js +124 -31
- package/scripts/gds/box-sync.js +66 -0
- package/scripts/gds/box.js +225 -53
- package/scripts/gds/cf-tunnel.js +145 -6
- package/scripts/gds/claim.js +34 -3
- package/scripts/gds/cli-lib.js +161 -27
- package/scripts/gds/code.js +6 -2
- package/scripts/gds/deploy-divergence.js +209 -0
- package/scripts/gds/dev-lib.js +309 -0
- package/scripts/gds/dev.js +322 -0
- package/scripts/gds/doctor.js +90 -61
- package/scripts/gds/fetch-art-key.js +1 -1
- package/scripts/gds/fetch-sound-prefs.js +2 -2
- package/scripts/gds/fitness.js +228 -12
- package/scripts/gds/gds-db-backup.service +16 -1
- package/scripts/gds/gds-db-offsite.service +9 -1
- package/scripts/gds/gen-api-client.js +1 -1
- package/scripts/gds/gen-api-docs.js +36 -14
- package/scripts/gds/gen-diagrams.js +45 -28
- package/scripts/gds/gen-file-map.js +2 -1
- package/scripts/gds/gen-freshness.js +32 -0
- package/scripts/gds/gen-repo-map.js +3 -2
- package/scripts/gds/gen-session-index.js +14 -5
- package/scripts/gds/hooks/pre-push.js +2 -2
- package/scripts/gds/init.js +60 -3
- package/scripts/gds/install-git-hooks.js +1 -1
- package/scripts/gds/key-card.js +15 -6
- package/scripts/gds/key-status.js +1 -1
- package/scripts/gds/linkify-refs.js +35 -9
- package/scripts/gds/login.js +16 -1
- package/scripts/gds/paste-token.js +7 -1
- package/scripts/gds/preflight.js +126 -0
- package/scripts/gds/provision.js +123 -12
- package/scripts/gds/publish-manifest.js +7 -1
- package/scripts/gds/reauth.js +7 -3
- package/scripts/gds/regen-instance-docs.js +47 -0
- package/scripts/gds/run-unit-tests.js +4 -2
- package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
- package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
- package/scripts/gds/sequence.js +425 -0
- package/scripts/gds/session-cost.js +10 -1
- package/scripts/gds/setup.js +31 -5
- package/scripts/gds/shell.js +5 -3
- package/scripts/gds/ship.js +319 -29
- package/scripts/gds/smoke-gds.js +139 -8
- package/scripts/gds/start.js +171 -22
- package/scripts/gds/status.js +1 -1
- package/scripts/gds/triage.js +10 -9
- package/scripts/gds/upgrade.js +124 -2
- package/scripts/status-mirror-sync.js +114 -84
- package/src/bongos/api-errors.js +1 -0
- package/src/bongos/auth.js +447 -102
- package/src/bongos/db-kernel.js +27 -0
- package/src/bongos/db.js +39 -0
- package/src/bongos/module-scope-map.js +27 -1
- package/src/bongos/permission-path-check.js +136 -102
- package/src/bongos/repo-info.js +47 -11
- package/src/bongos/route-rank-check.js +429 -46
- package/src/bongos/routes/_helpers.js +12 -1
- package/src/bongos/routes/auth.js +219 -2
- package/src/bongos/routes/backup.js +58 -2
- package/src/bongos/routes/me.js +35 -1
- package/src/bongos/serve-internal.js +84 -15
- package/src/branding.js +32 -0
- package/src/instance-config.js +30 -0
- package/src/module-api.js +112 -1
- package/src/platform-server.js +86 -9
- package/tests/abandon_task.mjs +59 -3
- package/tests/api_cli_flags.mjs +53 -0
- package/tests/auth_page_gate.mjs +13 -0
- package/tests/backfill_sessions.mjs +127 -1
- package/tests/backup_cli.mjs +54 -0
- package/tests/bongos_box_connect.mjs +77 -0
- package/tests/box_access.mjs +130 -1
- package/tests/box_credential.mjs +125 -0
- package/tests/box_host_keys.mjs +24 -0
- package/tests/box_infra_resolve.mjs +149 -0
- package/tests/box_scope_session.mjs +6 -2
- package/tests/box_sync.mjs +55 -0
- package/tests/box_task_scope.mjs +99 -0
- package/tests/branding.mjs +12 -0
- package/tests/cf_ruleset_preflight.mjs +142 -0
- package/tests/cf_tunnel.mjs +37 -0
- package/tests/claim_gate_rebase.mjs +98 -0
- package/tests/clawback_confirmed_backlog.mjs +103 -0
- package/tests/cli_lib_formatters.mjs +20 -0
- package/tests/confirm_attribution.mjs +30 -46
- package/tests/credit_grant.mjs +198 -10
- package/tests/criterion_authoring_gate.mjs +272 -0
- package/tests/criterion_progress.mjs +33 -1
- package/tests/dev_lib.mjs +253 -0
- package/tests/devbox_artifact_gate.mjs +75 -0
- package/tests/devbox_dmg_backdrop.mjs +137 -0
- package/tests/devbox_icons.mjs +304 -0
- package/tests/devbox_installer_brand.mjs +232 -0
- package/tests/devbox_key_register.mjs +234 -0
- package/tests/docs_openapi_route.mjs +110 -0
- package/tests/doctor_hooks.mjs +141 -0
- package/tests/extract_json.mjs +140 -0
- package/tests/fitness.mjs +89 -0
- package/tests/gen_freshness.mjs +96 -0
- package/tests/goal_advisory.mjs +251 -0
- package/tests/goal_criterion_confirm_ui.mjs +269 -0
- package/tests/goal_routes.mjs +59 -4
- package/tests/goal_tier.mjs +12 -3
- package/tests/governance_catalog.mjs +129 -0
- package/tests/governance_claim_eligibility.mjs +262 -0
- package/tests/governance_config.mjs +187 -0
- package/tests/governance_ownership_scope.mjs +410 -0
- package/tests/governance_page_gates.mjs +324 -0
- package/tests/governance_parity.mjs +336 -0
- package/tests/governance_principals.mjs +232 -0
- package/tests/governance_protected_surfaces.mjs +208 -0
- package/tests/governance_require_permission.mjs +223 -0
- package/tests/governance_resolver.mjs +84 -0
- package/tests/governance_role_sync.mjs +231 -0
- package/tests/governance_seed.mjs +127 -0
- package/tests/governance_session_scope.mjs +434 -0
- package/tests/governance_vanilla_boot.mjs +203 -0
- package/tests/grade_attribution.mjs +7 -7
- package/tests/hall_audit.mjs +223 -0
- package/tests/hall_nav.mjs +191 -0
- package/tests/hall_palette.mjs +206 -0
- package/tests/hall_signout.mjs +124 -0
- package/tests/hook_user_agents.mjs +10 -0
- package/tests/idea_triage.mjs +162 -0
- package/tests/idp_device_exchange.mjs +93 -0
- package/tests/init.mjs +51 -0
- package/tests/instance_config.mjs +65 -0
- package/tests/instance_mismatch_guard.mjs +23 -6
- package/tests/instance_release_config.mjs +104 -0
- package/tests/linkify_refs.mjs +15 -2
- package/tests/module_contributions.mjs +55 -0
- package/tests/module_loader.mjs +10 -5
- package/tests/module_scope_active_claims.mjs +94 -0
- package/tests/needs_rebase.mjs +92 -0
- package/tests/one_active_claim_per_session.mjs +76 -0
- package/tests/override_attribution.mjs +10 -8
- package/tests/override_request_ownership.mjs +8 -4
- package/tests/parse_cookie_decode.mjs +42 -0
- package/tests/pay_on_land.mjs +129 -0
- package/tests/platform_boot.mjs +100 -1
- package/tests/post_login_landing.mjs +42 -0
- package/tests/preflight_checks.mjs +72 -0
- package/tests/provision.mjs +132 -7
- package/tests/publish_branch_route.mjs +53 -0
- package/tests/publish_reconciler.mjs +187 -2
- package/tests/repo_info_binding.mjs +248 -0
- package/tests/reward_gate.mjs +11 -2
- package/tests/reward_mode_cost_plus_only.mjs +116 -0
- package/tests/reward_mode_surfacing.mjs +168 -0
- package/tests/route_permission_check.mjs +301 -0
- package/tests/route_rank_check.mjs +254 -12
- package/tests/route_validation.mjs +30 -0
- package/tests/sequence.mjs +267 -0
- package/tests/session_digest.mjs +44 -0
- package/tests/session_index.mjs +18 -6
- package/tests/session_records.mjs +39 -1
- package/tests/session_reward_on_land.mjs +381 -0
- package/tests/session_reward_reconcile.mjs +229 -0
- package/tests/ship_api_client_pathspec.mjs +100 -0
- package/tests/ship_base_flag.mjs +93 -0
- package/tests/ship_ci_deploy.mjs +80 -3
- package/tests/ship_upstream_deletion.mjs +57 -0
- package/tests/smoke_target.mjs +233 -0
- package/tests/start_env_guard.mjs +87 -0
- package/tests/start_rebase_warning.mjs +103 -0
- package/tests/status_mirror_sync.mjs +122 -0
- package/tests/stop_refcheck.mjs +19 -1
- package/tests/task_goal_link.mjs +15 -6
- package/tests/upgrade.mjs +139 -0
- package/tests/web_admission_status.mjs +162 -0
- package/tests/write_validation_gaps.mjs +110 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// modules/platform-identity/platform-identity.js — the hub identity-provider
|
|
2
|
+
// domain (ADR 0141 §1/§2, task #1002106). Platform accounts + the SSO client
|
|
3
|
+
// registry + single-use authorization codes.
|
|
4
|
+
//
|
|
5
|
+
// THE ONE RULE (ADR 0016 / ADR 0141): this surface returns IDENTITY only —
|
|
6
|
+
// github_id, github_login, display_name, avatar_url — and NEVER a rank, credits,
|
|
7
|
+
// admission, or a session token. Authority is computed locally on each federated
|
|
8
|
+
// instance; the hub only answers "who is this GitHub user".
|
|
9
|
+
//
|
|
10
|
+
// Reaches core only through the doorway (src/module-api.js). The pure helpers
|
|
11
|
+
// (sha256hex, constantTimeEqualHex, clientSecretValid, redirectUriAllowed,
|
|
12
|
+
// newOpaqueCode) are exported for DB-free unit testing.
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const crypto = require('node:crypto');
|
|
16
|
+
const api = require('../../src/module-api');
|
|
17
|
+
|
|
18
|
+
const { pool: defaultPool } = api;
|
|
19
|
+
|
|
20
|
+
// Default authorization-code lifetime — deliberately short (~60s). A code is a
|
|
21
|
+
// bearer secret that travels through the browser; a tight TTL bounds the replay
|
|
22
|
+
// window even before the single-use guard fires.
|
|
23
|
+
const DEFAULT_CODE_TTL_MS = 60 * 1000;
|
|
24
|
+
|
|
25
|
+
// -------------------------------------------------------------------------
|
|
26
|
+
// Pure crypto helpers (no DB, no network — unit-tested directly)
|
|
27
|
+
// -------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
// SHA-256 hex of a string. Used to hash both the opaque authorization code and
|
|
30
|
+
// the client secret at rest. Both inputs are high-entropy random values, so a
|
|
31
|
+
// fast hash is appropriate (no password-dictionary surface); hashing-at-rest
|
|
32
|
+
// means a DB read leak never exposes a live code or secret.
|
|
33
|
+
function sha256hex(s) {
|
|
34
|
+
return crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Constant-time comparison of two hex digests. Length-checks first (digest
|
|
38
|
+
// lengths are fixed and non-secret), then crypto.timingSafeEqual so a timing
|
|
39
|
+
// side-channel can't reveal how many leading bytes of a guessed secret matched.
|
|
40
|
+
function constantTimeEqualHex(a, b) {
|
|
41
|
+
const ba = Buffer.from(String(a), 'utf8');
|
|
42
|
+
const bb = Buffer.from(String(b), 'utf8');
|
|
43
|
+
if (ba.length !== bb.length) return false;
|
|
44
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A fresh opaque authorization code: 256 bits of CSPRNG, base64url (URL-safe so
|
|
48
|
+
// it rides a redirect query string without escaping).
|
|
49
|
+
function newOpaqueCode() {
|
|
50
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A fixed decoy hash so the "unknown client" / "suspended client" paths perform
|
|
54
|
+
// the SAME hashing + constant-time compare as a real wrong-secret attempt. Without
|
|
55
|
+
// this, JS short-circuits before the compare for a missing client, leaking a timing
|
|
56
|
+
// oracle that distinguishes a registered client_id from an unregistered one — the
|
|
57
|
+
// "never distinguishes no-such-client from wrong-secret" guarantee (ADR 0141 §2).
|
|
58
|
+
// The decoy value is irrelevant; only that the work is identical on every path.
|
|
59
|
+
const DECOY_SECRET_HASH = sha256hex('platform-identity::absent-client::decoy');
|
|
60
|
+
|
|
61
|
+
// Does `presentedSecret` match the client's stored secret_hash? Constant-time, and
|
|
62
|
+
// constant-WORK: it always hashes the presented secret and runs one timingSafeEqual
|
|
63
|
+
// (against the real hash, or the decoy when the client is missing/secretless), then
|
|
64
|
+
// fails closed unless there was a real client secret to match. Callers may look the
|
|
65
|
+
// client up once and let this decide, with no client_id-enumeration timing oracle.
|
|
66
|
+
function clientSecretValid(client, presentedSecret) {
|
|
67
|
+
const hasRealSecret = !!(client && typeof client.secret_hash === 'string' && client.secret_hash);
|
|
68
|
+
const target = hasRealSecret ? client.secret_hash : DECOY_SECRET_HASH;
|
|
69
|
+
const match = constantTimeEqualHex(target, sha256hex(presentedSecret));
|
|
70
|
+
return hasRealSecret && match;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Decide whether a RESOLVED hub session may complete the SSO handoff. resolveSession
|
|
74
|
+
// (the raw resolver the browser leg uses so it can redirect-to-sign-in rather than
|
|
75
|
+
// 401) does NOT apply the deny gates requireBuilder does (src/bongos/auth.js) — so
|
|
76
|
+
// this replicates them: a deactivated account (status 'inactive'), a dev-box-scoped
|
|
77
|
+
// token (source 'box', which lives OUTSIDE the ADR 0016 trust boundary), and any
|
|
78
|
+
// non-human service principal (system_role set) must NOT federate. Pure + exported
|
|
79
|
+
// so the gate is unit-tested directly. Returns { ok, reason }.
|
|
80
|
+
function sessionUsableForFederation(session) {
|
|
81
|
+
if (!session) return { ok: false, reason: 'no_session' };
|
|
82
|
+
if (session.status === 'inactive') return { ok: false, reason: 'inactive' };
|
|
83
|
+
if (session.source === 'box') return { ok: false, reason: 'box_scope' };
|
|
84
|
+
if (session.system_role) return { ok: false, reason: 'service_principal' };
|
|
85
|
+
return { ok: true, reason: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Is `redirectUri` in this client's exact-match allow-list? The open-redirect
|
|
89
|
+
// guard (ADR 0141 §2 invariant #3): a code is only ever handed to a URI the
|
|
90
|
+
// client pre-registered, so a victim client's code can't be steered to an
|
|
91
|
+
// attacker callback.
|
|
92
|
+
function redirectUriAllowed(client, redirectUri) {
|
|
93
|
+
return !!client
|
|
94
|
+
&& Array.isArray(client.redirect_uris)
|
|
95
|
+
&& client.redirect_uris.includes(redirectUri);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
// Accounts
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
// Upsert the platform account for a GitHub identity (keyed on the stable
|
|
103
|
+
// github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
|
|
104
|
+
async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl }, { pool = defaultPool } = {}) {
|
|
105
|
+
const { rows } = await pool.query(
|
|
106
|
+
`INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
|
|
107
|
+
VALUES ($1, $2, $3, $4)
|
|
108
|
+
ON CONFLICT (github_id) DO UPDATE SET
|
|
109
|
+
github_login = EXCLUDED.github_login,
|
|
110
|
+
display_name = EXCLUDED.display_name,
|
|
111
|
+
avatar_url = EXCLUDED.avatar_url,
|
|
112
|
+
updated_at = now()
|
|
113
|
+
RETURNING id, github_id, github_login, display_name, avatar_url`,
|
|
114
|
+
[githubId, githubLogin, displayName ?? null, avatarUrl ?? null],
|
|
115
|
+
);
|
|
116
|
+
return rows[0];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Resolve the platform account for a github_login (for invitations by handle). A
|
|
120
|
+
// login isn't a stable key (it can change), so this returns the most-recently-seen
|
|
121
|
+
// account for that login, or null. Used only to map an invite's handle → github_id.
|
|
122
|
+
async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
|
|
123
|
+
const { rows } = await pool.query(
|
|
124
|
+
`SELECT github_id, github_login, display_name, avatar_url
|
|
125
|
+
FROM platform_identity_accounts WHERE lower(github_login) = lower($1)
|
|
126
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
127
|
+
[String(githubLogin || '')],
|
|
128
|
+
);
|
|
129
|
+
return rows[0] || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function getAccount(githubId, { pool = defaultPool } = {}) {
|
|
133
|
+
const { rows } = await pool.query(
|
|
134
|
+
`SELECT id, github_id, github_login, display_name, avatar_url, created_at, updated_at
|
|
135
|
+
FROM platform_identity_accounts WHERE github_id = $1`,
|
|
136
|
+
[githubId],
|
|
137
|
+
);
|
|
138
|
+
return rows[0] || null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -------------------------------------------------------------------------
|
|
142
|
+
// SSO client registry
|
|
143
|
+
// -------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
// Register (or rotate) a federated project as an SSO client. Provisioning
|
|
146
|
+
// (task #1002109) calls this with a freshly generated secret; the plaintext is
|
|
147
|
+
// hashed here and never stored. Upsert-on-client_id so re-provisioning rotates
|
|
148
|
+
// the secret + redirect_uris without a duplicate row.
|
|
149
|
+
async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId }, { pool = defaultPool } = {}) {
|
|
150
|
+
const secretHash = sha256hex(secret);
|
|
151
|
+
const uris = Array.isArray(redirectUris) ? redirectUris : [];
|
|
152
|
+
const { rows } = await pool.query(
|
|
153
|
+
`INSERT INTO platform_identity_sso_clients
|
|
154
|
+
(client_id, secret_hash, name, redirect_uris, origin, owner_github_id)
|
|
155
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
156
|
+
ON CONFLICT (client_id) DO UPDATE SET
|
|
157
|
+
secret_hash = EXCLUDED.secret_hash,
|
|
158
|
+
name = EXCLUDED.name,
|
|
159
|
+
redirect_uris = EXCLUDED.redirect_uris,
|
|
160
|
+
origin = EXCLUDED.origin,
|
|
161
|
+
owner_github_id = EXCLUDED.owner_github_id,
|
|
162
|
+
updated_at = now()
|
|
163
|
+
RETURNING id, client_id, name, redirect_uris, origin, owner_github_id, status`,
|
|
164
|
+
[clientId, secretHash, name, uris, origin ?? null, ownerGithubId ?? null],
|
|
165
|
+
);
|
|
166
|
+
// Auto-catalogue the project (unfeatured) so it appears in the Archon curation
|
|
167
|
+
// list the moment it federates (ADR 0141 §5). Best-effort — a catalog hiccup must
|
|
168
|
+
// never fail client registration. Only when an origin is known.
|
|
169
|
+
if (origin) {
|
|
170
|
+
try { await upsertProject({ origin, name, ownerGithubId }, { pool }); }
|
|
171
|
+
catch (err) { console.error('[platform-identity] auto-catalogue failed (non-fatal):', err.message); }
|
|
172
|
+
}
|
|
173
|
+
return rows[0];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Fetch a client by its public id, including secret_hash (for the constant-time
|
|
177
|
+
// compare at /sso/token). Returns null when unknown.
|
|
178
|
+
async function getClient(clientId, { pool = defaultPool } = {}) {
|
|
179
|
+
const { rows } = await pool.query(
|
|
180
|
+
`SELECT client_id, secret_hash, name, redirect_uris, origin, owner_github_id, status
|
|
181
|
+
FROM platform_identity_sso_clients WHERE client_id = $1`,
|
|
182
|
+
[clientId],
|
|
183
|
+
);
|
|
184
|
+
return rows[0] || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Resolve the ACTIVE client for a given instance origin. This is the authoritative
|
|
188
|
+
// mapping the CLI device-flow mint uses (ADR 0141 §1 security fix): the assertion's
|
|
189
|
+
// audience is derived from the USER-TYPED origin via this lookup, so a malicious
|
|
190
|
+
// instance manifest cannot steer the mint at a victim client_id. Exact-match on the
|
|
191
|
+
// normalized origin (trailing slashes trimmed). Returns null when no active client
|
|
192
|
+
// is registered for that origin (→ federation refuses, the correct default).
|
|
193
|
+
async function getClientByOrigin(origin, { pool = defaultPool } = {}) {
|
|
194
|
+
const norm = String(origin || '').replace(/\/+$/, '');
|
|
195
|
+
if (!norm) return null;
|
|
196
|
+
const { rows } = await pool.query(
|
|
197
|
+
`SELECT client_id, secret_hash, name, redirect_uris, origin, owner_github_id, status
|
|
198
|
+
FROM platform_identity_sso_clients
|
|
199
|
+
WHERE origin = $1 AND status = 'active'
|
|
200
|
+
ORDER BY id ASC LIMIT 1`,
|
|
201
|
+
[norm],
|
|
202
|
+
);
|
|
203
|
+
return rows[0] || null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
// Projects catalog + Explore (ADR 0141 §5)
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
// Upsert a catalog row (keyed by origin). Called when a client registers (so every
|
|
211
|
+
// federated project is catalogued, unfeatured) and by the Archon create/curate route.
|
|
212
|
+
// Never touches `featured` on update — that is Archon-only via setProjectFeatured.
|
|
213
|
+
async function upsertProject({ origin, name, tagline, artUrl, ownerGithubId }, { pool = defaultPool } = {}) {
|
|
214
|
+
const norm = String(origin || '').replace(/\/+$/, '');
|
|
215
|
+
if (!norm) throw new Error('project origin required');
|
|
216
|
+
const { rows } = await pool.query(
|
|
217
|
+
`INSERT INTO platform_identity_projects (origin, name, tagline, art_url, owner_github_id)
|
|
218
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
219
|
+
ON CONFLICT (origin) DO UPDATE SET
|
|
220
|
+
name = EXCLUDED.name,
|
|
221
|
+
tagline = COALESCE(EXCLUDED.tagline, platform_identity_projects.tagline),
|
|
222
|
+
art_url = COALESCE(EXCLUDED.art_url, platform_identity_projects.art_url),
|
|
223
|
+
owner_github_id = COALESCE(EXCLUDED.owner_github_id, platform_identity_projects.owner_github_id),
|
|
224
|
+
updated_at = now()
|
|
225
|
+
RETURNING id, origin, name, tagline, art_url, owner_github_id, status, featured`,
|
|
226
|
+
[norm, name || norm, tagline ?? null, artUrl ?? null, ownerGithubId ?? null],
|
|
227
|
+
);
|
|
228
|
+
return rows[0];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function getProject(id, { pool = defaultPool } = {}) {
|
|
232
|
+
const { rows } = await pool.query(
|
|
233
|
+
`SELECT id, origin, name, tagline, art_url, owner_github_id, status, featured
|
|
234
|
+
FROM platform_identity_projects WHERE id = $1`,
|
|
235
|
+
[id],
|
|
236
|
+
);
|
|
237
|
+
return rows[0] || null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The PUBLIC Explore listing — featured + active projects only, public projection
|
|
241
|
+
// (no owner_github_id, no status). Newest first.
|
|
242
|
+
async function listFeaturedProjects({ pool = defaultPool } = {}) {
|
|
243
|
+
const { rows } = await pool.query(
|
|
244
|
+
`SELECT origin, name, tagline, art_url
|
|
245
|
+
FROM platform_identity_projects
|
|
246
|
+
WHERE featured = true AND status = 'active'
|
|
247
|
+
ORDER BY updated_at DESC`,
|
|
248
|
+
);
|
|
249
|
+
return rows;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The Archon curation list — every active project + its featured flag.
|
|
253
|
+
async function listAllProjects({ pool = defaultPool } = {}) {
|
|
254
|
+
const { rows } = await pool.query(
|
|
255
|
+
`SELECT id, origin, name, tagline, art_url, owner_github_id, status, featured, updated_at
|
|
256
|
+
FROM platform_identity_projects
|
|
257
|
+
WHERE status = 'active'
|
|
258
|
+
ORDER BY featured DESC, updated_at DESC`,
|
|
259
|
+
);
|
|
260
|
+
return rows;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Archon-only: flip the Explore feature flag on a catalog row.
|
|
264
|
+
async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
|
|
265
|
+
const { rows } = await pool.query(
|
|
266
|
+
`UPDATE platform_identity_projects SET featured = $2, updated_at = now()
|
|
267
|
+
WHERE id = $1 RETURNING id, origin, name, featured, status`,
|
|
268
|
+
[id, !!featured],
|
|
269
|
+
);
|
|
270
|
+
return rows[0] || null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
// Project memberships (the cross-project directory — ADR 0141 §3)
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
const MEMBERSHIP_ROLES = ['owner', 'builder', 'visitor', 'pending'];
|
|
278
|
+
|
|
279
|
+
// Upsert a builder's membership in a project (keyed by the reporting client + the
|
|
280
|
+
// builder's github_id). COARSE role only — never a rank. Refreshes last_active on
|
|
281
|
+
// every check-in. Returns the row.
|
|
282
|
+
async function upsertMembership({ clientId, githubId, role = 'builder' }, { pool = defaultPool } = {}) {
|
|
283
|
+
const r = MEMBERSHIP_ROLES.includes(role) ? role : 'builder';
|
|
284
|
+
const { rows } = await pool.query(
|
|
285
|
+
`INSERT INTO platform_identity_project_memberships (client_id, github_id, role)
|
|
286
|
+
VALUES ($1, $2, $3)
|
|
287
|
+
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
288
|
+
role = EXCLUDED.role,
|
|
289
|
+
last_active = now()
|
|
290
|
+
RETURNING id, client_id, github_id, role, joined_at, last_active`,
|
|
291
|
+
[clientId, githubId, r],
|
|
292
|
+
);
|
|
293
|
+
return rows[0];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// All memberships for one account, newest-active first, joined to the project's
|
|
297
|
+
// display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
|
|
298
|
+
async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
|
|
299
|
+
const { rows } = await pool.query(
|
|
300
|
+
`SELECT m.client_id, m.role, m.joined_at, m.last_active,
|
|
301
|
+
c.name AS project_name, c.origin AS project_origin, c.owner_github_id
|
|
302
|
+
FROM platform_identity_project_memberships m
|
|
303
|
+
LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
|
|
304
|
+
WHERE m.github_id = $1 AND m.role <> 'pending'
|
|
305
|
+
ORDER BY m.last_active DESC`,
|
|
306
|
+
[githubId],
|
|
307
|
+
);
|
|
308
|
+
return rows;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// -------------------------------------------------------------------------
|
|
312
|
+
// Invitations (ADR 0141 §5, task #1002115) — pre-seed a 'pending' membership
|
|
313
|
+
// -------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
// Invite a builder into a project they don't yet build on: pre-seed a 'pending'
|
|
316
|
+
// membership. Does NOT clobber an existing membership (ON CONFLICT DO NOTHING) — a
|
|
317
|
+
// current member is left as-is. Returns { invited:true, row } when a pending row was
|
|
318
|
+
// created, or { invited:false, alreadyMember:true } when a membership already existed.
|
|
319
|
+
async function inviteToProject({ clientId, githubId }, { pool = defaultPool } = {}) {
|
|
320
|
+
const { rows } = await pool.query(
|
|
321
|
+
`INSERT INTO platform_identity_project_memberships (client_id, github_id, role)
|
|
322
|
+
VALUES ($1, $2, 'pending')
|
|
323
|
+
ON CONFLICT (client_id, github_id) DO NOTHING
|
|
324
|
+
RETURNING id, client_id, github_id, role`,
|
|
325
|
+
[clientId, githubId],
|
|
326
|
+
);
|
|
327
|
+
if (rows[0]) return { invited: true, row: rows[0] };
|
|
328
|
+
return { invited: false, alreadyMember: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The pending invitations for one account (joined to the project display fields).
|
|
332
|
+
async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {}) {
|
|
333
|
+
const { rows } = await pool.query(
|
|
334
|
+
`SELECT m.client_id, m.joined_at AS invited_at,
|
|
335
|
+
c.name AS project_name, c.origin AS project_origin
|
|
336
|
+
FROM platform_identity_project_memberships m
|
|
337
|
+
LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
|
|
338
|
+
WHERE m.github_id = $1 AND m.role = 'pending'
|
|
339
|
+
ORDER BY m.joined_at DESC`,
|
|
340
|
+
[githubId],
|
|
341
|
+
);
|
|
342
|
+
return rows;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Resolve a pending invite for (client, builder): accept → flip to 'builder' (the
|
|
346
|
+
// membership becomes real; the builder then signs into the project, whose own gate
|
|
347
|
+
// still applies + whose check-in keeps it fresh); decline → delete the pending row.
|
|
348
|
+
// Only ever touches a 'pending' row, so it can't downgrade or remove a real member.
|
|
349
|
+
async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPool } = {}) {
|
|
350
|
+
if (accept) {
|
|
351
|
+
const { rows } = await pool.query(
|
|
352
|
+
`UPDATE platform_identity_project_memberships SET role = 'builder', last_active = now()
|
|
353
|
+
WHERE client_id = $1 AND github_id = $2 AND role = 'pending'
|
|
354
|
+
RETURNING id`,
|
|
355
|
+
[clientId, githubId],
|
|
356
|
+
);
|
|
357
|
+
return { resolved: rows.length > 0, accepted: true };
|
|
358
|
+
}
|
|
359
|
+
const { rowCount } = await pool.query(
|
|
360
|
+
`DELETE FROM platform_identity_project_memberships
|
|
361
|
+
WHERE client_id = $1 AND github_id = $2 AND role = 'pending'`,
|
|
362
|
+
[clientId, githubId],
|
|
363
|
+
);
|
|
364
|
+
return { resolved: rowCount > 0, accepted: false };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Self-service LEAVE (ADR 0141 §3): remove a builder's membership in a project. The
|
|
368
|
+
// directory reflects the leave immediately; if the builder still actively builds on
|
|
369
|
+
// that instance they re-appear on their next sign-in check-in (the instance holds
|
|
370
|
+
// local authority). Returns the number of rows removed (0 or 1).
|
|
371
|
+
async function removeMembership(clientId, githubId, { pool = defaultPool } = {}) {
|
|
372
|
+
const { rowCount } = await pool.query(
|
|
373
|
+
`DELETE FROM platform_identity_project_memberships WHERE client_id = $1 AND github_id = $2`,
|
|
374
|
+
[clientId, githubId],
|
|
375
|
+
);
|
|
376
|
+
return rowCount;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
|
|
380
|
+
const { rows } = await pool.query(
|
|
381
|
+
`UPDATE platform_identity_sso_clients SET status = $2, updated_at = now()
|
|
382
|
+
WHERE client_id = $1 RETURNING client_id, status`,
|
|
383
|
+
[clientId, status],
|
|
384
|
+
);
|
|
385
|
+
return rows[0] || null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
// Cross-project builder activity (the scouting data plane — ADR 0141 §4)
|
|
390
|
+
// -------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
// Upsert one project's rollup for one builder (keyed by client_id + github_id).
|
|
393
|
+
// Descriptive analytics — never authority. Refreshes last_active on each report.
|
|
394
|
+
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [] }, { pool = defaultPool } = {}) {
|
|
395
|
+
const disc = Array.isArray(disciplines) ? disciplines : [];
|
|
396
|
+
const { rows } = await pool.query(
|
|
397
|
+
`INSERT INTO platform_identity_builder_project_activity
|
|
398
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
|
|
399
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
|
|
400
|
+
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
401
|
+
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
402
|
+
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
403
|
+
last_active = now(), updated_at = now()
|
|
404
|
+
RETURNING id, client_id, github_id`,
|
|
405
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, rank, disc],
|
|
406
|
+
);
|
|
407
|
+
return rows[0];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The scouting rollup: one row PER BUILDER, aggregated across every project they
|
|
411
|
+
// build on. Joined to the account for display. Ranked by total credits then ships —
|
|
412
|
+
// a talent signal, NOT an authority. Powers the Archon-only scouting view (#1002114).
|
|
413
|
+
async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
|
|
414
|
+
const { rows } = await pool.query(
|
|
415
|
+
`SELECT a.github_id,
|
|
416
|
+
acc.github_login, acc.display_name, acc.avatar_url,
|
|
417
|
+
count(*)::int AS project_count,
|
|
418
|
+
sum(a.credits)::int AS total_credits,
|
|
419
|
+
sum(a.tasks_shipped)::int AS total_tasks_shipped,
|
|
420
|
+
sum(a.karma)::int AS total_karma,
|
|
421
|
+
max(a.last_active) AS last_active,
|
|
422
|
+
array_agg(DISTINCT d) FILTER (WHERE d IS NOT NULL) AS disciplines
|
|
423
|
+
FROM platform_identity_builder_project_activity a
|
|
424
|
+
LEFT JOIN platform_identity_accounts acc ON acc.github_id = a.github_id
|
|
425
|
+
LEFT JOIN LATERAL unnest(a.disciplines) AS d ON true
|
|
426
|
+
GROUP BY a.github_id, acc.github_login, acc.display_name, acc.avatar_url
|
|
427
|
+
ORDER BY total_credits DESC, total_tasks_shipped DESC
|
|
428
|
+
LIMIT $1`,
|
|
429
|
+
[limit],
|
|
430
|
+
);
|
|
431
|
+
return rows;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// -------------------------------------------------------------------------
|
|
435
|
+
// Authorization codes (single-use, short-lived, bound)
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
// Mint a single-use authorization code bound to (client_id, github_id,
|
|
439
|
+
// redirect_uri), carrying an identity snapshot. Stores only the code's hash;
|
|
440
|
+
// returns the plaintext code (the only place it ever exists outside the browser).
|
|
441
|
+
async function mintAuthCode(
|
|
442
|
+
{ clientId, githubId, githubLogin, displayName, avatarUrl, redirectUri, ttlMs = DEFAULT_CODE_TTL_MS },
|
|
443
|
+
{ pool = defaultPool } = {},
|
|
444
|
+
) {
|
|
445
|
+
const code = newOpaqueCode();
|
|
446
|
+
const codeHash = sha256hex(code);
|
|
447
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
448
|
+
await pool.query(
|
|
449
|
+
`INSERT INTO platform_identity_auth_codes
|
|
450
|
+
(code_hash, client_id, github_id, redirect_uri, github_login, display_name, avatar_url, expires_at)
|
|
451
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
452
|
+
[codeHash, clientId, githubId, redirectUri, githubLogin, displayName ?? null, avatarUrl ?? null, expiresAt],
|
|
453
|
+
);
|
|
454
|
+
return code;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Atomically redeem a code for a given client. Single-winner by construction: the
|
|
458
|
+
// UPDATE ... WHERE consumed_at IS NULL ... RETURNING flips consumed_at in the same
|
|
459
|
+
// statement, so two concurrent redeems can never both succeed. Also enforces the
|
|
460
|
+
// client binding and the TTL. Returns the identity snapshot (+ the bound
|
|
461
|
+
// redirect_uri, for the caller's redirect_uri match) or null on any miss.
|
|
462
|
+
async function redeemAuthCode({ code, clientId }, { pool = defaultPool } = {}) {
|
|
463
|
+
const codeHash = sha256hex(code);
|
|
464
|
+
const { rows } = await pool.query(
|
|
465
|
+
`UPDATE platform_identity_auth_codes
|
|
466
|
+
SET consumed_at = now()
|
|
467
|
+
WHERE code_hash = $1
|
|
468
|
+
AND client_id = $2
|
|
469
|
+
AND consumed_at IS NULL
|
|
470
|
+
AND expires_at > now()
|
|
471
|
+
RETURNING github_id, github_login, display_name, avatar_url, redirect_uri`,
|
|
472
|
+
[codeHash, clientId],
|
|
473
|
+
);
|
|
474
|
+
return rows[0] || null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// TTL sweep of expired / consumed codes — the bound on table growth (wired as a
|
|
478
|
+
// module poller, pollers/code-sweep.js, started only on the hub). Codes have a ~60s
|
|
479
|
+
// TTL, so a 5-minute grace after expiry/consumption is ample slack for clock skew
|
|
480
|
+
// while keeping the table to roughly the last few minutes of sign-in traffic even
|
|
481
|
+
// under a burst of mints. Cheap; safe to call on a timer.
|
|
482
|
+
async function sweepExpiredCodes({ pool = defaultPool } = {}) {
|
|
483
|
+
const { rowCount } = await pool.query(
|
|
484
|
+
`DELETE FROM platform_identity_auth_codes
|
|
485
|
+
WHERE expires_at < now() - interval '5 minutes'
|
|
486
|
+
OR (consumed_at IS NOT NULL AND consumed_at < now() - interval '5 minutes')`,
|
|
487
|
+
);
|
|
488
|
+
return rowCount;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
module.exports = {
|
|
492
|
+
DEFAULT_CODE_TTL_MS,
|
|
493
|
+
// pure helpers
|
|
494
|
+
sha256hex,
|
|
495
|
+
constantTimeEqualHex,
|
|
496
|
+
newOpaqueCode,
|
|
497
|
+
clientSecretValid,
|
|
498
|
+
redirectUriAllowed,
|
|
499
|
+
sessionUsableForFederation,
|
|
500
|
+
// accounts
|
|
501
|
+
upsertAccount,
|
|
502
|
+
getAccount,
|
|
503
|
+
getAccountByLogin,
|
|
504
|
+
// clients
|
|
505
|
+
registerClient,
|
|
506
|
+
getClient,
|
|
507
|
+
getClientByOrigin,
|
|
508
|
+
setClientStatus,
|
|
509
|
+
// projects catalog + Explore
|
|
510
|
+
upsertProject,
|
|
511
|
+
getProject,
|
|
512
|
+
listFeaturedProjects,
|
|
513
|
+
listAllProjects,
|
|
514
|
+
setProjectFeatured,
|
|
515
|
+
// memberships
|
|
516
|
+
MEMBERSHIP_ROLES,
|
|
517
|
+
upsertMembership,
|
|
518
|
+
listMembershipsForAccount,
|
|
519
|
+
removeMembership,
|
|
520
|
+
// invitations
|
|
521
|
+
inviteToProject,
|
|
522
|
+
listPendingInvitesForAccount,
|
|
523
|
+
resolveInvite,
|
|
524
|
+
// cross-project activity + scouting
|
|
525
|
+
upsertActivity,
|
|
526
|
+
listScoutingRollup,
|
|
527
|
+
// codes
|
|
528
|
+
mintAuthCode,
|
|
529
|
+
redeemAuthCode,
|
|
530
|
+
sweepExpiredCodes,
|
|
531
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/platform-identity/pollers/code-sweep.js — the loader poller entry that
|
|
4
|
+
// bounds platform_identity_auth_codes growth (ADR 0141 §2). Codes have a ~60s TTL;
|
|
5
|
+
// each /sso/authorize mints one row, and without a sweep the table would grow
|
|
6
|
+
// unbounded. The loader's startModulePollers requires THIS file and calls
|
|
7
|
+
// start(deps) at boot / stop() at shutdown for the ENABLED module only (the hub) —
|
|
8
|
+
// non-blocking + failure-isolated, so a sweep hiccup never touches sign-in.
|
|
9
|
+
//
|
|
10
|
+
// Mirrors the lifecycle publish-reconciler poller shape.
|
|
11
|
+
|
|
12
|
+
const pi = require('../platform-identity');
|
|
13
|
+
|
|
14
|
+
// Every 5 minutes — codes expire in ~60s and the sweep keeps a 5-minute grace, so
|
|
15
|
+
// this cadence keeps the table to roughly the last few minutes of traffic.
|
|
16
|
+
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
let timer = null;
|
|
19
|
+
|
|
20
|
+
async function sweepOnce(deps = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const n = await pi.sweepExpiredCodes(deps.pool ? { pool: deps.pool } : {});
|
|
23
|
+
if (n > 0) console.log(`[platform-identity] code-sweep: pruned ${n} expired/consumed auth code(s)`);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// Best-effort: a sweep failure must never crash the process or block sign-in.
|
|
26
|
+
console.error('[platform-identity] code-sweep failed (non-fatal):', err && err.message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
start: (deps = {}) => {
|
|
32
|
+
if (timer) return; // idempotent
|
|
33
|
+
// unref so the sweep timer never keeps the process alive on its own.
|
|
34
|
+
timer = setInterval(() => { sweepOnce(deps); }, SWEEP_INTERVAL_MS);
|
|
35
|
+
if (timer.unref) timer.unref();
|
|
36
|
+
},
|
|
37
|
+
stop: () => {
|
|
38
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
39
|
+
},
|
|
40
|
+
// exported for tests
|
|
41
|
+
sweepOnce,
|
|
42
|
+
SWEEP_INTERVAL_MS,
|
|
43
|
+
};
|