@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,388 @@
|
|
|
1
|
+
// modules/platform-identity/tests/platform-identity.mjs
|
|
2
|
+
//
|
|
3
|
+
// DB-free unit suite for the hub identity-provider domain (ADR 0141, task #1002106).
|
|
4
|
+
// Two layers: the pure crypto helpers (no DB), and the domain DB functions against
|
|
5
|
+
// a RECORDING FAKE pool (asserts the SQL shape + params + the security invariants
|
|
6
|
+
// without needing Postgres). Auto-run by scripts/gds/run-unit-tests.js ONLY when the
|
|
7
|
+
// platform-identity module is enabled; run standalone:
|
|
8
|
+
// node modules/platform-identity/tests/platform-identity.mjs
|
|
9
|
+
//
|
|
10
|
+
// These assertions ARE the security spec for the grader-outage window: single-use
|
|
11
|
+
// atomic redeem, code/secret hashed-at-rest, redirect_uri exact-match allow-list,
|
|
12
|
+
// and identity-only redemption (never a rank).
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const P = require('../platform-identity.js');
|
|
20
|
+
|
|
21
|
+
let passed = 0, failed = 0;
|
|
22
|
+
function t(name, fn) {
|
|
23
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
24
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
25
|
+
}
|
|
26
|
+
async function ta(name, fn) {
|
|
27
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
28
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A recording fake pool: returns queued responses in order and records every call.
|
|
32
|
+
function mockPool(responses = []) {
|
|
33
|
+
const calls = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
return {
|
|
36
|
+
calls,
|
|
37
|
+
query: async (text, params) => {
|
|
38
|
+
calls.push({ text: String(text), params: params || [] });
|
|
39
|
+
const r = responses[i++];
|
|
40
|
+
return r || { rows: [], rowCount: 0 };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('pure crypto helpers:');
|
|
46
|
+
|
|
47
|
+
t('sha256hex is deterministic + input-sensitive', () => {
|
|
48
|
+
assert.equal(P.sha256hex('abc'), P.sha256hex('abc'));
|
|
49
|
+
assert.notEqual(P.sha256hex('abc'), P.sha256hex('abd'));
|
|
50
|
+
assert.equal(P.sha256hex('abc'), crypto.createHash('sha256').update('abc').digest('hex'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
t('constantTimeEqualHex: equal → true, different → false, different length → false', () => {
|
|
54
|
+
const h = P.sha256hex('secret');
|
|
55
|
+
assert.equal(P.constantTimeEqualHex(h, h), true);
|
|
56
|
+
assert.equal(P.constantTimeEqualHex(h, P.sha256hex('other')), false);
|
|
57
|
+
assert.equal(P.constantTimeEqualHex(h, h.slice(0, 10)), false);
|
|
58
|
+
assert.equal(P.constantTimeEqualHex('', ''), true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
t('newOpaqueCode: url-safe, ~256-bit, unique across calls', () => {
|
|
62
|
+
const a = P.newOpaqueCode();
|
|
63
|
+
const b = P.newOpaqueCode();
|
|
64
|
+
assert.notEqual(a, b);
|
|
65
|
+
assert.match(a, /^[A-Za-z0-9_-]+$/, 'must be base64url (no +,/,=)');
|
|
66
|
+
// 32 bytes base64url → 43 chars.
|
|
67
|
+
assert.equal(a.length, 43);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
t('clientSecretValid: correct secret true; wrong/absent false', () => {
|
|
71
|
+
const secret = 'a'.repeat(64);
|
|
72
|
+
const client = { secret_hash: P.sha256hex(secret) };
|
|
73
|
+
assert.equal(P.clientSecretValid(client, secret), true);
|
|
74
|
+
assert.equal(P.clientSecretValid(client, 'wrong'), false);
|
|
75
|
+
assert.equal(P.clientSecretValid(null, secret), false);
|
|
76
|
+
assert.equal(P.clientSecretValid({}, secret), false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
t('redirectUriAllowed: exact match only (no prefix/substring/host tricks)', () => {
|
|
80
|
+
const client = { redirect_uris: ['https://proj.cloudbongos.com/api/gds/auth/web/callback'] };
|
|
81
|
+
assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com/api/gds/auth/web/callback'), true);
|
|
82
|
+
// A longer path that has the allowed URI as a prefix must NOT match.
|
|
83
|
+
assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com/api/gds/auth/web/callback/evil'), false);
|
|
84
|
+
// A shorter/host-only variant must NOT match.
|
|
85
|
+
assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com'), false);
|
|
86
|
+
// A look-alike host must NOT match.
|
|
87
|
+
assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com.evil.test/api/gds/auth/web/callback'), false);
|
|
88
|
+
assert.equal(P.redirectUriAllowed(null, 'https://x'), false);
|
|
89
|
+
assert.equal(P.redirectUriAllowed({ redirect_uris: [] }, 'https://x'), false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
t('clientSecretValid fails closed for unknown/secretless client (decoy path, no throw)', () => {
|
|
93
|
+
// The decoy-hash path must still return false and must not throw — the timing is
|
|
94
|
+
// equalized, but an absent client can never authenticate.
|
|
95
|
+
assert.equal(P.clientSecretValid(undefined, 'anything'), false);
|
|
96
|
+
assert.equal(P.clientSecretValid({ status: 'suspended' }, 'anything'), false);
|
|
97
|
+
assert.equal(P.clientSecretValid({ secret_hash: '' }, 'anything'), false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
console.log('\nsessionUsableForFederation (the browser-leg deny gate):');
|
|
101
|
+
|
|
102
|
+
t('a live full-authority web/cli session may federate', () => {
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
P.sessionUsableForFederation({ github_id: 1, status: 'active', source: 'web', system_role: null }),
|
|
105
|
+
{ ok: true, reason: null });
|
|
106
|
+
assert.equal(P.sessionUsableForFederation({ github_id: 1, source: 'cli' }).ok, true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
t('a missing session → no_session (caller redirects to sign-in)', () => {
|
|
110
|
+
assert.deepEqual(P.sessionUsableForFederation(null), { ok: false, reason: 'no_session' });
|
|
111
|
+
assert.deepEqual(P.sessionUsableForFederation(undefined), { ok: false, reason: 'no_session' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
t('a deactivated account may NOT federate (mirrors requireBuilder status gate)', () => {
|
|
115
|
+
const r = P.sessionUsableForFederation({ github_id: 1, status: 'inactive', source: 'web' });
|
|
116
|
+
assert.deepEqual(r, { ok: false, reason: 'inactive' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
t('a dev-box-scoped token may NOT federate (outside the trust boundary)', () => {
|
|
120
|
+
const r = P.sessionUsableForFederation({ github_id: 1, status: 'active', source: 'box' });
|
|
121
|
+
assert.deepEqual(r, { ok: false, reason: 'box_scope' });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
t('a service principal (system_role set) may NOT federate', () => {
|
|
125
|
+
const r = P.sessionUsableForFederation({ github_id: 1, status: 'active', source: 'web', system_role: 'bfg' });
|
|
126
|
+
assert.deepEqual(r, { ok: false, reason: 'service_principal' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log('\naccounts:');
|
|
130
|
+
|
|
131
|
+
await ta('upsertAccount upserts on github_id and returns the row', async () => {
|
|
132
|
+
const row = { id: '1', github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a' };
|
|
133
|
+
const pool = mockPool([{ rows: [row] }]);
|
|
134
|
+
const out = await P.upsertAccount(
|
|
135
|
+
{ githubId: 42, githubLogin: 'octocat', displayName: 'Octo', avatarUrl: 'a' }, { pool });
|
|
136
|
+
assert.deepEqual(out, row);
|
|
137
|
+
const c = pool.calls[0];
|
|
138
|
+
assert.match(c.text, /INSERT INTO platform_identity_accounts/);
|
|
139
|
+
assert.match(c.text, /ON CONFLICT \(github_id\) DO UPDATE/);
|
|
140
|
+
assert.deepEqual(c.params, [42, 'octocat', 'Octo', 'a']);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log('\nSSO client registry:');
|
|
144
|
+
|
|
145
|
+
await ta('registerClient hashes the secret at rest — never stores the plaintext', async () => {
|
|
146
|
+
const secret = 'z'.repeat(64);
|
|
147
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1' }] }]);
|
|
148
|
+
await P.registerClient(
|
|
149
|
+
{ clientId: 'c1', secret, name: 'Proj', redirectUris: ['https://p/cb'], origin: 'https://p', ownerGithubId: 7 },
|
|
150
|
+
{ pool });
|
|
151
|
+
const c = pool.calls[0];
|
|
152
|
+
assert.match(c.text, /INSERT INTO platform_identity_sso_clients/);
|
|
153
|
+
assert.match(c.text, /ON CONFLICT \(client_id\) DO UPDATE/);
|
|
154
|
+
// The secret param must be the HASH, and the plaintext must not appear anywhere.
|
|
155
|
+
assert.equal(c.params[1], P.sha256hex(secret), 'secret_hash param must be sha256(secret)');
|
|
156
|
+
assert.ok(!c.params.includes(secret), 'plaintext secret must never be a query param');
|
|
157
|
+
assert.deepEqual(c.params[3], ['https://p/cb'], 'redirect_uris array passed through');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await ta('getClient selects by client_id and returns null when unknown', async () => {
|
|
161
|
+
const pool = mockPool([{ rows: [] }]);
|
|
162
|
+
const out = await P.getClient('nope', { pool });
|
|
163
|
+
assert.equal(out, null);
|
|
164
|
+
assert.match(pool.calls[0].text, /FROM platform_identity_sso_clients WHERE client_id = \$1/);
|
|
165
|
+
assert.deepEqual(pool.calls[0].params, ['nope']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await ta('getClientByOrigin resolves the ACTIVE client by normalized origin (the CLI-mint audience source)', async () => {
|
|
169
|
+
const pool = mockPool([{ rows: [{ client_id: 'c-victim', status: 'active' }] }]);
|
|
170
|
+
const out = await P.getClientByOrigin('https://victim.com/', { pool });
|
|
171
|
+
assert.equal(out.client_id, 'c-victim');
|
|
172
|
+
// Trailing slash normalized; gated to active clients only.
|
|
173
|
+
assert.deepEqual(pool.calls[0].params, ['https://victim.com']);
|
|
174
|
+
assert.match(pool.calls[0].text, /WHERE origin = \$1 AND status = 'active'/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await ta('getClientByOrigin returns null for an unregistered origin (federation refuses)', async () => {
|
|
178
|
+
const pool = mockPool([{ rows: [] }]);
|
|
179
|
+
assert.equal(await P.getClientByOrigin('https://evil.example', { pool }), null);
|
|
180
|
+
// Empty / missing origin never hits the DB.
|
|
181
|
+
const pool2 = mockPool([]);
|
|
182
|
+
assert.equal(await P.getClientByOrigin('', { pool: pool2 }), null);
|
|
183
|
+
assert.equal(pool2.calls.length, 0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
console.log('\nprojects catalog + Explore (§5):');
|
|
187
|
+
|
|
188
|
+
await ta('upsertProject upserts on normalized origin; update preserves tagline/art (COALESCE)', async () => {
|
|
189
|
+
const pool = mockPool([{ rows: [{ id: '1', origin: 'https://p', featured: false }] }]);
|
|
190
|
+
await P.upsertProject({ origin: 'https://p/', name: 'Proj' }, { pool });
|
|
191
|
+
const c = pool.calls[0];
|
|
192
|
+
assert.match(c.text, /INSERT INTO platform_identity_projects/);
|
|
193
|
+
assert.match(c.text, /ON CONFLICT \(origin\) DO UPDATE/);
|
|
194
|
+
assert.match(c.text, /tagline\s*=\s*COALESCE/);
|
|
195
|
+
assert.equal(c.params[0], 'https://p', 'trailing slash normalized');
|
|
196
|
+
// Never sets featured on upsert — that is Archon-only.
|
|
197
|
+
assert.doesNotMatch(c.text, /featured\s*=/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only, no owner/status)', async () => {
|
|
201
|
+
const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null }] }]);
|
|
202
|
+
const out = await P.listFeaturedProjects({ pool });
|
|
203
|
+
assert.equal(out.length, 1);
|
|
204
|
+
const c = pool.calls[0];
|
|
205
|
+
assert.match(c.text, /WHERE featured = true AND status = 'active'/);
|
|
206
|
+
assert.doesNotMatch(c.text, /owner_github_id/, 'public projection must not leak owner id');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
|
|
210
|
+
const pool = mockPool([{ rows: [{ id: '1', featured: true }] }]);
|
|
211
|
+
const out = await P.setProjectFeatured(1, true, { pool });
|
|
212
|
+
assert.equal(out.featured, true);
|
|
213
|
+
assert.match(pool.calls[0].text, /UPDATE platform_identity_projects SET featured = \$2/);
|
|
214
|
+
assert.deepEqual(pool.calls[0].params, [1, true]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await ta('registerClient auto-catalogues the project (2nd query) when an origin is given', async () => {
|
|
218
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
|
|
219
|
+
await P.registerClient({ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p', ownerGithubId: 7 }, { pool });
|
|
220
|
+
assert.equal(pool.calls.length, 2, 'client insert + project upsert');
|
|
221
|
+
assert.match(pool.calls[1].text, /INSERT INTO platform_identity_projects/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
console.log('\nproject memberships (§3 check-in directory):');
|
|
225
|
+
|
|
226
|
+
await ta('upsertMembership upserts on (client_id, github_id) with a coarse role', async () => {
|
|
227
|
+
const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', role: 'owner' }] }]);
|
|
228
|
+
await P.upsertMembership({ clientId: 'c1', githubId: 42, role: 'owner' }, { pool });
|
|
229
|
+
const c = pool.calls[0];
|
|
230
|
+
assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
|
|
231
|
+
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
232
|
+
assert.match(c.text, /last_active = now\(\)/);
|
|
233
|
+
assert.deepEqual(c.params, ['c1', 42, 'owner']);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await ta('upsertMembership clamps an unknown role to builder (never trusts a caller role blindly)', async () => {
|
|
237
|
+
const pool = mockPool([{ rows: [{}] }]);
|
|
238
|
+
await P.upsertMembership({ clientId: 'c1', githubId: 42, role: 'archon' }, { pool });
|
|
239
|
+
assert.equal(pool.calls[0].params[2], 'builder', 'a non-coarse role is clamped to builder');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await ta('removeMembership deletes the (client_id, github_id) row (self-service leave)', async () => {
|
|
243
|
+
const pool = mockPool([{ rowCount: 1 }]);
|
|
244
|
+
const n = await P.removeMembership('c1', 42, { pool });
|
|
245
|
+
assert.equal(n, 1);
|
|
246
|
+
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships WHERE client_id = \$1 AND github_id = \$2/);
|
|
247
|
+
assert.deepEqual(pool.calls[0].params, ['c1', 42]);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await ta('listMembershipsForAccount joins projects + excludes pending', async () => {
|
|
251
|
+
const pool = mockPool([{ rows: [{ client_id: 'c1', role: 'builder', project_name: 'Proj' }] }]);
|
|
252
|
+
const out = await P.listMembershipsForAccount(42, { pool });
|
|
253
|
+
assert.equal(out.length, 1);
|
|
254
|
+
const c = pool.calls[0];
|
|
255
|
+
assert.match(c.text, /FROM platform_identity_project_memberships m/);
|
|
256
|
+
assert.match(c.text, /LEFT JOIN platform_identity_sso_clients c/);
|
|
257
|
+
assert.match(c.text, /role <> 'pending'/);
|
|
258
|
+
assert.deepEqual(c.params, [42]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
console.log('\ninvitations (§5 — pre-seed pending membership):');
|
|
262
|
+
|
|
263
|
+
await ta('inviteToProject pre-seeds a pending row, never clobbering an existing member', async () => {
|
|
264
|
+
const pool = mockPool([{ rows: [{ id: '1', role: 'pending' }] }]);
|
|
265
|
+
const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
|
|
266
|
+
assert.equal(out.invited, true);
|
|
267
|
+
const c = pool.calls[0];
|
|
268
|
+
assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
|
|
269
|
+
assert.match(c.text, /VALUES \(\$1, \$2, 'pending'\)/);
|
|
270
|
+
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO NOTHING/, 'must not downgrade an existing member');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await ta('inviteToProject reports already-member when the row exists (DO NOTHING → no row)', async () => {
|
|
274
|
+
const pool = mockPool([{ rows: [] }]);
|
|
275
|
+
const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
|
|
276
|
+
assert.deepEqual(out, { invited: false, alreadyMember: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await ta('resolveInvite accept flips ONLY a pending row to builder', async () => {
|
|
280
|
+
const pool = mockPool([{ rows: [{ id: '1' }] }]);
|
|
281
|
+
const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: true }, { pool });
|
|
282
|
+
assert.deepEqual(out, { resolved: true, accepted: true });
|
|
283
|
+
const c = pool.calls[0];
|
|
284
|
+
assert.match(c.text, /UPDATE platform_identity_project_memberships SET role = 'builder'/);
|
|
285
|
+
assert.match(c.text, /AND role = 'pending'/, 'only touches pending — never a real member');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await ta('resolveInvite decline deletes ONLY a pending row', async () => {
|
|
289
|
+
const pool = mockPool([{ rowCount: 1 }]);
|
|
290
|
+
const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: false }, { pool });
|
|
291
|
+
assert.deepEqual(out, { resolved: true, accepted: false });
|
|
292
|
+
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND role = 'pending'/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await ta('getAccountByLogin resolves case-insensitively, newest first', async () => {
|
|
296
|
+
const pool = mockPool([{ rows: [{ github_id: '42', github_login: 'Octo' }] }]);
|
|
297
|
+
const out = await P.getAccountByLogin('octo', { pool });
|
|
298
|
+
assert.equal(out.github_id, '42');
|
|
299
|
+
assert.match(pool.calls[0].text, /lower\(github_login\) = lower\(\$1\)/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
console.log('\ncross-project activity (§4 scouting data plane):');
|
|
303
|
+
|
|
304
|
+
await ta('upsertActivity upserts per (client_id, github_id) with the talent snapshot', async () => {
|
|
305
|
+
const pool = mockPool([{ rows: [{ id: '1' }] }]);
|
|
306
|
+
await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3, rank: 'metic', disciplines: ['engineer'] }, { pool });
|
|
307
|
+
const c = pool.calls[0];
|
|
308
|
+
assert.match(c.text, /INSERT INTO platform_identity_builder_project_activity/);
|
|
309
|
+
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
310
|
+
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer']]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await ta('listScoutingRollup aggregates per builder across projects, ranked, joined to accounts', async () => {
|
|
314
|
+
const pool = mockPool([{ rows: [{ github_id: '42', github_login: 'octo', total_credits: 250, project_count: 2 }] }]);
|
|
315
|
+
const out = await P.listScoutingRollup({ pool, limit: 50 });
|
|
316
|
+
assert.equal(out.length, 1);
|
|
317
|
+
const c = pool.calls[0];
|
|
318
|
+
assert.match(c.text, /FROM platform_identity_builder_project_activity a/);
|
|
319
|
+
assert.match(c.text, /GROUP BY a\.github_id/);
|
|
320
|
+
assert.match(c.text, /sum\(a\.credits\)/);
|
|
321
|
+
assert.match(c.text, /ORDER BY total_credits DESC/);
|
|
322
|
+
assert.deepEqual(c.params, [50]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
console.log('\nauthorization codes:');
|
|
326
|
+
|
|
327
|
+
await ta('mintAuthCode stores the HASH of the code (never the plaintext) + binds it', async () => {
|
|
328
|
+
const pool = mockPool([{ rows: [] }]);
|
|
329
|
+
const code = await P.mintAuthCode(
|
|
330
|
+
{ clientId: 'c1', githubId: 42, githubLogin: 'octocat', displayName: 'Octo', avatarUrl: 'a',
|
|
331
|
+
redirectUri: 'https://p/cb' },
|
|
332
|
+
{ pool });
|
|
333
|
+
assert.match(code, /^[A-Za-z0-9_-]{43}$/, 'returns a fresh opaque code');
|
|
334
|
+
const c = pool.calls[0];
|
|
335
|
+
assert.match(c.text, /INSERT INTO platform_identity_auth_codes/);
|
|
336
|
+
// param 0 is code_hash — it must equal sha256(returned code), and the plaintext
|
|
337
|
+
// code must NOT be among the params.
|
|
338
|
+
assert.equal(c.params[0], P.sha256hex(code), 'code_hash param must be sha256(code)');
|
|
339
|
+
assert.ok(!c.params.includes(code), 'plaintext code must never be a query param');
|
|
340
|
+
// Binding: client_id, github_id, redirect_uri present.
|
|
341
|
+
assert.equal(c.params[1], 'c1');
|
|
342
|
+
assert.equal(c.params[2], 42);
|
|
343
|
+
assert.equal(c.params[3], 'https://p/cb');
|
|
344
|
+
// expires_at (last param) is a Date in the near future (~<= default TTL).
|
|
345
|
+
const exp = c.params[7];
|
|
346
|
+
assert.ok(exp instanceof Date, 'expires_at is a Date');
|
|
347
|
+
const dt = exp.getTime() - Date.now();
|
|
348
|
+
assert.ok(dt > 0 && dt <= P.DEFAULT_CODE_TTL_MS + 1000, `TTL ~${P.DEFAULT_CODE_TTL_MS}ms`);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await ta('redeemAuthCode is an ATOMIC single-use redeem, bound + identity-only', async () => {
|
|
352
|
+
const identity = { github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a', redirect_uri: 'https://p/cb' };
|
|
353
|
+
const pool = mockPool([{ rows: [identity] }]);
|
|
354
|
+
const code = 'plain-code-value';
|
|
355
|
+
const out = await P.redeemAuthCode({ code, clientId: 'c1' }, { pool });
|
|
356
|
+
assert.deepEqual(out, identity);
|
|
357
|
+
const c = pool.calls[0];
|
|
358
|
+
// Atomic single-use: one UPDATE that sets consumed_at AND guards on it being NULL.
|
|
359
|
+
assert.match(c.text, /UPDATE platform_identity_auth_codes/);
|
|
360
|
+
assert.match(c.text, /SET consumed_at = now\(\)/);
|
|
361
|
+
assert.match(c.text, /consumed_at IS NULL/);
|
|
362
|
+
assert.match(c.text, /expires_at > now\(\)/);
|
|
363
|
+
assert.match(c.text, /RETURNING/);
|
|
364
|
+
// Looked up by the HASH of the presented code + bound to the client.
|
|
365
|
+
assert.equal(c.params[0], P.sha256hex(code), 'looked up by sha256(code)');
|
|
366
|
+
assert.equal(c.params[1], 'c1');
|
|
367
|
+
// Identity-only: the RETURNING clause must not leak a rank / credits / token.
|
|
368
|
+
assert.doesNotMatch(c.text, /\brank\b/i, 'redeem must never return a rank');
|
|
369
|
+
assert.doesNotMatch(c.text, /\bcredits?\b/i, 'redeem must never return credits');
|
|
370
|
+
assert.doesNotMatch(c.text, /\btoken\b/i, 'redeem must never return a session token');
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await ta('redeemAuthCode returns null on no-match (consumed/expired/wrong client)', async () => {
|
|
374
|
+
const pool = mockPool([{ rows: [] }]);
|
|
375
|
+
const out = await P.redeemAuthCode({ code: 'x', clientId: 'c1' }, { pool });
|
|
376
|
+
assert.equal(out, null);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await ta('sweepExpiredCodes deletes expired + long-consumed rows', async () => {
|
|
380
|
+
const pool = mockPool([{ rowCount: 3 }]);
|
|
381
|
+
const n = await P.sweepExpiredCodes({ pool });
|
|
382
|
+
assert.equal(n, 3);
|
|
383
|
+
assert.match(pool.calls[0].text, /DELETE FROM platform_identity_auth_codes/);
|
|
384
|
+
assert.match(pool.calls[0].text, /expires_at < now\(\)/);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
|
|
388
|
+
if (failed > 0) process.exit(1);
|
|
File without changes
|
|
@@ -137,6 +137,12 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
137
137
|
api.requireBuilder,
|
|
138
138
|
api.requireRank('archon'),
|
|
139
139
|
async (req, res) => {
|
|
140
|
+
// drachmae_awarded is OPTIONAL and deliberately UNTYPED here: the handler
|
|
141
|
+
// accepts a number, a stringified number, '' and null alike, and the inline
|
|
142
|
+
// check below is what enforces finite/non-negative. Declaring a type would
|
|
143
|
+
// narrow the live contract; declaring the field at all is what lets the
|
|
144
|
+
// validator reject UNKNOWN fields, which is the actual gap (BV1.R109c).
|
|
145
|
+
if (validateOrRespond(req, res, { drachmae_awarded: {} })) return;
|
|
140
146
|
const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
|
|
141
147
|
if (id === null) return;
|
|
142
148
|
const drachmaeRaw = req.body?.drachmae_awarded;
|
|
@@ -221,6 +227,10 @@ module.exports = function buildSecurityReportsRouter() {
|
|
|
221
227
|
api.requireBuilder,
|
|
222
228
|
api.requireRank('archon'),
|
|
223
229
|
async (req, res) => {
|
|
230
|
+
// task_id is REQUIRED but untyped: the handler accepts a number or a
|
|
231
|
+
// stringified number and coerces below, so a type would narrow the live
|
|
232
|
+
// contract. Same idiom as POST /tasks/:id/dependencies (BV1.R109c).
|
|
233
|
+
if (validateOrRespond(req, res, { task_id: { required: true } })) return;
|
|
224
234
|
const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
|
|
225
235
|
if (id === null) return;
|
|
226
236
|
const taskIdRaw = req.body?.task_id;
|
package/modules/sessions/db.js
CHANGED
|
@@ -109,41 +109,184 @@ async function upsertSessionRecord(opts) {
|
|
|
109
109
|
return upsertSessionRecordTx(pool, opts);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// awardSessionTokenRewardTx — resolve the economy `reward` port and book the
|
|
113
|
+
// idempotent cost-plus session token reward on `client`, mirroring the granted
|
|
114
|
+
// drachmae onto the session_records row for observability (the authoritative
|
|
115
|
+
// payout is the credit_log row; token_reward_drachmae is a mirror — migration
|
|
116
|
+
// 091). Returns the reward result ({ drachmae, awarded, ... }). Degrades to a
|
|
117
|
+
// clean no-op when economy is disabled (no `reward` provider). Shared by BOTH
|
|
118
|
+
// pay-on-land triggers (the upload-side gate below + the task.shipped listener
|
|
119
|
+
// via awardDeferredSessionRewardForTask) so both book the reward identically;
|
|
120
|
+
// per-session watermark idempotency (the SELECT fast-path + the
|
|
121
|
+
// uniq_credit_session_token_reward_basis partial index) makes a double-attempt
|
|
122
|
+
// safe, and lets a later, costlier ship in the same session book its increment.
|
|
123
|
+
// task 1676: pass totalTokens so the reward can fall back to pricing the
|
|
124
|
+
// session's real token total when modelUsage is empty/unpriceable — the
|
|
125
|
+
// dev-box/CI agent path that historically paid $0 cost-plus (ADR 0097).
|
|
126
|
+
async function awardSessionTokenRewardTx(client, { builderId, sessionId, modelUsage, totalTokens }) {
|
|
127
|
+
const reward = seams.resolveOptional('reward');
|
|
128
|
+
if (!reward) return { drachmae: 0, trueCostUsd: 0, awarded: false, unknownModels: [], deferred: false };
|
|
129
|
+
const tokenReward = await reward.awardSessionTokenReward(
|
|
130
|
+
client, builderId, sessionId, modelUsage || {}, totalTokens || 0
|
|
131
|
+
);
|
|
132
|
+
if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) {
|
|
133
|
+
await client.query(
|
|
134
|
+
`UPDATE session_records
|
|
135
|
+
SET token_reward_drachmae = token_reward_drachmae + $2
|
|
136
|
+
WHERE session_id = $1 AND builder_id = $3`,
|
|
137
|
+
[sessionId, tokenReward.drachmae, builderId]
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return tokenReward;
|
|
141
|
+
}
|
|
142
|
+
|
|
112
143
|
// upsertSessionRecordWithReward — ONE transaction: upsert the session digest
|
|
113
|
-
// (incl. model_usage), then
|
|
114
|
-
// reward (task 1005, ADR 0054)
|
|
115
|
-
// Returns the record augmented with { tokenReward }. The
|
|
116
|
-
// SEPARATE credit_log row (reason='session.token_reward'); total_credits is
|
|
144
|
+
// (incl. model_usage), then — the UPLOAD-side half of ADR 0120 "pay on land" —
|
|
145
|
+
// book the cost-plus token reward (task 1005, ADR 0054) ONLY IF the code has
|
|
146
|
+
// already LANDED. Returns the record augmented with { tokenReward }. The reward
|
|
147
|
+
// is a SEPARATE credit_log row (reason='session.token_reward'); total_credits is
|
|
117
148
|
// bumped by the credit_log trigger only, never double-counted here.
|
|
149
|
+
//
|
|
150
|
+
// ADR 0120: the reward is contingent on the SHIP, not the upload. ship.js orders
|
|
151
|
+
// differ by mode: sync/laptop mode LANDS the task (status='shipped') then uploads
|
|
152
|
+
// the digest, so at upload time a task is already shipped → we award now. ci/async
|
|
153
|
+
// mode UPLOADS the digest first and lands later, so no task is shipped yet → we
|
|
154
|
+
// DEFER (record the digest, award nothing) and the reward books when the
|
|
155
|
+
// task.shipped event fires (routes/sessions.js's listener →
|
|
156
|
+
// awardDeferredSessionRewardForTask). Both triggers call the SAME idempotent
|
|
157
|
+
// award, so exactly-once is guaranteed by the per-session_id credit_log
|
|
158
|
+
// idempotency — never by which trigger wins the race.
|
|
118
159
|
async function upsertSessionRecordWithReward(opts) {
|
|
119
160
|
return api.withTx(async (client) => {
|
|
120
161
|
const record = await upsertSessionRecordTx(client, opts);
|
|
121
|
-
|
|
162
|
+
// Deferred by default (ADR 0120): the digest is recorded, but the token
|
|
163
|
+
// reward only books once at least one of the session's tasks has landed.
|
|
164
|
+
let tokenReward = { drachmae: 0, trueCostUsd: 0, awarded: false, unknownModels: [], deferred: true };
|
|
122
165
|
if (record) {
|
|
123
166
|
// record is non-null only when the ownership guard passed (the row is the
|
|
124
|
-
// caller's), so
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await client.query(
|
|
136
|
-
`UPDATE session_records
|
|
137
|
-
SET token_reward_drachmae = token_reward_drachmae + $2
|
|
138
|
-
WHERE session_id = $1 AND builder_id = $3`,
|
|
139
|
-
[opts.sessionId, tokenReward.drachmae, opts.builderId]
|
|
167
|
+
// caller's), so any reward is always attributed to the verified owner.
|
|
168
|
+
// Gate on the SHIP: award now iff at least one of the session's task_ids
|
|
169
|
+
// has reached status='shipped'. Otherwise leave the deferred default.
|
|
170
|
+
const ids = Array.isArray(record.task_ids)
|
|
171
|
+
? record.task_ids.map((n) => Number(n)).filter(Number.isFinite)
|
|
172
|
+
: [];
|
|
173
|
+
let landed = false;
|
|
174
|
+
if (ids.length > 0) {
|
|
175
|
+
const { rows } = await client.query(
|
|
176
|
+
`SELECT 1 FROM tasks WHERE id = ANY($1::bigint[]) AND status = 'shipped' LIMIT 1`,
|
|
177
|
+
[ids]
|
|
140
178
|
);
|
|
179
|
+
landed = rows.length > 0;
|
|
180
|
+
}
|
|
181
|
+
if (landed) {
|
|
182
|
+
tokenReward = await awardSessionTokenRewardTx(client, {
|
|
183
|
+
builderId: opts.builderId,
|
|
184
|
+
sessionId: opts.sessionId,
|
|
185
|
+
modelUsage: opts.modelUsage || {},
|
|
186
|
+
totalTokens: opts.totalTokens || 0,
|
|
187
|
+
});
|
|
141
188
|
}
|
|
142
189
|
}
|
|
143
190
|
return record ? { ...record, tokenReward } : null;
|
|
144
191
|
});
|
|
145
192
|
}
|
|
146
193
|
|
|
194
|
+
// awardDeferredSessionRewardForTask — the LAND-side half of ADR 0120 "pay on
|
|
195
|
+
// land". When a task reaches status='shipped', routes/sessions.js's task.shipped
|
|
196
|
+
// listener calls this with the shipped task id. It finds every session_record
|
|
197
|
+
// that shipped this task and books the cost-plus token reward for each — the
|
|
198
|
+
// ci/async path, where the digest was uploaded (and DEFERRED) before the code
|
|
199
|
+
// landed. Opens its own transaction.
|
|
200
|
+
//
|
|
201
|
+
// We do NOT pre-filter on token_reward_drachmae: awardSessionTokenReward is
|
|
202
|
+
// idempotent per session_id, so a session that already collected at upload time
|
|
203
|
+
// (sync mode) — or a re-fired event — is a clean no-op. The credit_log is the
|
|
204
|
+
// single source of truth for "already paid"; the mirror column is only
|
|
205
|
+
// observability, so it is never the idempotency key. Returns { sessions, awarded }
|
|
206
|
+
// (rows scanned, rewards freshly booked).
|
|
207
|
+
async function awardDeferredSessionRewardForTask(taskId) {
|
|
208
|
+
const id = Number(taskId);
|
|
209
|
+
if (!Number.isFinite(id)) return { sessions: 0, awarded: 0 };
|
|
210
|
+
return api.withTx(async (client) => {
|
|
211
|
+
const { rows } = await client.query(
|
|
212
|
+
`SELECT session_id, builder_id, model_usage, total_tokens
|
|
213
|
+
FROM session_records
|
|
214
|
+
WHERE task_ids @> ARRAY[$1]::bigint[]`,
|
|
215
|
+
[id]
|
|
216
|
+
);
|
|
217
|
+
let awarded = 0;
|
|
218
|
+
for (const r of rows) {
|
|
219
|
+
const tokenReward = await awardSessionTokenRewardTx(client, {
|
|
220
|
+
builderId: r.builder_id,
|
|
221
|
+
sessionId: r.session_id,
|
|
222
|
+
modelUsage: r.model_usage || {},
|
|
223
|
+
totalTokens: r.total_tokens || 0,
|
|
224
|
+
});
|
|
225
|
+
if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
|
|
226
|
+
}
|
|
227
|
+
return { sessions: rows.length, awarded };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// reconcileUnbookedSessionRewards — the STATE-BASED safety net for the deferred
|
|
232
|
+
// cost-plus session-token reward (ADR 0120). Today the reward books via two LIVE
|
|
233
|
+
// triggers only: upsertSessionRecordWithReward (upload-time, iff a task the
|
|
234
|
+
// session shipped is already status='shipped') and awardDeferredSessionRewardForTask
|
|
235
|
+
// (driven by the in-process `task.shipped` event listener). Both MISS a task that
|
|
236
|
+
// reaches status='shipped' OUT OF BAND — a direct-SQL / hotpatch flip, or a ship
|
|
237
|
+
// performed in a DIFFERENT process — because no `task.shipped` event fires in THIS
|
|
238
|
+
// server to release the deferred reward. Such a session's drachmae then strand
|
|
239
|
+
// PERMANENTLY even though the code genuinely landed. (This is routine on a box that
|
|
240
|
+
// hotpatches / ships with --skip-grade.) This idempotent sweep closes that gap:
|
|
241
|
+
// it finds every session whose task is now genuinely 'shipped' but that carries no
|
|
242
|
+
// session.token_reward row, and books it.
|
|
243
|
+
//
|
|
244
|
+
// Mirrors the task 1679 achievements reconcile — the accepted precedent for
|
|
245
|
+
// "the event trigger missed some ships, so reconcile from durable STATE, not the
|
|
246
|
+
// event." Design invariants:
|
|
247
|
+
// • STRICTLY pay-on-land: the JOIN requires t.status='shipped', so a task stuck
|
|
248
|
+
// at 'confirmed' is NEVER paid (ADR 0120 — never pay for code that didn't land).
|
|
249
|
+
// • Touches NO GitHub and sits behind NO push-credential guard — its whole point
|
|
250
|
+
// is to run on a CREDENTIAL-LESS box (the cloudbongos shape) and recover
|
|
251
|
+
// already-shipped work the reconciler-push-gap left unbooked.
|
|
252
|
+
// • Idempotent via the SAME watermark guard awardSessionTokenReward enforces (the
|
|
253
|
+
// SELECT fast-path + the uniq_credit_session_token_reward_basis partial index),
|
|
254
|
+
// so a re-sweep — or a race with a live trigger — never double-pays.
|
|
255
|
+
// Returns { scanned, awarded }.
|
|
256
|
+
async function reconcileUnbookedSessionRewards({ limit = 200 } = {}) {
|
|
257
|
+
// undefined → the destructuring default (200); a finite number clamps to
|
|
258
|
+
// [1, 1000] (so an explicit 0/negative floors at 1, never an unbounded scan);
|
|
259
|
+
// garbage (NaN, 'abc') falls back to 200.
|
|
260
|
+
const n = Number(limit);
|
|
261
|
+
const cap = Number.isFinite(n) ? Math.max(1, Math.min(1000, Math.trunc(n))) : 200;
|
|
262
|
+
return api.withTx(async (client) => {
|
|
263
|
+
const { rows } = await client.query(
|
|
264
|
+
`SELECT DISTINCT sr.session_id, sr.builder_id, sr.model_usage, sr.total_tokens
|
|
265
|
+
FROM session_records sr
|
|
266
|
+
JOIN tasks t ON t.id = ANY(sr.task_ids) AND t.status = 'shipped'
|
|
267
|
+
WHERE sr.session_id IS NOT NULL
|
|
268
|
+
AND NOT EXISTS (
|
|
269
|
+
SELECT 1 FROM credit_log cl
|
|
270
|
+
WHERE cl.session_id = sr.session_id
|
|
271
|
+
AND cl.reason = 'session.token_reward')
|
|
272
|
+
ORDER BY sr.session_id
|
|
273
|
+
LIMIT $1`,
|
|
274
|
+
[cap]
|
|
275
|
+
);
|
|
276
|
+
let awarded = 0;
|
|
277
|
+
for (const r of rows) {
|
|
278
|
+
const tokenReward = await awardSessionTokenRewardTx(client, {
|
|
279
|
+
builderId: r.builder_id,
|
|
280
|
+
sessionId: r.session_id,
|
|
281
|
+
modelUsage: r.model_usage || {},
|
|
282
|
+
totalTokens: r.total_tokens || 0,
|
|
283
|
+
});
|
|
284
|
+
if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
|
|
285
|
+
}
|
|
286
|
+
return { scanned: rows.length, awarded };
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
147
290
|
// ---- 6D.2: session search + fetch (read side, ADR 0027) -------------------
|
|
148
291
|
|
|
149
292
|
// Safe ORDER BY whitelist — the ONLY value interpolated into the search SQL, so
|
|
@@ -314,6 +457,8 @@ module.exports = {
|
|
|
314
457
|
// write side (6D.1)
|
|
315
458
|
upsertSessionRecord,
|
|
316
459
|
upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
|
|
460
|
+
awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
|
|
461
|
+
reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
|
|
317
462
|
// search + fetch (6D.2)
|
|
318
463
|
searchSessions,
|
|
319
464
|
summarizeSessions,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"description": "The session-records corpus + its data plane (POST /sessions upload, the Archon-gated cross-builder /sessions/search + /sessions/:id deep-dive), the BFG 'read-the-room' cross-builder scorer (ADR 0026 §6B), and the session-pulse / stale-claim heartbeat helpers. A core-domain module carved out of the GDS core in BV1.R82 (ADR 0093 §1); default-on because every instance records its builders' sessions for the BFG. Like the memory module, sessions registers no port — core never calls back into it: the ship-time digest write is an HTTP POST to the module's own /sessions route, and the only outward dependency is the economy `reward` kernel PORT it resolves (session token reward + sumCreditsForTasks) at upload time.",
|
|
5
5
|
"coreVersion": "^1.10.0",
|
|
6
6
|
"default": true,
|
|
7
|
-
"contributes": { "routes": ["sessions"] },
|
|
7
|
+
"contributes": { "routes": ["sessions"], "pollers": ["session-reward-reconcile"] },
|
|
8
8
|
"provides": [],
|
|
9
9
|
"consumes": []
|
|
10
10
|
}
|