@bongos/core 1.23.22 → 1.24.1
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 +902 -407
- 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 +90 -0
- package/clients/bongos-client/index.cjs +90 -0
- package/clients/bongos-client/index.d.ts +149 -4
- package/clients/bongos-client/index.mjs +90 -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/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 +5 -1
- package/docs/api/openapi.json +8555 -5876
- package/docs/api-reference.md +63 -10
- package/docs/architecture.md +37 -1
- package/docs/canonical-permissions.md +1 -1
- package/docs/copy-inventory.md +420 -337
- package/docs/copy-registry.json +1321 -500
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +5 -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 +55 -6
- package/docs/page-readings.json +1185 -1011
- 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/catalog.js +6 -0
- package/modules/government/db.js +34 -0
- package/modules/government/docket.js +36 -0
- package/modules/government/migrations/government_035_payout_approve.sql +29 -0
- package/modules/government/pollers/board-expiry.js +108 -20
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/government/routes/government.js +22 -0
- package/modules/government/routing.js +39 -5
- package/modules/government/waiting.js +74 -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 +156 -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/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 +24 -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 +220 -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/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 +3 -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/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_protected_surfaces.mjs +1 -1
- package/tests/government_routes.mjs +1 -1
- 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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// tests/claims_batch_routes.mjs — the batch claim and batch release routes,
|
|
2
|
+
// driven end to end through express and the error envelope (task 1004983;
|
|
3
|
+
// gaps 2 and 15 of credit-burn/test-gap-hunt.md).
|
|
4
|
+
//
|
|
5
|
+
// Why this file exists. releaseClaimsBatch (modules/lifecycle/db-claims.js) was
|
|
6
|
+
// named in no test, yet it is the one place a builder hands N claims back at
|
|
7
|
+
// once: it sets each task to 'ready' and clears needs_rebase, and its `force`
|
|
8
|
+
// flag skips the session check. The only thing that stops a builder releasing
|
|
9
|
+
// SOMEONE ELSE's claim is its per-item ownership compare. POST /claims/batch
|
|
10
|
+
// was never driven as a route either, so nothing pinned which gates sit in
|
|
11
|
+
// front of it.
|
|
12
|
+
//
|
|
13
|
+
// How. The doorway (src/module-api.js) is stubbed in require.cache BEFORE the
|
|
14
|
+
// route loads — db-claims.js destructures pool/withTx at require time, the
|
|
15
|
+
// tests/access_requests_invite.mjs pattern. requireBuilder admits whichever
|
|
16
|
+
// caller a test sets; withTx runs the REAL releaseClaimsBatch against a client
|
|
17
|
+
// that records every query and answers the claims read from a fixture. For
|
|
18
|
+
// POST /claims/batch the db facade's claimTasksBatch is swapped per test for a
|
|
19
|
+
// recording handler (the route reads it at call time), so each documented error
|
|
20
|
+
// can be thrown on demand.
|
|
21
|
+
//
|
|
22
|
+
// Every permission test asserts BOTH halves: the refusal, and that no write
|
|
23
|
+
// query ran.
|
|
24
|
+
//
|
|
25
|
+
// Run: node --test tests/claims_batch_routes.mjs
|
|
26
|
+
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const express = require('express');
|
|
34
|
+
|
|
35
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
36
|
+
const realApi = require(apiPath);
|
|
37
|
+
|
|
38
|
+
// ── the recording pool ───────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
let calls = []; // every query any path ran: { sql, params, via }
|
|
41
|
+
let claimFixture = []; // rows the claims read answers with
|
|
42
|
+
let activeClaims = []; // rows getActiveClaimsForBuilder answers with
|
|
43
|
+
let connects = 0; // pool.connect() count — claimTasksBatch's tx door
|
|
44
|
+
|
|
45
|
+
function answer(sql, params, via) {
|
|
46
|
+
calls.push({ sql, params, via });
|
|
47
|
+
// The release's locked read (FOR UPDATE) — distinct from the newcomer gate's
|
|
48
|
+
// active-claims read, which joins the same tables.
|
|
49
|
+
if (/FROM claims c JOIN tasks t ON t\.id = c\.task_id/.test(sql) && /FOR UPDATE/.test(sql)) {
|
|
50
|
+
const wanted = new Set(params[0].map(String));
|
|
51
|
+
return { rows: claimFixture.filter((r) => wanted.has(String(r.id))) };
|
|
52
|
+
}
|
|
53
|
+
if (/FROM claims c/.test(sql) && /released_at IS NULL/.test(sql)) {
|
|
54
|
+
return { rows: activeClaims };
|
|
55
|
+
}
|
|
56
|
+
return { rows: [], rowCount: 1 };
|
|
57
|
+
}
|
|
58
|
+
const client = {
|
|
59
|
+
async query(sql, params) { return answer(sql, params, 'tx'); },
|
|
60
|
+
release() {},
|
|
61
|
+
};
|
|
62
|
+
const pool = {
|
|
63
|
+
async query(sql, params) { return answer(sql, params, 'pool'); },
|
|
64
|
+
async connect() { connects += 1; return client; },
|
|
65
|
+
};
|
|
66
|
+
// Same shape as src/bongos/db-kernel.js withTx, on the recording client.
|
|
67
|
+
async function withTx(fn) {
|
|
68
|
+
await client.query('BEGIN');
|
|
69
|
+
try {
|
|
70
|
+
const out = await fn(client);
|
|
71
|
+
await client.query('COMMIT');
|
|
72
|
+
return out;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
await client.query('ROLLBACK');
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let caller = { id: '42', rank: 'thetes' };
|
|
80
|
+
let callerSession = 'sess-A';
|
|
81
|
+
function requireBuilder(req, _res, next) {
|
|
82
|
+
req.builder = { ...caller };
|
|
83
|
+
req.gdsSession = callerSession ? { session_id: callerSession } : undefined;
|
|
84
|
+
next();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
require.cache[apiPath].exports = { ...realApi, pool, withTx, requireBuilder };
|
|
88
|
+
|
|
89
|
+
const db = require('../modules/lifecycle/db.js');
|
|
90
|
+
const realClaimTasksBatch = db.claimTasksBatch;
|
|
91
|
+
const buildClaimsRouter = require('../modules/lifecycle/routes/claims.js');
|
|
92
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
93
|
+
|
|
94
|
+
const router = buildClaimsRouter();
|
|
95
|
+
const app = express();
|
|
96
|
+
app.use(express.json());
|
|
97
|
+
app.use(attachFail);
|
|
98
|
+
app.use('/api/bongos', router);
|
|
99
|
+
|
|
100
|
+
let server, port;
|
|
101
|
+
before(async () => {
|
|
102
|
+
server = http.createServer(app);
|
|
103
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
104
|
+
port = server.address().port;
|
|
105
|
+
});
|
|
106
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
calls = []; claimFixture = []; activeClaims = []; connects = 0;
|
|
110
|
+
caller = { id: '42', rank: 'thetes' };
|
|
111
|
+
callerSession = 'sess-A';
|
|
112
|
+
db.claimTasksBatch = realClaimTasksBatch;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
function post(pathname, body) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const payload = JSON.stringify(body);
|
|
118
|
+
const req = http.request({
|
|
119
|
+
host: '127.0.0.1', port, path: pathname, method: 'POST',
|
|
120
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
121
|
+
}, (res) => {
|
|
122
|
+
let data = '';
|
|
123
|
+
res.on('data', (c) => { data += c; });
|
|
124
|
+
res.on('end', () => resolve({ status: res.statusCode, json: data ? JSON.parse(data) : null }));
|
|
125
|
+
});
|
|
126
|
+
req.on('error', reject);
|
|
127
|
+
req.write(payload);
|
|
128
|
+
req.end();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const RELEASE = '/api/bongos/claims/batch/release';
|
|
133
|
+
const CLAIM = '/api/bongos/claims/batch';
|
|
134
|
+
|
|
135
|
+
// Any statement that changes state. BEGIN/COMMIT and SELECTs are not writes.
|
|
136
|
+
const isWrite = (c) => /^\s*(UPDATE|INSERT|DELETE)\b/i.test(c.sql);
|
|
137
|
+
const writes = () => calls.filter(isWrite);
|
|
138
|
+
const claimReleases = () => writes().filter((c) => /UPDATE claims SET released_at/.test(c.sql));
|
|
139
|
+
const taskToReady = () => writes().filter((c) => /UPDATE tasks\s+SET status = 'ready'/.test(c.sql));
|
|
140
|
+
|
|
141
|
+
// A claim row as the locked read returns it. node-pg hands bigint columns back
|
|
142
|
+
// as strings, so the realistic default spells every id that way.
|
|
143
|
+
const row = (over) => ({
|
|
144
|
+
id: '101', task_id: '501', builder_id: '42', released_at: null,
|
|
145
|
+
claimed_at: '2026-10-29T10:00:00Z', worktree_name: 'tree-a',
|
|
146
|
+
creator_session_id: 'sess-A', title: 'Own task', ...over,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── POST /claims/batch/release — the mixed batch ─────────────────────────────
|
|
150
|
+
|
|
151
|
+
test('batch release [own, another builder\'s, own already-released, missing]: only the own open claim is released', async () => {
|
|
152
|
+
claimFixture = [
|
|
153
|
+
row({ id: '101', task_id: '501', title: 'Own task' }),
|
|
154
|
+
row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z', title: 'Their task' }),
|
|
155
|
+
row({ id: '103', task_id: '503', released_at: '2026-10-28T00:00:00Z', title: 'Old task' }),
|
|
156
|
+
];
|
|
157
|
+
const r = await post(RELEASE, { claim_ids: [101, 102, 103, 999] });
|
|
158
|
+
assert.equal(r.status, 200);
|
|
159
|
+
assert.deepEqual(r.json.released, [{ claim_id: 101, task_id: '501', task_title: 'Own task' }]);
|
|
160
|
+
assert.deepEqual(r.json.failures, [
|
|
161
|
+
{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' },
|
|
162
|
+
{ claim_id: 103, reason: 'ALREADY_RESOLVED' },
|
|
163
|
+
{ claim_id: 999, reason: 'CLAIM_NOT_FOUND' },
|
|
164
|
+
]);
|
|
165
|
+
// Exactly one claim closed and one task sent back to the queue — the own one.
|
|
166
|
+
assert.equal(claimReleases().length, 1);
|
|
167
|
+
assert.deepEqual(claimReleases()[0].params, [101]);
|
|
168
|
+
assert.equal(taskToReady().length, 1);
|
|
169
|
+
assert.deepEqual(taskToReady()[0].params, ['501']);
|
|
170
|
+
// Nothing at all was written about the other builder's claim or task.
|
|
171
|
+
for (const w of writes()) {
|
|
172
|
+
assert.ok(!w.params.map(String).some((p) => p === '102' || p === '502'),
|
|
173
|
+
`a write touched the other builder's claim or task: ${w.sql.trim().slice(0, 60)}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('batch release of ONLY another builder\'s claim: refused, and no write query ran', async () => {
|
|
178
|
+
claimFixture = [row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z' })];
|
|
179
|
+
const r = await post(RELEASE, { claim_ids: [102] });
|
|
180
|
+
assert.equal(r.status, 200);
|
|
181
|
+
assert.deepEqual(r.json.released, []);
|
|
182
|
+
assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }]);
|
|
183
|
+
assert.deepEqual(writes(), []);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('force does not reach past ownership: another builder\'s claim with force:true is still refused, nothing written', async () => {
|
|
187
|
+
claimFixture = [row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z' })];
|
|
188
|
+
const r = await post(RELEASE, { claim_ids: [102], force: true });
|
|
189
|
+
assert.equal(r.status, 200);
|
|
190
|
+
assert.deepEqual(r.json.released, []);
|
|
191
|
+
assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }]);
|
|
192
|
+
assert.deepEqual(writes(), []);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── the id-type matrix (node-pg bigint strings vs numbers) ────────────────────
|
|
196
|
+
|
|
197
|
+
test('another builder\'s claim is refused whatever the id types — string or number on either side', async () => {
|
|
198
|
+
for (const [rowId, callerId] of [['7', '42'], ['7', 42], [7, '42'], [7, 42]]) {
|
|
199
|
+
calls = [];
|
|
200
|
+
caller = { id: callerId, rank: 'thetes' };
|
|
201
|
+
claimFixture = [row({ id: '102', task_id: '502', builder_id: rowId, creator_session_id: 'sess-Z' })];
|
|
202
|
+
const r = await post(RELEASE, { claim_ids: [102] });
|
|
203
|
+
assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }],
|
|
204
|
+
`row builder_id ${JSON.stringify(rowId)} vs caller ${JSON.stringify(callerId)}`);
|
|
205
|
+
assert.deepEqual(writes(), []);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('own claim with matching id types (both strings, as production sends them) is released', async () => {
|
|
210
|
+
caller = { id: '42', rank: 'thetes' };
|
|
211
|
+
claimFixture = [row({ builder_id: '42' }), row({ id: '102', task_id: '502', builder_id: '7', creator_session_id: 'sess-Z' })];
|
|
212
|
+
const r = await post(RELEASE, { claim_ids: [101, 102] });
|
|
213
|
+
assert.deepEqual(r.json.released.map((x) => x.claim_id), [101]);
|
|
214
|
+
assert.deepEqual(r.json.failures, [{ claim_id: 102, reason: 'NOT_YOUR_CLAIM' }]);
|
|
215
|
+
assert.equal(claimReleases().length, 1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// KNOWN BUG (task 1005004, filed by task 1004983): releaseClaimsBatch compares
|
|
219
|
+
// claim.builder_id !== builderId strictly, so a STRING row id (node-pg bigint)
|
|
220
|
+
// against a NUMBER caller id refuses the caller's OWN claim as NOT_YOUR_CLAIM.
|
|
221
|
+
// Production is unaffected today only because req.builder.id is also a string.
|
|
222
|
+
// It fails closed (never releases a stranger's claim), so this pins CURRENT
|
|
223
|
+
// behaviour; when the compare is fixed, flip this to expect a release.
|
|
224
|
+
test('KNOWN BUG: own claim as string builder_id vs a NUMBER caller id is refused NOT_YOUR_CLAIM (fails closed)', async () => {
|
|
225
|
+
caller = { id: 42, rank: 'thetes' };
|
|
226
|
+
claimFixture = [row({ builder_id: '42' })];
|
|
227
|
+
const r = await post(RELEASE, { claim_ids: [101] });
|
|
228
|
+
assert.deepEqual(r.json.released, []);
|
|
229
|
+
assert.deepEqual(r.json.failures, [{ claim_id: 101, reason: 'NOT_YOUR_CLAIM' }]);
|
|
230
|
+
assert.deepEqual(writes(), []);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── the session check, and force ─────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
test('own claim bound to a different session, without force: CLAIM_SESSION_MISMATCH, nothing written', async () => {
|
|
236
|
+
callerSession = 'sess-A';
|
|
237
|
+
claimFixture = [row({ creator_session_id: 'sess-B', worktree_name: 'tree-b' })];
|
|
238
|
+
const r = await post(RELEASE, { claim_ids: [101] });
|
|
239
|
+
assert.equal(r.status, 200);
|
|
240
|
+
assert.deepEqual(r.json.released, []);
|
|
241
|
+
assert.deepEqual(r.json.failures, [{ claim_id: 101, reason: 'CLAIM_SESSION_MISMATCH', claimWorktree: 'tree-b' }]);
|
|
242
|
+
assert.deepEqual(writes(), []);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('own claim bound to a different session, WITH force: released', async () => {
|
|
246
|
+
callerSession = 'sess-A';
|
|
247
|
+
claimFixture = [row({ creator_session_id: 'sess-B', worktree_name: 'tree-b' })];
|
|
248
|
+
const r = await post(RELEASE, { claim_ids: [101], force: true });
|
|
249
|
+
assert.equal(r.status, 200);
|
|
250
|
+
assert.deepEqual(r.json.released.map((x) => x.claim_id), [101]);
|
|
251
|
+
assert.deepEqual(r.json.failures, []);
|
|
252
|
+
assert.equal(claimReleases().length, 1);
|
|
253
|
+
assert.equal(taskToReady().length, 1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('force must be the boolean true: a truthy string is refused by validation before any query', async () => {
|
|
257
|
+
claimFixture = [row({ creator_session_id: 'sess-B' })];
|
|
258
|
+
const r = await post(RELEASE, { claim_ids: [101], force: 'yes' });
|
|
259
|
+
assert.equal(r.status, 400);
|
|
260
|
+
assert.deepEqual(calls, []);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('a released task goes back to ready with needs_rebase cleared, and the session log is sealed as abandoned', async () => {
|
|
264
|
+
claimFixture = [row()];
|
|
265
|
+
await post(RELEASE, { claim_ids: [101] });
|
|
266
|
+
const ready = taskToReady()[0];
|
|
267
|
+
assert.match(ready.sql, /needs_rebase = false/);
|
|
268
|
+
assert.match(ready.sql, /needs_rebase_reason = NULL/);
|
|
269
|
+
assert.match(claimReleases()[0].sql, /outcome = 'abandoned'/);
|
|
270
|
+
const seal = writes().find((c) => /INSERT INTO session_logs/.test(c.sql));
|
|
271
|
+
assert.ok(seal, 'session log sealed');
|
|
272
|
+
assert.equal(seal.params[0], '42', 'sealed under the caller, never a caller-supplied id');
|
|
273
|
+
assert.equal(seal.params[4], 'abandoned');
|
|
274
|
+
// All of it inside one transaction.
|
|
275
|
+
assert.equal(calls[0].sql, 'BEGIN');
|
|
276
|
+
assert.equal(calls[calls.length - 1].sql, 'COMMIT');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── the release body ──────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
test('release body: an empty array → 400 claim_ids_required, no query', async () => {
|
|
282
|
+
const r = await post(RELEASE, { claim_ids: [] });
|
|
283
|
+
assert.equal(r.status, 400);
|
|
284
|
+
assert.equal(r.json.error.code, 'claim_ids_required');
|
|
285
|
+
assert.deepEqual(calls, []);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('release body: 21 ids → 400, no query', async () => {
|
|
289
|
+
const r = await post(RELEASE, { claim_ids: Array.from({ length: 21 }, (_, i) => i + 1) });
|
|
290
|
+
assert.equal(r.status, 400);
|
|
291
|
+
assert.deepEqual(calls, []);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('release body: 20 ids is allowed through to the release', async () => {
|
|
295
|
+
const r = await post(RELEASE, { claim_ids: Array.from({ length: 20 }, (_, i) => i + 1) });
|
|
296
|
+
assert.equal(r.status, 200);
|
|
297
|
+
assert.equal(r.json.failures.length, 20);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('release body: non-numeric ids → 400 claim_ids_must_be_numbers, no query', async () => {
|
|
301
|
+
const r = await post(RELEASE, { claim_ids: [101, 'abc'] });
|
|
302
|
+
assert.equal(r.status, 400);
|
|
303
|
+
assert.equal(r.json.error.code, 'claim_ids_must_be_numbers');
|
|
304
|
+
assert.deepEqual(calls, []);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('release body: claim_ids missing → 400, no query', async () => {
|
|
308
|
+
const r = await post(RELEASE, {});
|
|
309
|
+
assert.equal(r.status, 400);
|
|
310
|
+
assert.deepEqual(calls, []);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── POST /claims/batch — the middleware chain ────────────────────────────────
|
|
314
|
+
|
|
315
|
+
function chainFor(path) {
|
|
316
|
+
const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods.post);
|
|
317
|
+
assert.ok(layer, `POST ${path} is mounted`);
|
|
318
|
+
return layer.route.stack.map((s) => s.handle.name || '<handler>');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
test('POST /claims/batch middleware chain is pinned: requireBuilder → gateNewcomerClaim → handler', () => {
|
|
322
|
+
assert.deepEqual(chainFor('/claims/batch'), ['requireBuilder', 'gateNewcomerClaim', '<handler>']);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('POST /claims carries the one-claim-per-session gate that POST /claims/batch does not (the documented difference)', () => {
|
|
326
|
+
const single = chainFor('/claims');
|
|
327
|
+
assert.ok(single.includes('gateOneActiveClaimPerSession'));
|
|
328
|
+
assert.ok(single.includes('gateNewcomerClaim'));
|
|
329
|
+
assert.ok(!chainFor('/claims/batch').includes('gateOneActiveClaimPerSession'),
|
|
330
|
+
'if batch claim gains the session gate, update this pin (task 1005005 asks whether it should)');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('POST /claims/batch/release middleware chain is pinned: requireBuilder → handler', () => {
|
|
334
|
+
assert.deepEqual(chainFor('/claims/batch/release'), ['requireBuilder', '<handler>']);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ── POST /claims/batch — the newcomer gate ───────────────────────────────────
|
|
338
|
+
|
|
339
|
+
test('a xenos with a claim in flight is refused by gateNewcomerClaim: 403, and no claim or write is attempted', async () => {
|
|
340
|
+
caller = { id: '42', rank: 'xenos' };
|
|
341
|
+
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task' }];
|
|
342
|
+
let batchCalls = 0;
|
|
343
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
344
|
+
const r = await post(CLAIM, { task_ids: [501, 502] });
|
|
345
|
+
assert.equal(r.status, 403);
|
|
346
|
+
assert.equal(r.json.error.code, 'newcomer_must_ship_first');
|
|
347
|
+
assert.equal(r.json.error.details.active_task_id, '700');
|
|
348
|
+
assert.equal(batchCalls, 0, 'claimTasksBatch never ran');
|
|
349
|
+
assert.equal(connects, 0, 'no transaction was opened');
|
|
350
|
+
assert.deepEqual(writes(), []);
|
|
351
|
+
assert.equal(calls.length, 1, 'only the gate\'s own active-claims read');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('a thetes with a claim in flight is NOT held by the newcomer gate — and makes no gate lookup', async () => {
|
|
355
|
+
caller = { id: '42', rank: 'thetes' };
|
|
356
|
+
activeClaims = [{ claim_id: '88', task_id: '700', title: 'First task' }];
|
|
357
|
+
let batchCalls = 0;
|
|
358
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
359
|
+
const r = await post(CLAIM, { task_ids: [501] });
|
|
360
|
+
assert.equal(r.status, 201);
|
|
361
|
+
assert.equal(batchCalls, 1);
|
|
362
|
+
assert.deepEqual(calls, []);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ── POST /claims/batch — each documented error maps to its status and body ────
|
|
366
|
+
|
|
367
|
+
test('success: 201 with the claims, ids coerced to numbers, caller and session from the server', async () => {
|
|
368
|
+
let seen;
|
|
369
|
+
db.claimTasksBatch = async (args) => { seen = args; return { claims: [{ id: 1, task_id: 501 }] }; };
|
|
370
|
+
const r = await post(CLAIM, { task_ids: ['501', 502], worktree_name: 'wt' });
|
|
371
|
+
assert.equal(r.status, 201);
|
|
372
|
+
assert.deepEqual(r.json.claims, [{ id: 1, task_id: 501 }]);
|
|
373
|
+
assert.deepEqual(seen, { taskIds: [501, 502], builderId: '42', worktreePrefix: 'wt', creatorSessionId: 'sess-A' });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('bind_session:false claims session-unbound', async () => {
|
|
377
|
+
let seen;
|
|
378
|
+
db.claimTasksBatch = async (args) => { seen = args; return { claims: [] }; };
|
|
379
|
+
await post(CLAIM, { task_ids: [501], bind_session: false });
|
|
380
|
+
assert.equal(seen.creatorSessionId, null);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const ERROR_MAP = [
|
|
384
|
+
['BATCH_FAILED', 409, 'batch_failed'],
|
|
385
|
+
['BATCH_EMPTY', 400, 'task_ids_required'],
|
|
386
|
+
['BATCH_DUPLICATE_IDS', 400, 'duplicate_task_ids'],
|
|
387
|
+
['BUILDER_INACTIVE', 403, 'BUILDER_INACTIVE'],
|
|
388
|
+
];
|
|
389
|
+
for (const [thrown, status, code] of ERROR_MAP) {
|
|
390
|
+
test(`claimTasksBatch throws ${thrown} → ${status} ${code}`, async () => {
|
|
391
|
+
const failures = [{ task_id: 501, reason: 'ALREADY_CLAIMED' }];
|
|
392
|
+
db.claimTasksBatch = async () => { throw { code: thrown, failures }; };
|
|
393
|
+
const r = await post(CLAIM, { task_ids: [501] });
|
|
394
|
+
assert.equal(r.status, status);
|
|
395
|
+
assert.equal(r.json.error.code, code);
|
|
396
|
+
if (thrown === 'BATCH_FAILED') assert.deepEqual(r.json.error.details, { failures });
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test('an unexpected error → 500 batch_claim_failed with no internals in the message', async () => {
|
|
401
|
+
db.claimTasksBatch = async () => { throw new Error('connection refused at 10.0.0.1'); };
|
|
402
|
+
const r = await post(CLAIM, { task_ids: [501] });
|
|
403
|
+
assert.equal(r.status, 500);
|
|
404
|
+
assert.equal(r.json.error.code, 'batch_claim_failed');
|
|
405
|
+
assert.equal(r.json.error.message, 'internal error');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('claim body: empty → 400 task_ids_required, 21 ids → 400, non-numeric → 400 task_ids_must_be_numbers; never reaches claimTasksBatch', async () => {
|
|
409
|
+
let batchCalls = 0;
|
|
410
|
+
db.claimTasksBatch = async () => { batchCalls += 1; return { claims: [] }; };
|
|
411
|
+
const empty = await post(CLAIM, { task_ids: [] });
|
|
412
|
+
assert.equal(empty.status, 400);
|
|
413
|
+
assert.equal(empty.json.error.code, 'task_ids_required');
|
|
414
|
+
const big = await post(CLAIM, { task_ids: Array.from({ length: 21 }, (_, i) => i + 1) });
|
|
415
|
+
assert.equal(big.status, 400);
|
|
416
|
+
const bad = await post(CLAIM, { task_ids: [1, 'x'] });
|
|
417
|
+
assert.equal(bad.status, 400);
|
|
418
|
+
assert.equal(bad.json.error.code, 'task_ids_must_be_numbers');
|
|
419
|
+
assert.equal(batchCalls, 0);
|
|
420
|
+
assert.deepEqual(calls, []);
|
|
421
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// tests/config_read_cache.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004924 — request-path config reads are cached. authConfigured(),
|
|
4
|
+
// discordConfigured() and the secret box's master-key load ran on every request
|
|
5
|
+
// and, with no env creds, each did sync readFileSync attempts (most of them
|
|
6
|
+
// ENOENT misses) that blocked the event loop. They now go through
|
|
7
|
+
// instance-config readConfigFileCached: one read per TTL window, keyed by the
|
|
8
|
+
// resolved read paths, so a different home dir is never answered from another
|
|
9
|
+
// dir's cache.
|
|
10
|
+
//
|
|
11
|
+
// Hermetic: every case runs under a fresh temp HOME (USERPROFILE too — on
|
|
12
|
+
// Windows os.homedir() reads that, never HOME), proven before the body runs, and
|
|
13
|
+
// counts fs.readFileSync calls for the file under test.
|
|
14
|
+
//
|
|
15
|
+
// Run: node tests/config_read_cache.mjs
|
|
16
|
+
|
|
17
|
+
import { strict as assert } from 'node:assert';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const ic = require('../src/instance-config.js');
|
|
28
|
+
const authConfig = require('../src/bongos/auth-config.js');
|
|
29
|
+
const authDiscord = require('../src/bongos/auth-discord.js');
|
|
30
|
+
const box = require('../src/bongos/secret-box.js');
|
|
31
|
+
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
// Every env name that could satisfy the creds lookups from env (any prefix).
|
|
35
|
+
const ENV_SUFFIXES = /(GITHUB_CLIENT_ID|GITHUB_CLIENT_SECRET|DISCORD_CLIENT_ID|DISCORD_CLIENT_SECRET|BUILDER_SECRET_KEY)$/;
|
|
36
|
+
|
|
37
|
+
function withSandbox(fn) {
|
|
38
|
+
const home = mkdtempSync(path.join(tmpdir(), 'otb-cfgcache-'));
|
|
39
|
+
const { root } = path.parse(home);
|
|
40
|
+
const sandbox = {
|
|
41
|
+
HOME: home,
|
|
42
|
+
USERPROFILE: home,
|
|
43
|
+
HOMEDRIVE: root.replace(/[\\/]+$/, ''),
|
|
44
|
+
HOMEPATH: home.slice(root.length - 1) || '\\',
|
|
45
|
+
};
|
|
46
|
+
const saved = {};
|
|
47
|
+
for (const k of Object.keys(process.env)) if (ENV_SUFFIXES.test(k)) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
48
|
+
for (const k of Object.keys(sandbox)) { saved[k] = process.env[k]; process.env[k] = sandbox[k]; }
|
|
49
|
+
ic.clearConfigReadCache();
|
|
50
|
+
try {
|
|
51
|
+
if (os.homedir() !== home) throw new Error(`sandbox escape: os.homedir() is ${os.homedir()}, not ${home}`);
|
|
52
|
+
return fn(home);
|
|
53
|
+
} finally {
|
|
54
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
55
|
+
ic.clearConfigReadCache();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Count fs.readFileSync calls whose path ends with `name`, for the body of fn.
|
|
60
|
+
function countReads(name, fn) {
|
|
61
|
+
const real = fs.readFileSync;
|
|
62
|
+
let n = 0;
|
|
63
|
+
fs.readFileSync = function (p, ...rest) {
|
|
64
|
+
if (String(p).endsWith(name)) n++;
|
|
65
|
+
return real.call(this, p, ...rest);
|
|
66
|
+
};
|
|
67
|
+
try { fn(); } finally { fs.readFileSync = real; }
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeCfg(home, name, body) {
|
|
72
|
+
const dir = path.join(home, '.config', ic.configDirName());
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
writeFileSync(path.join(dir, name), body);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await test('authConfigured(): 50 calls with no env creds read gds-github.json once, not 50 times', () => {
|
|
78
|
+
withSandbox(() => {
|
|
79
|
+
const paths = ic.configReadPaths('gds-github.json').length;
|
|
80
|
+
const n = countReads('gds-github.json', () => {
|
|
81
|
+
for (let i = 0; i < 50; i++) assert.equal(authConfig.authConfigured(), false);
|
|
82
|
+
});
|
|
83
|
+
assert.ok(n <= paths, `read ${n} times; at most one pass over ${paths} dir(s) expected`);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('discordConfigured(): 50 calls read discord-bot.json once', () => {
|
|
88
|
+
withSandbox((home) => {
|
|
89
|
+
writeCfg(home, 'discord-bot.json', JSON.stringify({ client_id: 'c', client_secret: 's' }));
|
|
90
|
+
const n = countReads('discord-bot.json', () => {
|
|
91
|
+
for (let i = 0; i < 50; i++) assert.equal(authDiscord.discordConfigured(), true);
|
|
92
|
+
});
|
|
93
|
+
assert.equal(n, 1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await test('secret box: 50 isConfigured() calls from a key FILE read it once', () => {
|
|
98
|
+
withSandbox((home) => {
|
|
99
|
+
writeCfg(home, 'builder-secret.key', 'ab'.repeat(32) + '\n');
|
|
100
|
+
const n = countReads('builder-secret.key', () => {
|
|
101
|
+
for (let i = 0; i < 50; i++) assert.equal(box.isConfigured(), true);
|
|
102
|
+
});
|
|
103
|
+
assert.equal(n, 1);
|
|
104
|
+
// and the cached key still round-trips
|
|
105
|
+
assert.equal(box.decrypt(box.encrypt('hello')), 'hello');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('env stays live: setting or changing BUILDER_SECRET_KEY takes effect on the next call', () => {
|
|
110
|
+
withSandbox(() => {
|
|
111
|
+
assert.equal(box.isConfigured(), false);
|
|
112
|
+
process.env.BUILDER_SECRET_KEY = 'cd'.repeat(32);
|
|
113
|
+
assert.equal(box.isConfigured(), true);
|
|
114
|
+
const blob = box.encrypt('x');
|
|
115
|
+
process.env.BUILDER_SECRET_KEY = 'ef'.repeat(32);
|
|
116
|
+
assert.throws(() => box.decrypt(blob), undefined, 'a changed env key is used at once, not a memoised old one');
|
|
117
|
+
delete process.env.BUILDER_SECRET_KEY;
|
|
118
|
+
assert.equal(box.isConfigured(), false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('provisionMasterKey drops a cached miss, so the new key is seen at once', () => {
|
|
123
|
+
withSandbox(() => {
|
|
124
|
+
assert.equal(box.isConfigured(), false); // caches "no key file"
|
|
125
|
+
const r = box.provisionMasterKey();
|
|
126
|
+
assert.equal(r.created, true);
|
|
127
|
+
assert.equal(box.isConfigured(), true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('readConfigFileCached: a miss and a hit both last the TTL, then re-read', () => {
|
|
132
|
+
withSandbox((home) => {
|
|
133
|
+
let t = 1_000_000;
|
|
134
|
+
const now = () => t;
|
|
135
|
+
const ttlMs = 30_000;
|
|
136
|
+
assert.equal(ic.readConfigFileCached('probe.json', { now, ttlMs }), null);
|
|
137
|
+
writeCfg(home, 'probe.json', 'v1');
|
|
138
|
+
t += ttlMs - 1;
|
|
139
|
+
assert.equal(ic.readConfigFileCached('probe.json', { now, ttlMs }), null, 'a cached miss holds inside the TTL');
|
|
140
|
+
t += 1;
|
|
141
|
+
assert.equal(ic.readConfigFileCached('probe.json', { now, ttlMs }), 'v1', 'after the TTL the file is read again');
|
|
142
|
+
// the DEFAULT TTL is short: five minutes on, a dropped-in creds file is seen
|
|
143
|
+
writeCfg(home, 'probe.json', 'v2');
|
|
144
|
+
t += 5 * 60_000;
|
|
145
|
+
assert.equal(ic.readConfigFileCached('probe.json', { now }), 'v2', 'the default TTL keeps creds hot-reloadable');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await test('readConfigFileCached: a different home is a fresh read, never another dir\'s cache', () => {
|
|
150
|
+
withSandbox((homeA) => {
|
|
151
|
+
writeCfg(homeA, 'probe2.json', 'from-A');
|
|
152
|
+
assert.equal(ic.readConfigFileCached('probe2.json'), 'from-A');
|
|
153
|
+
});
|
|
154
|
+
withSandbox(() => {
|
|
155
|
+
assert.equal(ic.readConfigFileCached('probe2.json'), null, 'a new home with no file reads null');
|
|
156
|
+
});
|
|
157
|
+
// withSandbox clears the cache, so prove the KEY isolates homes with no clear between
|
|
158
|
+
const homeB = mkdtempSync(path.join(tmpdir(), 'otb-cfgcache-'));
|
|
159
|
+
withSandbox((homeA) => {
|
|
160
|
+
writeCfg(homeA, 'probe3.json', 'A');
|
|
161
|
+
assert.equal(ic.readConfigFileCached('probe3.json'), 'A');
|
|
162
|
+
const keep = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
|
|
163
|
+
process.env.HOME = homeB; process.env.USERPROFILE = homeB;
|
|
164
|
+
try {
|
|
165
|
+
if (os.homedir() !== homeB) return; // platform reads some other var; covered above
|
|
166
|
+
assert.equal(ic.readConfigFileCached('probe3.json'), null, 'home B has no probe3.json');
|
|
167
|
+
} finally { Object.assign(process.env, keep); }
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
summary();
|
|
@@ -161,6 +161,18 @@ try {
|
|
|
161
161
|
assert.equal(errCode(u.body), 'page_unread');
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
await test('the editor read carries the engineering work open on the page files (information only)', async () => {
|
|
165
|
+
const orig = lifecyclePort.engineeringActiveOnPage;
|
|
166
|
+
const asked = [];
|
|
167
|
+
lifecyclePort.engineeringActiveOnPage = async (files) => { asked.push(files); return [{ task_id: '7', holder: 'Grace' }]; };
|
|
168
|
+
try {
|
|
169
|
+
const r = await call('GET', `${STUDIO}/draft`, ALICE);
|
|
170
|
+
assert.equal(r.status, 200);
|
|
171
|
+
assert.deepEqual(r.body.engineering_active, [{ task_id: '7', holder: 'Grace' }]);
|
|
172
|
+
assert.ok(asked.length === 1 && Array.isArray(asked[0]) && asked[0].length > 0, 'asked about the page files');
|
|
173
|
+
} finally { lifecyclePort.engineeringActiveOnPage = orig; }
|
|
174
|
+
});
|
|
175
|
+
|
|
164
176
|
await test('composeEditor keeps only draft lines the reading still has', () => {
|
|
165
177
|
const out = pageStatus.composeEditor({
|
|
166
178
|
page: { id: 'builders:studio', title: 'Studio', surface: 'builders' },
|