@bongos/core 1.23.22 → 1.24.2
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 +933 -413
- package/.claude/skills/blocker-review/SKILL.md +9 -1
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +1 -1
- package/.claude/skills/builder-redteam/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/.claude/skills/collab-review/SKILL.md +5 -2
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +2 -2
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/owner-review/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +11 -11
- package/.claude/skills/read-session-export/SKILL.md +1 -1
- package/.claude/skills/recall/SKILL.md +1 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/strand-fix/SKILL.md +13 -0
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/AGENTS.md +15 -0
- package/GEMINI.md +5 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +96 -0
- package/clients/bongos-client/index.cjs +96 -0
- package/clients/bongos-client/index.d.ts +155 -4
- package/clients/bongos-client/index.mjs +96 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
- package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
- package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
- package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
- package/docs/adr/README.md +6 -2
- package/docs/api/openapi.json +8651 -5833
- package/docs/api-reference.md +66 -10
- package/docs/architecture.md +37 -1
- package/docs/canonical-permissions.md +1 -1
- package/docs/copy-inventory.md +427 -339
- package/docs/copy-registry.json +1376 -500
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +7 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
- package/docs/onboarding/diagrams/README.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +15 -15
- package/docs/onboarding/primer.md +2 -2
- package/docs/page-inventory.json +57 -7
- package/docs/page-readings.json +1210 -1027
- package/docs/recipes/google-sign-in.md +60 -0
- package/docs/recipes/ops-gotchas.md +1 -1
- package/migrations/core_282_builders_lower_login_idx.sql +33 -0
- package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
- package/migrations/core_283_payout_approvals.sql +183 -0
- package/migrations/core_284_payout_gates.sql +179 -0
- package/migrations/core_285_task_needs.sql +74 -0
- package/modules/ai-studio/billing.js +67 -0
- package/modules/ai-studio/context.js +29 -0
- package/modules/ai-studio/db.js +172 -0
- package/modules/ai-studio/gemini.js +75 -0
- package/modules/ai-studio/generate.js +119 -0
- package/modules/ai-studio/keys.js +73 -0
- package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
- package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
- package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
- package/modules/ai-studio/openrouter.js +90 -0
- package/modules/ai-studio/payments.js +105 -0
- package/modules/ai-studio/pricing.js +92 -0
- package/modules/ai-studio/routes/ai-studio.js +235 -5
- package/modules/ai-studio/studio.js +121 -0
- package/modules/autonomy/fence.js +64 -1
- package/modules/builder-settings/builder-needs.js +27 -1
- package/modules/copy-desk/routes/copy-desk.js +5 -0
- package/modules/economy/achievements.js +22 -10
- package/modules/economy/cost-classify.js +9 -0
- package/modules/economy/cost-write-guard.js +52 -0
- package/modules/economy/cost.js +2 -1
- package/modules/economy/credits.js +18 -15
- package/modules/economy/goal-reward.js +161 -0
- package/modules/economy/reward.js +4 -0
- package/modules/economy/routes/cost.js +33 -7
- package/modules/government/access-routes.js +70 -0
- package/modules/government/access.js +88 -0
- package/modules/government/asks.js +5 -2
- package/modules/government/catalog.js +6 -0
- package/modules/government/db.js +72 -0
- package/modules/government/docket.js +48 -2
- package/modules/government/migrations/government_035_payout_approve.sql +29 -0
- package/modules/government/migrations/government_036_access_holders.sql +33 -0
- package/modules/government/pollers/board-expiry.js +108 -20
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/government/routes/government.js +31 -0
- package/modules/government/routing.js +49 -7
- package/modules/government/waiting.js +78 -0
- package/modules/hall-ui/public/ai-studio-lib.js +268 -0
- package/modules/hall-ui/public/ai-studio.css +193 -0
- package/modules/hall-ui/public/ai-studio.html +98 -0
- package/modules/hall-ui/public/ai-studio.js +556 -0
- package/modules/hall-ui/public/ai-studio.states.json +94 -0
- package/modules/hall-ui/public/blockers-by-person.js +158 -0
- package/modules/hall-ui/public/blockers-lib.js +15 -1
- package/modules/hall-ui/public/blockers.html +8 -0
- package/modules/hall-ui/public/blockers.js +1 -1
- package/modules/hall-ui/public/claim-action.js +3 -0
- package/modules/hall-ui/public/collab.html +12 -0
- package/modules/hall-ui/public/craft-mode.js +4 -1
- package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals-render.js +4 -0
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/gov-living.js +6 -2
- package/modules/hall-ui/public/government-access.js +108 -0
- package/modules/hall-ui/public/government.html +21 -0
- package/modules/hall-ui/public/hall-ask.css +5 -0
- package/modules/hall-ui/public/hall-ask.js +39 -3
- package/modules/hall-ui/public/notes-panel.css +20 -0
- package/modules/hall-ui/public/notes-panel.js +146 -0
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +7 -5
- package/modules/hall-ui/public/settings-ai-keys.js +124 -0
- package/modules/hall-ui/public/settings.html +15 -0
- package/modules/hall-ui/public/street-deck.js +12 -3
- package/modules/hall-ui/public/studio.css +90 -8
- package/modules/hall-ui/public/studio.html +39 -10
- package/modules/hall-ui/public/studio.js +148 -18
- package/modules/hall-ui/public/task.html +2 -0
- package/modules/hall-ui/public/task.js +4 -0
- package/modules/hall-ui/public/tweak-editor.js +12 -3
- package/modules/hall-ui/public/welcome-boot.css +42 -6
- package/modules/hall-ui/public/welcome-boot.js +20 -6
- package/modules/hall-ui/public/work.js +20 -0
- package/modules/hall-ui/records/ai-studio.md +36 -0
- package/modules/hall-ui/records/blockers-people.md +38 -0
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +18 -5
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/done-when.js +23 -1
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/notes.js +219 -0
- package/modules/lifecycle/page-notice.js +124 -0
- package/modules/lifecycle/page-tweak-artist.js +18 -1
- package/modules/lifecycle/publish-reconciler.js +67 -20
- package/modules/lifecycle/routes/claims.js +5 -2
- package/modules/lifecycle/routes/goals.js +3 -1
- package/modules/lifecycle/routes/notes.js +91 -0
- package/modules/lifecycle/routes/project-settings.js +43 -35
- package/modules/lifecycle/routes/task-route-helpers.js +21 -0
- package/modules/lifecycle/routes/task-write-routes.js +21 -3
- package/modules/lifecycle/routes/tasks.js +12 -12
- package/modules/lifecycle/task-needs-gate.js +70 -0
- package/modules/market-watch/CLAUDE.md +15 -0
- package/modules/market-watch/db.js +340 -0
- package/modules/market-watch/key.js +58 -0
- package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
- package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
- package/modules/market-watch/module.json +26 -0
- package/modules/market-watch/pollers/hub-seed.js +54 -0
- package/modules/market-watch/pollers/weekly-run.js +100 -0
- package/modules/market-watch/research.js +350 -0
- package/modules/market-watch/run.js +245 -0
- package/modules/market-watch/seed/market-data.json +38 -0
- package/modules/market-watch/seed/partners-data.json +497 -0
- package/modules/market-watch/seed.js +252 -0
- package/modules/market-watch/store.js +230 -0
- package/modules/memory/routes/memory.js +14 -2
- package/modules/memory/routes/search.js +8 -2
- package/modules/platform-identity/account-logins.js +192 -0
- package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
- package/modules/platform-identity/platform-identity.js +5 -4
- package/modules/platform-identity/routes/my-projects.js +6 -0
- package/modules/platform-identity/routes/profile.js +18 -0
- package/modules/platform-identity/routes/public-profile.js +10 -1
- package/modules/platform-identity/routes/sso.js +26 -1
- package/modules/platform-identity/tests/platform-identity.mjs +2 -1
- package/modules/provisioning/credential-preflight.js +13 -3
- package/modules/provisioning/pollers/app-liveness.js +25 -5
- package/modules/provisioning/routes/provisioning.js +1 -1
- package/modules/provisioning/starter-bundles.js +6 -2
- package/modules/public-landing/public/account.html +10 -2
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
- package/modules/public-landing/public/community.html +1 -1
- package/modules/public-landing/public/connections.html +2 -2
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +2 -2
- package/modules/public-landing/public/projects.html +5 -5
- package/modules/raise/views/nda.html +2 -0
- package/modules/specialities/db.js +32 -1
- package/modules/specialities/routes/specialities.js +10 -6
- package/modules/status-ui/public/index.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +230 -0
- package/scripts/bongos/autobongos-tweak-lane.js +46 -1
- package/scripts/bongos/backfill-subagent-rewards.js +2 -2
- package/scripts/bongos/claim.js +22 -1
- package/scripts/bongos/db-backup-nightly.sh +29 -0
- package/scripts/bongos/deploy-divergence.js +7 -0
- package/scripts/bongos/doc-cli-guard.js +6 -0
- package/scripts/bongos/fitness.js +3 -2
- package/scripts/bongos/gen-diagrams.js +3 -3
- package/scripts/bongos/government-walk.js +3 -0
- package/scripts/bongos/lib.sh +4 -5
- package/scripts/bongos/migration-namespace.js +13 -1
- package/scripts/bongos/plain-cards.js +4 -0
- package/scripts/bongos/provision-github-setup.js +3 -1
- package/scripts/bongos/provision-units.js +6 -1
- package/scripts/bongos/publish-manifest.js +3 -0
- package/scripts/bongos/release.js +2 -0
- package/scripts/bongos/run-unit-tests.js +268 -82
- package/scripts/bongos/seed-tasks.example.json +56 -0
- package/scripts/bongos/seed-tasks.js +222 -0
- package/scripts/bongos/seed-verify-criteria.js +1 -2
- package/scripts/bongos/ship-finish.js +2 -0
- package/scripts/bongos/ship-resume.js +2 -0
- package/scripts/bongos/strand-lease.js +250 -0
- package/scripts/bongos/strand-watch.js +26 -5
- package/scripts/bongos/version-literal-guard.js +2 -2
- package/scripts/discord/post.js +4 -3
- package/scripts/gds/seed-tasks.js +7 -0
- package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/auth-config.js +29 -1
- package/src/bongos/auth-discord.js +3 -3
- package/src/bongos/auth-github.js +3 -6
- package/src/bongos/auth-google.js +217 -0
- package/src/bongos/device-approval.js +140 -0
- package/src/bongos/llm-pricing.js +17 -1
- package/src/bongos/metrics.js +48 -2
- package/src/bongos/middleware/rate-limit.js +30 -11
- package/src/bongos/module-scope-map.js +12 -1
- package/src/bongos/optimizer.js +15 -1
- package/src/bongos/payout-approvals.js +342 -0
- package/src/bongos/payout-gates.js +364 -0
- package/src/bongos/prelaunch-gate.js +7 -9
- package/src/bongos/prelaunch-switch.js +20 -0
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/auth-google.js +246 -0
- package/src/bongos/routes/auth.js +38 -1
- package/src/bongos/routes/backup.js +33 -9
- package/src/bongos/routes/builders.js +57 -30
- package/src/bongos/routes/github.js +9 -0
- package/src/bongos/routes/me.js +18 -0
- package/src/bongos/routes/my-payouts.js +133 -0
- package/src/bongos/routes/payout-approvals.js +312 -0
- package/src/bongos/routes/payout-gates.js +179 -0
- package/src/bongos/routes.js +15 -1
- package/src/bongos/secret-box.js +18 -3
- package/src/bongos/serve-internal.js +47 -19
- package/src/bongos/sign-in-pages.js +138 -0
- package/src/bongos/task-needs.js +146 -0
- package/src/bongos-downloads.js +18 -1
- package/src/instance-config.js +28 -0
- package/src/module-api.js +14 -1
- package/tests/access_holders.mjs +214 -0
- package/tests/account_logins.mjs +326 -0
- package/tests/account_logins_db.mjs +161 -0
- package/tests/ai_studio_billing.mjs +253 -0
- package/tests/ai_studio_generate.mjs +282 -0
- package/tests/ai_studio_keys.mjs +159 -0
- package/tests/ai_studio_requests.mjs +319 -0
- package/tests/auth_backchannel_logout_route.mjs +201 -0
- package/tests/auto_satisfy_criteria.mjs +10 -1
- package/tests/autobongos_tweak_lane.mjs +144 -0
- package/tests/autobongos_tweak_pin_identity.mjs +137 -0
- package/tests/backfill_subagent_rewards.mjs +17 -1
- package/tests/backup_trigger_lock.mjs +119 -0
- package/tests/blockers_people.mjs +198 -0
- package/tests/bounded_rate_maps.mjs +184 -0
- package/tests/claimable_blocker_status.mjs +1 -1
- package/tests/claims_batch_routes.mjs +421 -0
- package/tests/config_read_cache.mjs +171 -0
- package/tests/copy_desk_page_editor.mjs +12 -0
- package/tests/cost_write_guard.mjs +200 -0
- package/tests/craft_achievements.mjs +33 -10
- package/tests/criterion_goal_attach.mjs +1 -1
- package/tests/db_backup_nightly.mjs +73 -1
- package/tests/deploy_divergence_line.mjs +7 -0
- package/tests/effective_visibility_predicate.mjs +12 -0
- package/tests/fitness.mjs +19 -0
- package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
- package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
- package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
- package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
- package/tests/github_one_trip.mjs +34 -0
- package/tests/github_repo_visibility.mjs +20 -0
- package/tests/goal_achievement.mjs +30 -1
- package/tests/goal_achievement_reward.mjs +205 -0
- package/tests/goal_sort_staleness.mjs +2 -1
- package/tests/goal_tier.mjs +11 -1
- package/tests/google_fonts_nonblocking.mjs +84 -0
- package/tests/google_sign_in.mjs +401 -0
- package/tests/government_board_expiry.mjs +94 -0
- package/tests/government_board_membership.mjs +4 -1
- package/tests/government_protected_surfaces.mjs +1 -1
- package/tests/government_routes.mjs +1 -1
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_studio_home.mjs +61 -2
- package/tests/home_attention_cards.mjs +1 -1
- package/tests/human_signal.mjs +141 -0
- package/tests/install_script_reader.mjs +51 -0
- package/tests/integration_fixture_ids.mjs +82 -0
- package/tests/invite_expiry_db.mjs +13 -1
- package/tests/lib_sh_resolution.mjs +0 -4
- package/tests/market_watch_data.mjs +244 -0
- package/tests/market_watch_data_db.mjs +308 -0
- package/tests/market_watch_module.mjs +114 -0
- package/tests/market_watch_run.mjs +503 -0
- package/tests/metrics.mjs +124 -0
- package/tests/migration_namespace.mjs +14 -0
- package/tests/module_api.mjs +2 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/notes.mjs +182 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/page_tweak_marker_sql.mjs +76 -0
- package/tests/page_tweak_no_block.mjs +135 -0
- package/tests/page_tweak_notice.mjs +103 -0
- package/tests/payout_approvals.mjs +253 -0
- package/tests/payout_approvals_db.mjs +106 -0
- package/tests/payout_gates.mjs +322 -0
- package/tests/payout_gates_db.mjs +113 -0
- package/tests/planning_session_skill.mjs +13 -3
- package/tests/project_settings_page.mjs +29 -5
- package/tests/project_settings_spend_cap_route.mjs +235 -0
- package/tests/promotion_sweep.mjs +29 -1
- package/tests/provision.mjs +3 -2
- package/tests/provisioning_app_status.mjs +18 -0
- package/tests/provisioning_credential_preflight.mjs +53 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
- package/tests/publish_reconciler_overlap.mjs +60 -0
- package/tests/rank_change_broadcast_port.mjs +151 -0
- package/tests/seed_tasks.mjs +176 -0
- package/tests/seed_verify_criteria.mjs +2 -6
- package/tests/site_classified_once.mjs +142 -0
- package/tests/speciality_db_shape.mjs +23 -0
- package/tests/speciality_session_skills.mjs +48 -2
- package/tests/strand_lease.mjs +249 -0
- package/tests/strand_watch.mjs +10 -0
- package/tests/task_criteria_routes.mjs +275 -0
- package/tests/task_criteria_unlink_db.mjs +123 -0
- package/tests/task_needs.mjs +233 -0
- package/tests/task_needs_db.mjs +110 -0
- package/tests/unit_runner_serial.mjs +266 -43
- package/tests/version_literals.mjs +5 -7
- package/tests/welcome_boot.mjs +16 -5
- package/tests/welcome_boot_browser.mjs +36 -4
- package/docs/recipes/bongos-cli-release.md +0 -66
- package/scripts/bongos/backfill-disciplines.js +0 -144
- package/scripts/bongos/backfill-session-earnings.js +0 -123
- package/scripts/bongos/backfill-session-types.js +0 -87
- package/scripts/bongos/build-bongos.js +0 -94
- package/scripts/bongos/cleanup-orphans.js +0 -74
- package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
- package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
- package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
- package/scripts/bongos/smoke-batch-claim.sh +0 -238
- package/scripts/bongos/smoke-batch-release.sh +0 -316
- package/scripts/bongos/smoke-claim-consistency.sh +0 -222
- package/scripts/bongos/smoke-dependencies.sh +0 -143
- package/scripts/gds/backfill-disciplines.js +0 -7
- package/scripts/gds/backfill-session-earnings.js +0 -7
- package/scripts/gds/backfill-session-types.js +0 -7
- package/scripts/gds/cleanup-orphans.js +0 -7
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
- package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
- package/scripts/gds/smoke-batch-claim.sh +0 -8
- package/scripts/gds/smoke-batch-release.sh +0 -8
- package/scripts/gds/smoke-claim-consistency.sh +0 -8
- package/scripts/gds/smoke-dependencies.sh +0 -8
- package/tests/smoke_dependencies_witness.mjs +0 -46
|
@@ -26,7 +26,7 @@ const { buildDependencyAdvisory } = require('../dependency-advisory');
|
|
|
26
26
|
const { classifyTaskKind, VALID_KINDS, WRITABLE_DISCIPLINES, resolveWriteDiscipline, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
27
27
|
const { isModuleKey, moduleKeys } = api.moduleScopeMap;
|
|
28
28
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
29
|
-
const { badKind, detectsMigration } = require('./task-route-helpers.js');
|
|
29
|
+
const { badKind, badNeeds, detectsMigration } = require('./task-route-helpers.js');
|
|
30
30
|
const { describeCheckViolation } = require('../pg-constraint-fail.js');
|
|
31
31
|
const { goalNotOpenRefusal } = require('./goal-route-authz.js');
|
|
32
32
|
const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
|
|
@@ -62,6 +62,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
62
62
|
newcomer_friendly: { type: 'boolean' }, // V3.R15 #239 — Xenos-claimable flag
|
|
63
63
|
security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
|
|
64
64
|
requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — per-task rank floor (auto-derived; explicit value can only raise it)
|
|
65
|
+
needs: {}, // task 1004950 / ADR 0395 — ['owner'|'server'] or a comma list; checked inline
|
|
65
66
|
auto_detect_touches: { type: 'boolean' },
|
|
66
67
|
// task 438: link the new task to done-when criteria at creation time. Each
|
|
67
68
|
// element is a criterion_id slug (required on a multi-goal version — task
|
|
@@ -169,6 +170,9 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
169
170
|
return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. The floor auto-derives from touches + security_sensitive; an explicit value can only raise it.`, details: { valid: db.LIVE_RANK_LADDER } });
|
|
170
171
|
}
|
|
171
172
|
}
|
|
173
|
+
// task 1004950 / ADR 0395: what the task needs beyond the build loop.
|
|
174
|
+
const createNeeds = api.taskNeeds.normalizeNeeds(body.needs);
|
|
175
|
+
if (!createNeeds.ok) return badNeeds(res, createNeeds.bad);
|
|
172
176
|
// BV1.R53 (task 1422): validate module_key against MODULE_SCOPE_MAP.
|
|
173
177
|
if (body.module_key != null && !isModuleKey(body.module_key)) {
|
|
174
178
|
return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or omitted. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
|
|
@@ -245,6 +249,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
245
249
|
requiresRank: body.requires_rank ?? null,
|
|
246
250
|
moduleKey: body.module_key ?? null,
|
|
247
251
|
goalId: body.goal_id ?? null,
|
|
252
|
+
needs: createNeeds.needs,
|
|
248
253
|
// task 1003278 — attribution, from the AUTHENTICATED request, never the
|
|
249
254
|
// body: who created a task is a fact about the caller, not an input.
|
|
250
255
|
createdBy: req.builder.id,
|
|
@@ -348,6 +353,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
348
353
|
xenos_claimable: { type: 'boolean' }, // pre-rename alias for newcomer_friendly
|
|
349
354
|
security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
|
|
350
355
|
automation_tag: { type: 'string', maxLength: 64 },
|
|
356
|
+
needs: {}, // task 1004950 — ['owner'|'server'], a comma list, or null/[] to clear; checked inline
|
|
351
357
|
needs_migration: { type: 'boolean' }, // task 1002648 — correct the create-time heuristic's flag (the claim-time allocator keys off it)
|
|
352
358
|
// task 1002647 (map s4.3): the task body is a load-bearing builder prompt
|
|
353
359
|
// and was settable only at create — no route edited it at any rank. Now
|
|
@@ -397,6 +403,7 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
397
403
|
const hasParallelSafe = Object.prototype.hasOwnProperty.call(body, 'parallel_safe');
|
|
398
404
|
const hasSecuritySensitive = Object.prototype.hasOwnProperty.call(body, 'security_sensitive');
|
|
399
405
|
const hasAutomationTag = Object.prototype.hasOwnProperty.call(body, 'automation_tag');
|
|
406
|
+
const hasNeeds = Object.prototype.hasOwnProperty.call(body, 'needs');
|
|
400
407
|
const hasNeedsMigration = Object.prototype.hasOwnProperty.call(body, 'needs_migration');
|
|
401
408
|
const hasRequiresRank = Object.prototype.hasOwnProperty.call(body, 'requires_rank');
|
|
402
409
|
const hasModuleKey = Object.prototype.hasOwnProperty.call(body, 'module_key');
|
|
@@ -416,8 +423,8 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
416
423
|
if (Object.prototype.hasOwnProperty.call(body, 'status')) {
|
|
417
424
|
return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
|
|
418
425
|
}
|
|
419
|
-
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary) {
|
|
420
|
-
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
426
|
+
if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeeds && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary) {
|
|
427
|
+
return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, parallel_safe, security_sensitive, automation_tag, needs, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
|
|
421
428
|
}
|
|
422
429
|
// ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
|
|
423
430
|
// ---- FIRST write. The previous shape validated-and-wrote per field in ----
|
|
@@ -476,6 +483,8 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
476
483
|
return res.fail('bad_automation_tag', { status: 400, message: 'automation_tag must be one of overnight-safe | live-only | needs-input, or null to clear', details: { valid: Array.from(VALID_AUTOMATION_TAGS) } });
|
|
477
484
|
}
|
|
478
485
|
}
|
|
486
|
+
const patchNeeds = hasNeeds ? api.taskNeeds.normalizeNeeds(body.needs) : null;
|
|
487
|
+
if (patchNeeds && !patchNeeds.ok) return badNeeds(res, patchNeeds.bad);
|
|
479
488
|
if (hasModuleKey) {
|
|
480
489
|
// BV1.R53 (task 1422): assign or clear the module affinity label. null clears.
|
|
481
490
|
if (body.module_key !== null && !isModuleKey(body.module_key)) {
|
|
@@ -577,6 +586,15 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
577
586
|
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
578
587
|
}
|
|
579
588
|
}
|
|
589
|
+
if (hasNeeds) {
|
|
590
|
+
try {
|
|
591
|
+
updated = await db.updateTaskNeeds(id, patchNeeds.needs);
|
|
592
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
log.error('[gds] PATCH /tasks/:id needs', err);
|
|
595
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
580
598
|
if (hasNeedsMigration) {
|
|
581
599
|
try {
|
|
582
600
|
updated = await db.updateTaskNeedsMigration(id, body.needs_migration);
|
|
@@ -91,7 +91,7 @@ const PUBLISH_BUNDLE_MAX_BYTES = 20 * 1024 * 1024; // 20 MB decoded bundle
|
|
|
91
91
|
// autolinks to GitHub's own issue space (dead links for GDS task ids — task 916).
|
|
92
92
|
// Mirrors ship.js sanitizeGitHubRefs (which lives in scripts/ and isn't requirable
|
|
93
93
|
// here).
|
|
94
|
-
const { MIGRATION_KEYWORD_RE, VALID_FITS, badKind, deHashRefs, detectsMigration, gateTaskOwnership, graderBypassEnabled, isVerifyTask, peerVoteNet, postGateAuthorTrust, rankAttestsGateTrust } = require('./task-route-helpers.js');
|
|
94
|
+
const { MIGRATION_KEYWORD_RE, VALID_FITS, badKind, badNeedsFilter, parseNeedsFilter, deHashRefs, detectsMigration, gateTaskOwnership, graderBypassEnabled, isVerifyTask, peerVoteNet, postGateAuthorTrust, rankAttestsGateTrust } = require('./task-route-helpers.js');
|
|
95
95
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
96
96
|
const log = api.logger('lifecycle');
|
|
97
97
|
const registerTaskWriteRoutes = require('./task-write-routes.js'); // task 1003206 — POST /tasks + PATCH /tasks/:id
|
|
@@ -176,10 +176,11 @@ module.exports = function buildTasksRouter() {
|
|
|
176
176
|
if (limit && limit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer up to 1000.' });
|
|
177
177
|
const offset = posInt(offsetRaw, 'bad_offset');
|
|
178
178
|
if (offset && offset.bad) return res.fail('bad_offset', { status: 400, message: 'offset must be a positive integer.' });
|
|
179
|
+
const needs = parseNeedsFilter(req.query.needs); if (needs && needs.bad) return badNeedsFilter(res); // task 1004950
|
|
179
180
|
|
|
180
181
|
const tasks = await db.listTasks({
|
|
181
182
|
versionId: version, status, kind, discipline, goalId,
|
|
182
|
-
createdBy, staleDays, limit, offset,
|
|
183
|
+
createdBy, staleDays, limit, offset, needs,
|
|
183
184
|
});
|
|
184
185
|
// ?include=deps (hall redesign task 1002439) — enrich each task with its
|
|
185
186
|
// dependency summary (`blocked_by[]` with per-target satisfied state, and a
|
|
@@ -216,16 +217,15 @@ module.exports = function buildTasksRouter() {
|
|
|
216
217
|
if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
217
218
|
// ?limit=N — opt-in, for a caller that renders N rows (goal 1000072). It slices the
|
|
218
219
|
// RETURNED feed, never the query — lifecycle/CLAUDE.md has the why.
|
|
220
|
+
const claimNeeds = parseNeedsFilter(req.query.needs); if (claimNeeds && claimNeeds.bad) return badNeedsFilter(res); // task 1004950
|
|
219
221
|
const claimLimit = posInt(req.query.limit, 'bad_limit', 1000);
|
|
220
222
|
if (claimLimit && claimLimit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer (max 1000).' });
|
|
221
223
|
const [feed, active] = await Promise.all([
|
|
222
224
|
db.listClaimableTasks({
|
|
223
|
-
versionId: version || null,
|
|
224
|
-
|
|
225
|
-
discipline: discipline || null,
|
|
226
|
-
fit: fit || null,
|
|
225
|
+
versionId: version || null, builderId: req.builder.id,
|
|
226
|
+
discipline: discipline || null, fit: fit || null,
|
|
227
227
|
createdBy: claimCreatedBy ?? null,
|
|
228
|
-
staleDays: claimStaleDays ?? null,
|
|
228
|
+
staleDays: claimStaleDays ?? null, needs: claimNeeds ?? null,
|
|
229
229
|
}),
|
|
230
230
|
req.query.group === 'goal' ? [] : db.listActiveClaims(),
|
|
231
231
|
]);
|
|
@@ -507,11 +507,11 @@ module.exports = function buildTasksRouter() {
|
|
|
507
507
|
// the numeric done_when_criteria.id, mirroring DELETE deps/:depId).
|
|
508
508
|
// rank: metic+archon — removes a criterion↔task edge; task-shaping, Metic+ (ADR 0090).
|
|
509
509
|
router.delete('/tasks/:id/criteria/:criterionId', auth.requireBuilder, auth.requirePermission('task.criterion.manage'), async (req, res) => {
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
|
|
510
|
+
// task 1004984: whole positive ids only (Number.isFinite let 7.5 through).
|
|
511
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
512
|
+
if (id === null) return;
|
|
513
|
+
const criterionId = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id', positiveInt: true });
|
|
514
|
+
if (criterionId === null) return;
|
|
515
515
|
const removed = await db.removeTaskCriterion(id, criterionId);
|
|
516
516
|
res.json({ ok: true, removed });
|
|
517
517
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// taskNeedsFailures — the claim-time half of "a task says what it needs" (task
|
|
2
|
+
// 1004950, ADR 0395). The goalMembershipFailures / relevanceFrictionFailures
|
|
3
|
+
// shape: one DB-read check called from all THREE claim surfaces in db-claims.js
|
|
4
|
+
// (claimTask, claimTasksBatch, validateClaimBatch), so no surface can advertise
|
|
5
|
+
// or take a claim another refuses.
|
|
6
|
+
//
|
|
7
|
+
// Only `server` refuses. `owner` work is claimable — by the owner, the one person
|
|
8
|
+
// the feed shows it to; refusing it here would also refuse the owner's own agent
|
|
9
|
+
// session, which is how the owner does that work. The feed is where `owner`
|
|
10
|
+
// narrows (db-tasks.listClaimableTasks).
|
|
11
|
+
//
|
|
12
|
+
// `client` is the caller's open txn client (claimTask) or the pool itself (the
|
|
13
|
+
// read-only dry run). Capabilities are resolved fresh, once per call, through the
|
|
14
|
+
// doorway — never from the request body (ADR 0016). Returns [{ task_id, refusal }].
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const api = require('../../src/module-api');
|
|
19
|
+
|
|
20
|
+
async function taskNeedsFailures({ client, tasks, builderId, deps = {} }) {
|
|
21
|
+
const ids = (tasks || []).map((t) => Number(t.id)).filter((n) => Number.isFinite(n));
|
|
22
|
+
if (ids.length === 0) return [];
|
|
23
|
+
const { rows } = await client.query(
|
|
24
|
+
`SELECT id, needs FROM tasks WHERE id = ANY($1::bigint[]) AND cardinality(needs) > 0`,
|
|
25
|
+
[ids]
|
|
26
|
+
);
|
|
27
|
+
if (rows.length === 0) return [];
|
|
28
|
+
const needsApi = api.taskNeeds;
|
|
29
|
+
const capabilities = await (deps.resolveCapabilities || needsApi.resolveCapabilities)(builderId);
|
|
30
|
+
const failures = [];
|
|
31
|
+
for (const r of rows) {
|
|
32
|
+
const refused = needsApi.claimRefusalNeeds(r.needs, capabilities);
|
|
33
|
+
if (refused.length === 0) continue;
|
|
34
|
+
failures.push({
|
|
35
|
+
task_id: Number(r.id),
|
|
36
|
+
refusal: {
|
|
37
|
+
code: 'NEEDS_SERVER_ACCESS',
|
|
38
|
+
message: needsApi.SERVER_REFUSAL_MESSAGE,
|
|
39
|
+
needs: refused,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return failures;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- The two list filters (task 1004950), here so db-tasks.js stays under its cap.
|
|
47
|
+
|
|
48
|
+
// needsFilterWhere — the board's ?needs= filter: one need, or 'none' for work that
|
|
49
|
+
// needs nothing beyond the build loop. A filter on what a list shows, never a
|
|
50
|
+
// narrowing of what anyone may read. Pushes its param; returns the clause or null.
|
|
51
|
+
function needsFilterWhere(needs, alias, params) {
|
|
52
|
+
const col = alias ? `${alias}.needs` : 'needs';
|
|
53
|
+
if (needs === 'none') return `cardinality(${col}) = 0`;
|
|
54
|
+
if (!needs) return null;
|
|
55
|
+
params.push(needs); // @> so tasks_needs_idx (core_285) serves it
|
|
56
|
+
return `${col} @> ARRAY[$${params.length}]::text[]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// capabilityFeedWhere — the RECOMMEND rule (ADR 0395): a task's needs must be a
|
|
60
|
+
// subset of the viewer's capabilities, resolved fresh. The autonomous/parallel
|
|
61
|
+
// runner is an agent working alone, so it never holds `owner` whoever it runs as —
|
|
62
|
+
// an agent cannot make the owner's decision. Pushes its param; returns the clause.
|
|
63
|
+
async function capabilityFeedWhere(builderId, fit, alias, params, deps = {}) {
|
|
64
|
+
const capabilities = await (deps.resolveCapabilities || api.taskNeeds.resolveCapabilities)(builderId);
|
|
65
|
+
const unattended = fit === 'autonomous' || fit === 'parallel';
|
|
66
|
+
params.push(unattended ? capabilities.filter((c) => c !== 'owner') : capabilities);
|
|
67
|
+
return api.taskNeeds.feedNeedsClause(alias, `$${params.length}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { taskNeedsFailures, needsFilterWhere, capabilityFeedWhere };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @bongos/core — installed platform dependency (not your project's CLAUDE.md)
|
|
2
|
+
|
|
3
|
+
This file is a placeholder shipped inside the **@bongos/core** package. The core's own
|
|
4
|
+
internal agent notes are intentionally omitted from the published artifact so they cannot
|
|
5
|
+
load into your instance's context.
|
|
6
|
+
|
|
7
|
+
**It does not govern this repository.** Your own root `CLAUDE.md`, your `.claude/`
|
|
8
|
+
directory, and the live Bongos database are the source of truth for how work happens here
|
|
9
|
+
and who may do what.
|
|
10
|
+
|
|
11
|
+
You are seeing this only because an agent opened a file under `node_modules/@bongos/core/`.
|
|
12
|
+
Nothing in here needs to be read to use the platform — run `bongos help` for commands.
|
|
13
|
+
|
|
14
|
+
_To work ON the platform core itself, use the standalone @bongos/core source repository,
|
|
15
|
+
not this vendored copy._
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/market-watch/db.js — the read queries behind the Market Watch screens (task
|
|
4
|
+
// 1004936, goal 1000137). The pages (tasks 1004938–1004941) and the weekly run (task
|
|
5
|
+
// 1004937) read through these. The weekly run's own bookkeeping (its run row, its lease,
|
|
6
|
+
// the item results it writes) is in store.js; nothing else spells a market_watch_* query.
|
|
7
|
+
//
|
|
8
|
+
// Every read takes a pool and the project key (seed.js projectKey: the instance's public
|
|
9
|
+
// origin) and filters on it, so one project can never read another's rows, even on a
|
|
10
|
+
// shared database.
|
|
11
|
+
//
|
|
12
|
+
// DERIVED, NOT STORED. The screens show an item's level and streak, a sparkline per
|
|
13
|
+
// dimension, this month's spend and when the next run is due. None of those is a column:
|
|
14
|
+
// each is computed here from the runs, entries and snapshots, by a pure function below
|
|
15
|
+
// that is tested without a database. A stored copy would be a second number to keep true.
|
|
16
|
+
//
|
|
17
|
+
// - a run's number ("run 8") counts the runs that RAN (done or partial), oldest first;
|
|
18
|
+
// a skipped or failed run is a dash on the settings strip and takes no number.
|
|
19
|
+
// - an item's level is the number of numbered runs that found something on it (at
|
|
20
|
+
// least 1 once it is tracked); its streak is how many of the latest numbered runs in a
|
|
21
|
+
// row did.
|
|
22
|
+
// - a dimension's sparkline has one point per numbered run the item was scored in;
|
|
23
|
+
// a dimension that run did not score is null, never a carried-forward value.
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const RAN = new Set(['done', 'partial']);
|
|
27
|
+
const CADENCE_DAYS = Object.freeze({ weekly: 7, biweekly: 14, monthly: 30 });
|
|
28
|
+
// The settings an unconfigured project reads: the watch is off until an owner turns it on.
|
|
29
|
+
const DEFAULT_SETTINGS = Object.freeze({ enabled: false, monthly_cap_usd: 20, cadence: 'weekly', configured: false });
|
|
30
|
+
|
|
31
|
+
function need(project) {
|
|
32
|
+
if (typeof project !== 'string' || !project) throw new Error('market-watch db: a project key is required');
|
|
33
|
+
return project;
|
|
34
|
+
}
|
|
35
|
+
const num = (v) => (v == null ? null : Number(v));
|
|
36
|
+
|
|
37
|
+
// ---- pure helpers ---------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
// numberRuns(runs) — runs in any order → the same rows, oldest first, each with `n` (its
|
|
40
|
+
// run number) when it ran and null when it did not.
|
|
41
|
+
function numberRuns(runs) {
|
|
42
|
+
const sorted = [...runs].sort((a, b) => (new Date(a.created_at) - new Date(b.created_at)) || (Number(a.id) - Number(b.id)));
|
|
43
|
+
let n = 0;
|
|
44
|
+
return sorted.map((r) => ({ ...r, n: RAN.has(r.status) ? ++n : null }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// progress(numberedRuns, runIdsWithFindings) → { level, streak } for one item.
|
|
48
|
+
function progress(numbered, foundIn) {
|
|
49
|
+
const found = new Set([...foundIn].map(String));
|
|
50
|
+
const ran = numbered.filter((r) => r.n != null);
|
|
51
|
+
const level = Math.max(1, ran.filter((r) => found.has(String(r.id))).length);
|
|
52
|
+
let streak = 0;
|
|
53
|
+
for (let i = ran.length - 1; i >= 0 && found.has(String(ran[i].id)); i -= 1) streak += 1;
|
|
54
|
+
return { level, streak };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// sparklines(rows, numberedRuns) → { runs: [{ id, n, date }], series: { <dim>: [v|null] } }
|
|
58
|
+
// rows: { run_id, dimension, value }. Points are the numbered runs that scored the item.
|
|
59
|
+
function sparklines(rows, numbered) {
|
|
60
|
+
const scoredIn = new Set(rows.map((r) => String(r.run_id)));
|
|
61
|
+
const runs = numbered.filter((r) => r.n != null && scoredIn.has(String(r.id)))
|
|
62
|
+
.map((r) => ({ id: Number(r.id), n: r.n, date: r.created_at }));
|
|
63
|
+
const at = new Map(runs.map((r, i) => [String(r.id), i]));
|
|
64
|
+
const series = {};
|
|
65
|
+
for (const r of rows) {
|
|
66
|
+
const i = at.get(String(r.run_id));
|
|
67
|
+
if (i === undefined) continue;
|
|
68
|
+
if (!series[r.dimension]) series[r.dimension] = runs.map(() => null);
|
|
69
|
+
series[r.dimension][i] = num(r.value);
|
|
70
|
+
}
|
|
71
|
+
return { runs, series };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// weekStreak(asks, answeredAskIds) — asks newest first, each { id, open }. How many asks
|
|
75
|
+
// in a row the member answered, counting back from the newest. An open ask they have not
|
|
76
|
+
// answered yet does not break the streak: they still can.
|
|
77
|
+
function weekStreak(asks, answered) {
|
|
78
|
+
const a = new Set([...answered].map(String));
|
|
79
|
+
let streak = 0;
|
|
80
|
+
for (let i = 0; i < asks.length; i += 1) {
|
|
81
|
+
if (a.has(String(asks[i].id))) { streak += 1; continue; }
|
|
82
|
+
if (i === 0 && asks[i].open) continue;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
return streak;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// monthBounds(now) → [start, end) of now's calendar month in UTC: the cap's window.
|
|
89
|
+
function monthBounds(now = new Date()) {
|
|
90
|
+
const d = new Date(now);
|
|
91
|
+
return [new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)), new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// nextRunDue(lastRunAt, cadence) → Date | null. null: no run yet, so one is due now.
|
|
95
|
+
function nextRunDue(lastRunAt, cadence) {
|
|
96
|
+
const days = CADENCE_DAYS[cadence];
|
|
97
|
+
if (!days) throw new Error(`market-watch: unknown cadence ${cadence}`);
|
|
98
|
+
if (!lastRunAt) return null;
|
|
99
|
+
return new Date(new Date(lastRunAt).getTime() + days * DAY_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- reads ----------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const RUNS_SQL = `SELECT id, status, reason, trigger, created_at, started_at, finished_at
|
|
105
|
+
FROM market_watch_runs WHERE project = $1`;
|
|
106
|
+
|
|
107
|
+
async function numberedRuns(pool, { project }) {
|
|
108
|
+
const { rows } = await pool.query(RUNS_SQL, [need(project)]);
|
|
109
|
+
return numberRuns(rows);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ITEMS_SQL = `
|
|
113
|
+
SELECT i.id, i.kind, i.name, i.url, i.category, i.description, i.why_tracked, i.facts,
|
|
114
|
+
i.status, i.added_via, i.created_at,
|
|
115
|
+
(SELECT s.value FROM market_watch_score_snapshots s
|
|
116
|
+
JOIN market_watch_runs r ON r.id = s.run_id
|
|
117
|
+
WHERE s.item_id = i.id AND s.dimension = 'overall'
|
|
118
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT 1) AS overall,
|
|
119
|
+
(SELECT max(e.found_on) FROM market_watch_log_entries e WHERE e.item_id = i.id) AS last_found_on
|
|
120
|
+
FROM market_watch_items i
|
|
121
|
+
WHERE i.project = $1
|
|
122
|
+
AND ($2::text IS NULL OR i.kind = $2)
|
|
123
|
+
AND ($3::boolean OR i.status <> 'removed')
|
|
124
|
+
AND ($4::bigint IS NULL OR i.id = $4)
|
|
125
|
+
ORDER BY i.kind, overall DESC NULLS LAST, i.name`;
|
|
126
|
+
|
|
127
|
+
const FOUND_IN_SQL = `
|
|
128
|
+
SELECT DISTINCT e.item_id, e.run_id
|
|
129
|
+
FROM market_watch_log_entries e
|
|
130
|
+
JOIN market_watch_items i ON i.id = e.item_id
|
|
131
|
+
WHERE i.project = $1 AND e.run_id IS NOT NULL AND ($2::bigint IS NULL OR e.item_id = $2)`;
|
|
132
|
+
|
|
133
|
+
// listItems — the home desk: every tracked item with its latest overall score, last
|
|
134
|
+
// finding, level and streak.
|
|
135
|
+
async function listItems(pool, { project, kind = null, includeRemoved = false, id = null } = {}) {
|
|
136
|
+
need(project);
|
|
137
|
+
const [{ rows: items }, { rows: found }, runs] = await Promise.all([
|
|
138
|
+
pool.query(ITEMS_SQL, [project, kind, includeRemoved, id]),
|
|
139
|
+
pool.query(FOUND_IN_SQL, [project, id]),
|
|
140
|
+
numberedRuns(pool, { project }),
|
|
141
|
+
]);
|
|
142
|
+
const byItem = new Map();
|
|
143
|
+
for (const f of found) {
|
|
144
|
+
const k = String(f.item_id);
|
|
145
|
+
if (!byItem.has(k)) byItem.set(k, []);
|
|
146
|
+
byItem.get(k).push(f.run_id);
|
|
147
|
+
}
|
|
148
|
+
return items.map((it) => ({
|
|
149
|
+
...it,
|
|
150
|
+
id: Number(it.id),
|
|
151
|
+
overall: num(it.overall),
|
|
152
|
+
...progress(runs, byItem.get(String(it.id)) || []),
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// getItem — one item, with removed ones included (its page still opens).
|
|
157
|
+
async function getItem(pool, { project, id }) {
|
|
158
|
+
const rows = await listItems(pool, { project, includeRemoved: true, id });
|
|
159
|
+
return rows[0] || null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const CASE_FILE_SQL = `
|
|
163
|
+
SELECT e.id, e.run_id, e.type, e.found_on, e.takeaway, e.dimension, e.source_url,
|
|
164
|
+
e.source_label, e.confidence, e.pinned, e.created_at
|
|
165
|
+
FROM market_watch_log_entries e
|
|
166
|
+
JOIN market_watch_items i ON i.id = e.item_id
|
|
167
|
+
WHERE i.project = $1 AND e.item_id = $2
|
|
168
|
+
ORDER BY e.pinned DESC, e.found_on DESC, e.id DESC`;
|
|
169
|
+
|
|
170
|
+
// caseFile — the item page's sortable table: found, run, type, takeaway, confidence, source.
|
|
171
|
+
async function caseFile(pool, { project, itemId }) {
|
|
172
|
+
need(project);
|
|
173
|
+
const [{ rows }, runs] = await Promise.all([
|
|
174
|
+
pool.query(CASE_FILE_SQL, [project, itemId]),
|
|
175
|
+
numberedRuns(pool, { project }),
|
|
176
|
+
]);
|
|
177
|
+
const runN = new Map(runs.map((r) => [String(r.id), r.n]));
|
|
178
|
+
return rows.map((e) => ({ ...e, id: Number(e.id), run: e.run_id == null ? null : runN.get(String(e.run_id)) ?? null }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const SCORES_SQL = `
|
|
182
|
+
SELECT s.run_id, s.dimension, s.value
|
|
183
|
+
FROM market_watch_score_snapshots s
|
|
184
|
+
JOIN market_watch_items i ON i.id = s.item_id
|
|
185
|
+
WHERE i.project = $1 AND s.item_id = $2`;
|
|
186
|
+
|
|
187
|
+
// scoreHistory — the item page's sparklines, one per dimension plus 'overall'.
|
|
188
|
+
async function scoreHistory(pool, { project, itemId }) {
|
|
189
|
+
need(project);
|
|
190
|
+
const [{ rows }, runs] = await Promise.all([
|
|
191
|
+
pool.query(SCORES_SQL, [project, itemId]),
|
|
192
|
+
numberedRuns(pool, { project }),
|
|
193
|
+
]);
|
|
194
|
+
return sparklines(rows, runs);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const RULES_SQL = `
|
|
198
|
+
SELECT id, version, status, search_queries, sources, rubric, instructions, summary, reason,
|
|
199
|
+
author_id, approved_by, approved_at, created_at
|
|
200
|
+
FROM market_watch_rule_versions WHERE project = $1 ORDER BY version`;
|
|
201
|
+
|
|
202
|
+
// rules — the rules page: every version for the slider, the one in use, and the proposed next.
|
|
203
|
+
async function rules(pool, { project }) {
|
|
204
|
+
const { rows } = await pool.query(RULES_SQL, [need(project)]);
|
|
205
|
+
const versions = rows.map((r) => ({ ...r, id: Number(r.id) }));
|
|
206
|
+
return {
|
|
207
|
+
versions,
|
|
208
|
+
active: versions.find((v) => v.status === 'active') || null,
|
|
209
|
+
proposed: versions.find((v) => v.status === 'proposed') || null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const PROPOSALS_SQL = `
|
|
214
|
+
SELECT id, kind, origin, item_id, log_entry_id, rule_version_id, answer_id, payload, reason,
|
|
215
|
+
proposed_by, status, decided_by, decided_at, created_at
|
|
216
|
+
FROM market_watch_proposals
|
|
217
|
+
WHERE project = $1 AND ($2::text IS NULL OR status = $2) AND ($3::bigint IS NULL OR item_id = $3)
|
|
218
|
+
ORDER BY created_at DESC, id DESC
|
|
219
|
+
LIMIT $4`;
|
|
220
|
+
|
|
221
|
+
// proposals — the government's queue (open by default), or one item's.
|
|
222
|
+
async function proposals(pool, { project, status = 'open', itemId = null, limit = 100 } = {}) {
|
|
223
|
+
const { rows } = await pool.query(PROPOSALS_SQL, [need(project), status, itemId, limit]);
|
|
224
|
+
return rows.map((p) => ({ ...p, id: Number(p.id) }));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const WEEK_ASKS_SQL = `
|
|
228
|
+
SELECT a.id, a.week_of, a.question_key, a.prompt, a.options, a.closes_at,
|
|
229
|
+
COALESCE(json_agg(json_build_object('choice', t.choice, 'taps', t.n) ORDER BY t.n DESC, t.choice)
|
|
230
|
+
FILTER (WHERE t.choice IS NOT NULL), '[]'::json) AS tallies
|
|
231
|
+
FROM market_watch_weekly_asks a
|
|
232
|
+
LEFT JOIN (SELECT ask_id, choice, count(*)::int AS n FROM market_watch_ask_answers GROUP BY ask_id, choice) t
|
|
233
|
+
ON t.ask_id = a.id
|
|
234
|
+
WHERE a.project = $1 AND a.week_of = $2
|
|
235
|
+
GROUP BY a.id
|
|
236
|
+
ORDER BY a.question_key`;
|
|
237
|
+
|
|
238
|
+
// weeklyAsk — the week's card: its questions, their chips, and how many tapped each.
|
|
239
|
+
async function weeklyAsk(pool, { project, weekOf }) {
|
|
240
|
+
const { rows } = await pool.query(WEEK_ASKS_SQL, [need(project), weekOf]);
|
|
241
|
+
return rows.map((a) => ({ ...a, id: Number(a.id) }));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const ASK_HISTORY_SQL = `
|
|
245
|
+
SELECT a.id, a.week_of, (a.closes_at IS NULL OR a.closes_at > $3) AS open,
|
|
246
|
+
EXISTS (SELECT 1 FROM market_watch_ask_answers x WHERE x.ask_id = a.id AND x.builder_id = $2) AS answered
|
|
247
|
+
FROM market_watch_weekly_asks a
|
|
248
|
+
WHERE a.project = $1
|
|
249
|
+
ORDER BY a.week_of DESC, a.id DESC`;
|
|
250
|
+
|
|
251
|
+
// askStreak — the card's "4 weeks in a row" for one member. A week counts once however
|
|
252
|
+
// many of its questions they answered.
|
|
253
|
+
async function askStreak(pool, { project, builderId, now = new Date() }) {
|
|
254
|
+
const { rows } = await pool.query(ASK_HISTORY_SQL, [need(project), builderId, now]);
|
|
255
|
+
const weeks = [];
|
|
256
|
+
const byWeek = new Map();
|
|
257
|
+
for (const r of rows) {
|
|
258
|
+
const k = String(r.week_of instanceof Date ? r.week_of.toISOString().slice(0, 10) : r.week_of);
|
|
259
|
+
if (!byWeek.has(k)) { byWeek.set(k, { id: k, open: false, answered: false }); weeks.push(byWeek.get(k)); }
|
|
260
|
+
const w = byWeek.get(k);
|
|
261
|
+
w.open = w.open || r.open === true;
|
|
262
|
+
w.answered = w.answered || r.answered === true;
|
|
263
|
+
}
|
|
264
|
+
return weekStreak(weeks, weeks.filter((w) => w.answered).map((w) => w.id));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const SETTINGS_SQL = `SELECT enabled, monthly_cap_usd, cadence, updated_by, updated_at
|
|
268
|
+
FROM market_watch_settings WHERE project = $1`;
|
|
269
|
+
const MONTH_SPEND_SQL = `
|
|
270
|
+
SELECT COALESCE(sum(ri.cost_usd), 0) AS spent
|
|
271
|
+
FROM market_watch_run_items ri
|
|
272
|
+
JOIN market_watch_runs r ON r.id = ri.run_id
|
|
273
|
+
WHERE r.project = $1 AND ri.finished_at >= $2 AND ri.finished_at < $3`;
|
|
274
|
+
const RECENT_RUNS_SQL = `
|
|
275
|
+
SELECT r.id, r.status, r.reason, r.trigger, r.created_at, r.started_at, r.finished_at,
|
|
276
|
+
COALESCE(sum(ri.cost_usd), 0) AS cost_usd,
|
|
277
|
+
count(ri.item_id)::int AS items_total,
|
|
278
|
+
(count(ri.item_id) FILTER (WHERE ri.status = 'done'))::int AS items_done
|
|
279
|
+
FROM market_watch_runs r
|
|
280
|
+
LEFT JOIN market_watch_run_items ri ON ri.run_id = r.id
|
|
281
|
+
WHERE r.project = $1 AND r.trigger <> 'seed'
|
|
282
|
+
GROUP BY r.id
|
|
283
|
+
ORDER BY r.created_at DESC, r.id DESC
|
|
284
|
+
LIMIT $2`;
|
|
285
|
+
|
|
286
|
+
// monthSpend — what Market Watch spent this calendar month (UTC), from the per-item costs.
|
|
287
|
+
async function monthSpend(pool, { project, now = new Date() }) {
|
|
288
|
+
const [start, end] = monthBounds(now);
|
|
289
|
+
const { rows: [r] } = await pool.query(MONTH_SPEND_SQL, [need(project), start, end]);
|
|
290
|
+
return Number(r.spent);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// settingsView — the fuel gauge: the switch, cap, cadence, this month's spend, the strip
|
|
294
|
+
// of recent runs (a skipped run is a row with its reason) and when the next one is due.
|
|
295
|
+
async function settingsView(pool, { project, now = new Date(), recent = 8 } = {}) {
|
|
296
|
+
need(project);
|
|
297
|
+
const [{ rows: [s] }, spent, { rows: runs }] = await Promise.all([
|
|
298
|
+
pool.query(SETTINGS_SQL, [project]),
|
|
299
|
+
monthSpend(pool, { project, now }),
|
|
300
|
+
pool.query(RECENT_RUNS_SQL, [project, recent]),
|
|
301
|
+
]);
|
|
302
|
+
const settings = s
|
|
303
|
+
? { enabled: s.enabled, monthly_cap_usd: Number(s.monthly_cap_usd), cadence: s.cadence, configured: true, updated_by: s.updated_by, updated_at: s.updated_at }
|
|
304
|
+
: { ...DEFAULT_SETTINGS };
|
|
305
|
+
const recentRuns = runs.map((r) => ({ ...r, id: Number(r.id), cost_usd: Number(r.cost_usd) }));
|
|
306
|
+
return {
|
|
307
|
+
settings,
|
|
308
|
+
spentThisMonthUsd: spent,
|
|
309
|
+
recentRuns,
|
|
310
|
+
nextRunDue: nextRunDue(recentRuns[0] ? recentRuns[0].created_at : null, settings.cadence),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const REMAINING_SQL = `
|
|
315
|
+
SELECT i.id, i.project, i.kind, i.name, i.url, i.description, i.why_tracked
|
|
316
|
+
FROM market_watch_items i
|
|
317
|
+
JOIN market_watch_runs r ON r.id = $2 AND r.project = i.project
|
|
318
|
+
WHERE i.project = $1 AND i.status = 'active'
|
|
319
|
+
AND NOT EXISTS (SELECT 1 FROM market_watch_run_items ri
|
|
320
|
+
WHERE ri.run_id = r.id AND ri.item_id = i.id AND ri.status IN ('done', 'error'))
|
|
321
|
+
ORDER BY i.id`;
|
|
322
|
+
|
|
323
|
+
// remainingItems — what a (resumed) run still has to do: the project's active items with
|
|
324
|
+
// no finished row in this run. An item already done is never returned, so it is never
|
|
325
|
+
// researched, or charged, twice (ADR 0393 §2). An item that ERRORED is finished too: the
|
|
326
|
+
// model ran and was paid for (task 1004937), so a resume does not buy it a second call.
|
|
327
|
+
// Each row carries its project, so the run can refuse one that is not its own.
|
|
328
|
+
async function remainingItems(pool, { project, runId }) {
|
|
329
|
+
const { rows } = await pool.query(REMAINING_SQL, [need(project), runId]);
|
|
330
|
+
return rows.map((r) => ({ ...r, id: Number(r.id) }));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
// reads
|
|
335
|
+
numberedRuns, listItems, getItem, caseFile, scoreHistory, rules, proposals,
|
|
336
|
+
weeklyAsk, askStreak, monthSpend, settingsView, remainingItems,
|
|
337
|
+
// pure
|
|
338
|
+
numberRuns, progress, sparklines, weekStreak, monthBounds, nextRunDue,
|
|
339
|
+
CADENCE_DAYS, DEFAULT_SETTINGS,
|
|
340
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/market-watch/key.js — the project's own Anthropic key (task 1004937, ADR 0394 §1).
|
|
4
|
+
//
|
|
5
|
+
// The weekly run calls the Messages API on the PROJECT's key, never on ours and never on a
|
|
6
|
+
// builder's: the project's account pays the provider directly, and Bongos holds no money
|
|
7
|
+
// (ADR 0385). The owner pastes it into Market Watch settings (the page is task 1004938);
|
|
8
|
+
// it is stored encrypted with the kernel secret box, exactly as ai-studio stores a
|
|
9
|
+
// builder's provider keys (task 1004919): a "v1.<iv>.<tag>.<ct>" blob plus the last four
|
|
10
|
+
// characters, which are all the page ever shows.
|
|
11
|
+
//
|
|
12
|
+
// THE ONE ACCESSOR. readProjectKey is the only function that turns the blob back into a
|
|
13
|
+
// key, and only the run calls it, right before it builds the client. It never logs the
|
|
14
|
+
// key, never returns it to a route, and answers null (not a throw) for "no usable key":
|
|
15
|
+
// a missing row, an empty column, or a blob the master key cannot open. The run turns
|
|
16
|
+
// that null into a skipped run with reason 'no_key', visible on the settings strip.
|
|
17
|
+
//
|
|
18
|
+
// sealProjectKey is the writer's half, here so the settings route (task 1004938) stores
|
|
19
|
+
// exactly the shape this file reads. Pure apart from the injected encrypt/decrypt, so a
|
|
20
|
+
// test runs it with no master key and no database.
|
|
21
|
+
|
|
22
|
+
const MIN_KEY = 16;
|
|
23
|
+
const MAX_KEY = 512;
|
|
24
|
+
|
|
25
|
+
const KEY_SQL = `SELECT anthropic_key_enc FROM market_watch_settings WHERE project = $1`;
|
|
26
|
+
|
|
27
|
+
function defaultBox() {
|
|
28
|
+
return require('../../src/module-api').secretBox;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// sealProjectKey(raw, { encrypt }) → { key_enc, last4 } | { error }. Never echoes the key.
|
|
32
|
+
function sealProjectKey(raw, { encrypt } = {}) {
|
|
33
|
+
if (typeof raw !== 'string') return { error: 'key_required' };
|
|
34
|
+
const key = raw.trim();
|
|
35
|
+
if (!key) return { error: 'key_required' };
|
|
36
|
+
if (key.length < MIN_KEY || key.length > MAX_KEY) return { error: 'key_wrong_length' };
|
|
37
|
+
if (/\s/.test(key)) return { error: 'key_has_spaces' };
|
|
38
|
+
const enc = (encrypt || ((s) => defaultBox().encrypt(s)))(key);
|
|
39
|
+
return { key_enc: enc, last4: key.slice(-4) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// readProjectKey(pool, project, { decrypt }) → the plaintext key, or null.
|
|
43
|
+
// Fails closed to null: no key means no run, never a run on some other key.
|
|
44
|
+
async function readProjectKey(pool, project, { decrypt } = {}) {
|
|
45
|
+
if (typeof project !== 'string' || !project) return null;
|
|
46
|
+
const { rows } = await pool.query(KEY_SQL, [project]);
|
|
47
|
+
const blob = rows[0] && rows[0].anthropic_key_enc;
|
|
48
|
+
if (typeof blob !== 'string' || !blob) return null;
|
|
49
|
+
try {
|
|
50
|
+
const key = (decrypt || ((b) => defaultBox().decrypt(b)))(blob);
|
|
51
|
+
return typeof key === 'string' && key.trim() ? key.trim() : null;
|
|
52
|
+
} catch (_) {
|
|
53
|
+
// A wrong or missing master key, or a tampered blob. Not a key we can use.
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { readProjectKey, sealProjectKey, KEY_SQL };
|