@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,201 @@
|
|
|
1
|
+
// tests/auth_backchannel_logout_route.mjs — POST /auth/backchannel-logout, executed
|
|
2
|
+
// for real (task 1004987, goal 1000094).
|
|
3
|
+
//
|
|
4
|
+
// This route needs no sign-in: the hub-signed logout token is the ONLY credential,
|
|
5
|
+
// and a valid one revokes every session the named builder holds. Until this file only
|
|
6
|
+
// the verifier (verifyHubLogoutToken) was tested, in tests/idp_device_exchange.mjs; the
|
|
7
|
+
// route around it — the not-federated 404, the missing-key 503, the 401 on a bad token,
|
|
8
|
+
// the single-use jti, and the no-leak answer for an unknown builder — was not.
|
|
9
|
+
//
|
|
10
|
+
// What is real: the router (src/bongos/routes/auth.js), verifyHubLogoutToken and its
|
|
11
|
+
// signature check, the logout jti store, the body validator and the error envelope.
|
|
12
|
+
// Tokens are minted by the REAL hub minter (modules/platform-identity/hub-keys.js) with
|
|
13
|
+
// a keypair generated here, the tests/idp_device_exchange.mjs keypair recipe. What is
|
|
14
|
+
// faked: auth.loadIdpConfig / auth.loadHubPublicKey (the instance's configuration),
|
|
15
|
+
// db.getBuilderByGithubId (who exists) and db.revokeAllSessionsForBuilder, which
|
|
16
|
+
// RECORDS instead of revoking. Every refusal asserts that nothing was revoked.
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test tests/auth_backchannel_logout_route.mjs
|
|
19
|
+
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const express = require('express');
|
|
28
|
+
|
|
29
|
+
const auth = require('../src/bongos/auth.js');
|
|
30
|
+
const db = require('../src/bongos/db.js');
|
|
31
|
+
const hubKeys = require('../modules/platform-identity/hub-keys.js');
|
|
32
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
33
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
34
|
+
|
|
35
|
+
// ── keys: the hub's, and a forger's ──────────────────────────────────────────
|
|
36
|
+
const { privateKeyB64, publicKeyB64 } = hubKeys.generateHubKeypair();
|
|
37
|
+
const hubSigningKey = crypto.createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
38
|
+
const hubPublicKey = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), format: 'der', type: 'spki' });
|
|
39
|
+
const forgerSigningKey = crypto.generateKeyPairSync('ed25519').privateKey;
|
|
40
|
+
|
|
41
|
+
const CLIENT_ID = 'client-under-test';
|
|
42
|
+
const KNOWN_GITHUB_ID = 4242;
|
|
43
|
+
const KNOWN_BUILDER = { id: 77, github_id: String(KNOWN_GITHUB_ID), github_login: 'known-builder' };
|
|
44
|
+
|
|
45
|
+
const mintLogout = ({ githubId = KNOWN_GITHUB_ID, clientId = CLIENT_ID } = {}, opts = {}) =>
|
|
46
|
+
hubKeys.mintHubLogoutToken({ clientId, githubId }, { signingKey: hubSigningKey, ...opts });
|
|
47
|
+
|
|
48
|
+
// ── the instance's configuration and data, per case ──────────────────────────
|
|
49
|
+
let idp = { clientId: CLIENT_ID };
|
|
50
|
+
let publicKey = hubPublicKey;
|
|
51
|
+
let revokes = [];
|
|
52
|
+
let lookups = [];
|
|
53
|
+
|
|
54
|
+
beforeEach(() => { idp = { clientId: CLIENT_ID }; publicKey = hubPublicKey; revokes = []; lookups = []; });
|
|
55
|
+
|
|
56
|
+
auth.loadIdpConfig = () => idp;
|
|
57
|
+
auth.loadHubPublicKey = () => publicKey;
|
|
58
|
+
db.getBuilderByGithubId = async (githubId) => {
|
|
59
|
+
lookups.push(String(githubId));
|
|
60
|
+
return String(githubId) === String(KNOWN_GITHUB_ID) ? { ...KNOWN_BUILDER } : null;
|
|
61
|
+
};
|
|
62
|
+
db.revokeAllSessionsForBuilder = async (builderId) => { revokes.push(builderId); return 3; };
|
|
63
|
+
|
|
64
|
+
const app = express();
|
|
65
|
+
app.use(express.json());
|
|
66
|
+
app.use(attachFail);
|
|
67
|
+
app.use('/api/gds', buildAuthRouter());
|
|
68
|
+
|
|
69
|
+
let server, port;
|
|
70
|
+
before(async () => {
|
|
71
|
+
server = http.createServer(app);
|
|
72
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
73
|
+
port = server.address().port;
|
|
74
|
+
});
|
|
75
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
76
|
+
|
|
77
|
+
function logout(body) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const payload = JSON.stringify(body);
|
|
80
|
+
const req = http.request({
|
|
81
|
+
host: '127.0.0.1', port, path: '/api/gds/auth/backchannel-logout', method: 'POST',
|
|
82
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
83
|
+
}, (res) => {
|
|
84
|
+
let raw = '';
|
|
85
|
+
res.on('data', (c) => { raw += c; });
|
|
86
|
+
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* text */ } resolve({ status: res.statusCode, json, raw }); });
|
|
87
|
+
});
|
|
88
|
+
req.on('error', reject);
|
|
89
|
+
req.write(payload);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── not a public surface unless the instance federates ───────────────────────
|
|
95
|
+
|
|
96
|
+
test('idp not configured → 404, no lookup, no revoke — even for a perfectly valid token', async () => {
|
|
97
|
+
idp = null;
|
|
98
|
+
const r = await logout({ logout_token: mintLogout() });
|
|
99
|
+
assert.equal(r.status, 404);
|
|
100
|
+
assert.equal(r.raw, 'not found', 'indistinguishable from a route that does not exist');
|
|
101
|
+
assert.deepEqual(lookups, []);
|
|
102
|
+
assert.deepEqual(revokes, []);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('no hub public key set → 503 idp_unconfigured and no revoke', async () => {
|
|
106
|
+
publicKey = null;
|
|
107
|
+
const r = await logout({ logout_token: mintLogout() });
|
|
108
|
+
assert.equal(r.status, 503);
|
|
109
|
+
assert.equal(r.json.error.code, 'idp_unconfigured');
|
|
110
|
+
assert.deepEqual(lookups, []);
|
|
111
|
+
assert.deepEqual(revokes, []);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── forged and wrong tokens: 401, nothing revoked ────────────────────────────
|
|
115
|
+
|
|
116
|
+
test('a forged token (signed by a key that is not the hub’s) → 401 and no revoke', async () => {
|
|
117
|
+
const forged = hubKeys.mintHubLogoutToken({ clientId: CLIENT_ID, githubId: KNOWN_GITHUB_ID }, { signingKey: forgerSigningKey });
|
|
118
|
+
const r = await logout({ logout_token: forged });
|
|
119
|
+
assert.equal(r.status, 401);
|
|
120
|
+
assert.equal(r.json.error.code, 'invalid_logout_token');
|
|
121
|
+
assert.match(r.json.error.message, /signature invalid/);
|
|
122
|
+
assert.deepEqual(lookups, [], 'the subject of an unverified token is never even looked up');
|
|
123
|
+
assert.deepEqual(revokes, []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a genuine token with its subject swapped (payload re-encoded, signature kept) → 401 and no revoke', async () => {
|
|
127
|
+
const [h, p, s] = mintLogout({ githubId: 1 }).split('.');
|
|
128
|
+
const claims = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
|
|
129
|
+
claims.sub = String(KNOWN_GITHUB_ID);
|
|
130
|
+
const tampered = `${h}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.${s}`;
|
|
131
|
+
const r = await logout({ logout_token: tampered });
|
|
132
|
+
assert.equal(r.status, 401);
|
|
133
|
+
assert.deepEqual(revokes, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a hub-signed token minted for ANOTHER instance → 401 and no revoke', async () => {
|
|
137
|
+
const r = await logout({ logout_token: mintLogout({ clientId: 'some-other-project' }) });
|
|
138
|
+
assert.equal(r.status, 401);
|
|
139
|
+
assert.match(r.json.error.message, /audience/);
|
|
140
|
+
assert.deepEqual(revokes, []);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a hub LOGIN assertion (no logout purpose) cannot log anyone out → 401 and no revoke', async () => {
|
|
144
|
+
const assertion = hubKeys.mintHubAssertion(
|
|
145
|
+
{ clientId: CLIENT_ID, githubId: KNOWN_GITHUB_ID, githubLogin: 'known-builder', displayName: null, avatarUrl: null },
|
|
146
|
+
{ signingKey: hubSigningKey },
|
|
147
|
+
);
|
|
148
|
+
const r = await logout({ logout_token: assertion });
|
|
149
|
+
assert.equal(r.status, 401);
|
|
150
|
+
assert.deepEqual(revokes, []);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('an expired token → 401 and no revoke', async () => {
|
|
154
|
+
const r = await logout({ logout_token: mintLogout({}, { nowMs: Date.now() - 10 * 60 * 1000 }) });
|
|
155
|
+
assert.equal(r.status, 401);
|
|
156
|
+
assert.match(r.json.error.message, /expired/);
|
|
157
|
+
assert.deepEqual(revokes, []);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('no token at all → 400 and no revoke', async () => {
|
|
161
|
+
const r = await logout({});
|
|
162
|
+
assert.equal(r.status, 400);
|
|
163
|
+
assert.deepEqual(revokes, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── a valid token: once, and only once ───────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
test('a valid token for a known builder → 200 and exactly one revoke, of that builder', async () => {
|
|
169
|
+
const r = await logout({ logout_token: mintLogout() });
|
|
170
|
+
assert.equal(r.status, 200);
|
|
171
|
+
assert.deepEqual(r.json, { ok: true });
|
|
172
|
+
assert.deepEqual(lookups, [String(KNOWN_GITHUB_ID)]);
|
|
173
|
+
assert.deepEqual(revokes, [KNOWN_BUILDER.id]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the same token again (same jti) → 200, and still only the one revoke', async () => {
|
|
177
|
+
const token = mintLogout();
|
|
178
|
+
const first = await logout({ logout_token: token });
|
|
179
|
+
const replay = await logout({ logout_token: token });
|
|
180
|
+
const replayAgain = await logout({ logout_token: token });
|
|
181
|
+
assert.equal(first.status, 200);
|
|
182
|
+
assert.equal(replay.status, 200, 'a replay is an idempotent no-op, not an error');
|
|
183
|
+
assert.equal(replay.raw, first.raw, 'and answers the same bytes');
|
|
184
|
+
assert.equal(replayAgain.status, 200);
|
|
185
|
+
assert.deepEqual(revokes, [KNOWN_BUILDER.id], 'a captured token cannot keep killing sessions made after it');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a fresh token for the same builder (a new jti) revokes again — the guard is per token, not per builder', async () => {
|
|
189
|
+
await logout({ logout_token: mintLogout() });
|
|
190
|
+
await logout({ logout_token: mintLogout() });
|
|
191
|
+
assert.deepEqual(revokes, [KNOWN_BUILDER.id, KNOWN_BUILDER.id]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a valid token for an unknown sub → 200, zero revokes, and the same body as the known case', async () => {
|
|
195
|
+
const known = await logout({ logout_token: mintLogout() });
|
|
196
|
+
revokes = [];
|
|
197
|
+
const unknown = await logout({ logout_token: mintLogout({ githubId: 999001 }) });
|
|
198
|
+
assert.equal(unknown.status, 200);
|
|
199
|
+
assert.deepEqual(revokes, []);
|
|
200
|
+
assert.equal(unknown.raw, known.raw, 'whether this builder exists here is never leaked');
|
|
201
|
+
});
|
|
@@ -223,7 +223,16 @@ await test('the ship hook cannot fail a ship, and the sweep exists to retry it',
|
|
|
223
223
|
assert.match(line, /auto-satisfy skipped \(sweep retries\)/, 'the failure is reported, not thrown');
|
|
224
224
|
assert.match(logged.join('\n'), /shipTask 5/, 'and it names the ship it happened on');
|
|
225
225
|
const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
|
|
226
|
-
|
|
226
|
+
// task 1004924: the tick runs the guarded SWEEPS list (fire-and-forget, a
|
|
227
|
+
// sweep still in flight is skipped), so the wiring is an entry there.
|
|
228
|
+
// task 1004965: an entry's third element is its cadence; these two have none, so
|
|
229
|
+
// they keep the every-tick cadence while promotion and the craft backfill go hourly.
|
|
230
|
+
assert.match(rec, /\['criterion sweep', runCriterionSweep\]/, 'the sweep is fire-and-forget on EVERY tick, like runCostSweep');
|
|
231
|
+
assert.match(rec, /\['cost sweep', runCostSweep\]/);
|
|
232
|
+
const reconciler = require('../modules/lifecycle/publish-reconciler.js');
|
|
233
|
+
const cadence = new Map(reconciler.SWEEPS.map(([name, , everyMs]) => [name, everyMs]));
|
|
234
|
+
assert.equal(cadence.get('criterion sweep'), undefined, 'the criterion sweep is not slowed to hourly');
|
|
235
|
+
assert.equal(cadence.get('cost sweep'), undefined, 'nor the cost sweep');
|
|
227
236
|
assert.match(rec, /BACKLOG DRAIN/, 'and documents why it is not merely belt-and-braces');
|
|
228
237
|
});
|
|
229
238
|
|
|
@@ -206,3 +206,147 @@ test('a backlog of rounds the lane must skip costs at most ten reads a pass', as
|
|
|
206
206
|
await lane.pickRound({ api, state: {}, matchProtected, readInventory: () => INV, readRound: async () => { reads++; return round(1); } });
|
|
207
207
|
assert.equal(reads, 10);
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// ---- land only, going live waits for a person (task 1004974) -----------------
|
|
211
|
+
//
|
|
212
|
+
// The unattended path may reach its finish line (WAITING FOR THE ARTIST; the
|
|
213
|
+
// artist's approval then lands it like any confirmed task) and must never be the
|
|
214
|
+
// one that asks for a live pin move. Proven here: the hand-off fence refuses a
|
|
215
|
+
// prompt or script list naming a pin door, and a scan of the text the worker
|
|
216
|
+
// actually follows finds none. The SERVER side (whether the pin doors refuse the
|
|
217
|
+
// worker's own session) is tests/autobongos_tweak_pin_identity.mjs.
|
|
218
|
+
|
|
219
|
+
const fence = require('../modules/autonomy/fence.js');
|
|
220
|
+
const fs = require('node:fs');
|
|
221
|
+
const path = require('node:path');
|
|
222
|
+
const ROOT = path.resolve(path.dirname(require('node:url').fileURLToPath(import.meta.url)), '..');
|
|
223
|
+
|
|
224
|
+
// One planted line per door: the fence must refuse each, and name it.
|
|
225
|
+
const PLANTED = [
|
|
226
|
+
['core-upgrade', 'node scripts/bongos/api.js POST /api/bongos/provisioning/instances/41/core-upgrade --body \'{"to":"1.19.1"}\''],
|
|
227
|
+
['core-upgrade', 'node scripts/bongos/provision-core-upgrade.js --instance 41'],
|
|
228
|
+
['update-channel', 'node scripts/bongos/api.js POST /api/bongos/provisioning/instances/41/update-channel'],
|
|
229
|
+
['update-channel', 'set the update channel to minor'],
|
|
230
|
+
['npm-release', 'node scripts/bongos/api.js POST /api/bongos/npm-release/release'],
|
|
231
|
+
['core.pin.move', 'you hold core.pin.move, so use it'],
|
|
232
|
+
['core-update', 'node scripts/bongos/api.js POST /api/bongos/software-update/request'],
|
|
233
|
+
['render-deploy', 'node scripts/bongos/api.js POST /api/bongos/render-deploy/act'],
|
|
234
|
+
['bongos upgrade', 'ssh host bongos upgrade --to 1.19.1'],
|
|
235
|
+
['pin move', 'then move the live pin so the artist sees it'],
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
test('the hand-off fence passes the real prompt, the real lane scripts and the real skill', () => {
|
|
239
|
+
const prompt = lane.buildTweakPrompt({ task: { id: 901 }, pageId: 'landing:index' });
|
|
240
|
+
const skillText = lane.readSkill();
|
|
241
|
+
const cmds = lane.skillCommands(skillText);
|
|
242
|
+
assert.ok(cmds.some((c) => /ship\.js/.test(c)) && cmds.some((c) => /tweak-halt\.js/.test(c)), 'the skill\'s command lines were found, so the scan is not vacuous');
|
|
243
|
+
assert.equal(lane.guardHandOff({ prompt, skillText }).go, true);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('the hand-off fence refuses a prompt or a script list that names a pin door', () => {
|
|
247
|
+
const prompt = lane.buildTweakPrompt({ task: { id: 901 }, pageId: 'landing:index' });
|
|
248
|
+
for (const [door, line] of PLANTED) {
|
|
249
|
+
const inScripts = fence.refusePinMove({ prompt, scripts: [...lane.LANE_SCRIPTS, line] });
|
|
250
|
+
assert.equal(inScripts.code, 'pin_move', `script list: ${line}`);
|
|
251
|
+
assert.ok(inScripts.doors.includes(door), `names ${door}: ${inScripts.doors}`);
|
|
252
|
+
assert.equal(fence.refusePinMove({ prompt: `${prompt}\n${line}`, scripts: [...lane.LANE_SCRIPTS] }).code, 'pin_move', `prompt: ${line}`);
|
|
253
|
+
const skill = `${lane.readSkill()}\n\`\`\`bash\n${line}\n\`\`\`\n`;
|
|
254
|
+
assert.equal(lane.guardHandOff({ prompt, skillText: skill }).code, 'pin_move', `skill command: ${line}`);
|
|
255
|
+
}
|
|
256
|
+
assert.equal(fence.refusePinMove({ prompt, scripts: null }).go, false, 'an unreadable list refuses');
|
|
257
|
+
assert.equal(fence.refusePinMove({ prompt: undefined, scripts: [] }).go, false, 'an unreadable prompt refuses');
|
|
258
|
+
assert.equal(lane.guardHandOff({ prompt, skillText: null }).go, false, 'an unreadable skill refuses');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('iteration: a planted pin move in the skill stops the round before the claim (no tree, no worker)', async () => {
|
|
262
|
+
const api = fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } });
|
|
263
|
+
for (const readSkill of [
|
|
264
|
+
() => `${lane.readSkill()}\n\`\`\`bash\n${PLANTED[0][1]}\n\`\`\`\n`,
|
|
265
|
+
() => { throw new Error('ENOENT'); },
|
|
266
|
+
]) {
|
|
267
|
+
const h = harness({ api });
|
|
268
|
+
h.deps.laneDeps.readSkill = readSkill;
|
|
269
|
+
const row = await runner.iteration(opts, h.deps);
|
|
270
|
+
assert.equal(row.event, 'fenced');
|
|
271
|
+
assert.equal(row.lane, 'tweak');
|
|
272
|
+
assert.equal(row.code, 'pin_move');
|
|
273
|
+
assert.deepEqual(h.calls, [], 'nothing ran: no worktree, no claim, no worker');
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('iteration: the unattended path reaches its finish line and nothing it ran names a pin door', async () => {
|
|
278
|
+
const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }) });
|
|
279
|
+
const row = await runner.iteration(opts, h.deps);
|
|
280
|
+
assert.equal(row.outcome, 'waiting_for_artist', 'held at a passed grade for the artist, whose approval lands it');
|
|
281
|
+
assert.ok(h.calls.length >= 3, 'the tree, the claim and the worker all ran');
|
|
282
|
+
for (const c of h.calls) {
|
|
283
|
+
const said = [c.script, ...(c.args || []), c.prompt || ''].join(' ');
|
|
284
|
+
assert.deepEqual(fence.pinMoveDoors(said), [], `${c.script} named a pin door`);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// The scan of what the worker follows. Two matchers, so a bug in the fence's own
|
|
289
|
+
// patterns cannot make this scan agree with it: the fence's, and a literal one.
|
|
290
|
+
const LITERAL_DOORS = /core[-_]upgrade|update[-_]channel|core\.pin\.move|software-update|render-deploy\/act|bongos\s+upgrade|move\s+the\s+(?:live\s+)?pin/i;
|
|
291
|
+
const scanned = () => [
|
|
292
|
+
['the tweak skill (its unattended path follows every step)', fs.readFileSync(path.join(ROOT, lane.SKILL_PATH), 'utf8')],
|
|
293
|
+
['the tweak lane', fs.readFileSync(path.join(ROOT, 'scripts', 'bongos', 'autobongos-tweak-lane.js'), 'utf8')],
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
test('the unattended skill and the lane name no pin door', () => {
|
|
297
|
+
for (const [what, text] of scanned()) {
|
|
298
|
+
assert.ok(text.length > 1000, `${what} was read`);
|
|
299
|
+
assert.deepEqual(fence.pinMoveDoors(text), [], `${what} names a pin door`);
|
|
300
|
+
assert.equal(LITERAL_DOORS.exec(text), null, `${what} names a pin door`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// The fence's list is named doors, so it is kept honest from the code: every
|
|
305
|
+
// MUTATING route gated on core.pin.move (directly, or through a variable holding
|
|
306
|
+
// that gate) or on the owner-or-pin gate must be matched by a pattern. A new pin
|
|
307
|
+
// door added anywhere reds this until fence.js names it.
|
|
308
|
+
function pinGatedMutatingRoutes() {
|
|
309
|
+
const files = [];
|
|
310
|
+
const modulesDir = path.join(ROOT, 'modules');
|
|
311
|
+
for (const m of fs.readdirSync(modulesDir)) {
|
|
312
|
+
const dir = path.join(modulesDir, m, 'routes');
|
|
313
|
+
if (fs.existsSync(dir)) for (const f of fs.readdirSync(dir)) if (f.endsWith('.js')) files.push(path.join(dir, f));
|
|
314
|
+
}
|
|
315
|
+
const coreRoutes = path.join(ROOT, 'src', 'bongos', 'routes');
|
|
316
|
+
for (const f of fs.readdirSync(coreRoutes)) if (f.endsWith('.js')) files.push(path.join(coreRoutes, f));
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
320
|
+
// Variables that hold the atom gate (npm-release's `mayPreview`, say).
|
|
321
|
+
const gateVars = [...src.matchAll(/(?:const|let)\s+(\w+)\s*=\s*[\w.]*requirePermission\(\s*'core\.pin\.move'\s*\)/g)].map((m) => m[1]);
|
|
322
|
+
const calls = [...src.matchAll(/router\.(post|put|patch|delete)\(/g)];
|
|
323
|
+
calls.forEach((m, i) => {
|
|
324
|
+
const body = src.slice(m.index, i + 1 < calls.length ? calls[i + 1].index : src.length).split(/\n\s*router\.(?:get|use)\(/)[0];
|
|
325
|
+
const gated = /requirePermission\(\s*'core\.pin\.move'\s*\)|requireOwnProjectOrPin/.test(body)
|
|
326
|
+
|| gateVars.some((v) => new RegExp(`\\b${v}\\b`).test(body));
|
|
327
|
+
const route = /\(\s*['`]([^'`]+)['`]/.exec(body);
|
|
328
|
+
if (gated && route) out.push({ file: path.relative(ROOT, file), method: m[1], route: route[1] });
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
test('every mutating route behind the pin gates is a door the fence names', () => {
|
|
335
|
+
const routes = pinGatedMutatingRoutes();
|
|
336
|
+
const seen = routes.map((r) => r.route);
|
|
337
|
+
for (const known of ['/provisioning/instances/:id/core-upgrade', '/provisioning/instances/:id/update-channel', '/render-deploy/act', '/npm-release/release']) {
|
|
338
|
+
assert.ok(seen.includes(known), `the enumeration found ${known} (so it is not vacuous): ${seen.join(', ')}`);
|
|
339
|
+
}
|
|
340
|
+
for (const r of routes) {
|
|
341
|
+
assert.notDeepEqual(fence.pinMoveDoors(`POST /api/bongos${r.route}`), [], `${r.method.toUpperCase()} ${r.route} (${r.file}) is a pin door the fence does not name`);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('the scan fails on a planted pin move in either file', () => {
|
|
346
|
+
for (const [what, text] of scanned()) {
|
|
347
|
+
for (const [door, line] of PLANTED) {
|
|
348
|
+
assert.ok(fence.pinMoveDoors(`${text}\n${line}\n`).includes(door), `${what}: ${door} not caught`);
|
|
349
|
+
}
|
|
350
|
+
assert.ok(LITERAL_DOORS.test(`${text}\n${PLANTED[0][1]}\n`), `${what}: the literal scan misses core-upgrade`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// tests/autobongos_tweak_pin_identity.mjs — does a live pin door refuse the
|
|
2
|
+
// unattended worker's own session? (task 1004974)
|
|
3
|
+
//
|
|
4
|
+
// "Land only. Ask before going live." The lane never ASKS for a pin move
|
|
5
|
+
// (tests/autobongos_tweak_lane.mjs proves the hand-off fence and the scan). This
|
|
6
|
+
// file asks the other half: if a worker wandered past its prompt, would the
|
|
7
|
+
// server stop it?
|
|
8
|
+
//
|
|
9
|
+
// THE WORKER'S IDENTITY. autobongos-run.js hands a worker HOME and USERPROFILE
|
|
10
|
+
// (WORKER_ENV_ALLOW), so the worker reads the runner builder's own CLI session
|
|
11
|
+
// file and calls the instance as that builder, with a `cli` session — the same
|
|
12
|
+
// token the builder uses at a terminal. Nothing on the request says "unattended".
|
|
13
|
+
//
|
|
14
|
+
// What runs here is REAL: auth.requireBuilder resolving a session token, the
|
|
15
|
+
// provisioning router, its owner-or-pin gate, the act-alone gate and the handler.
|
|
16
|
+
// Only the session lookup, the permission resolver and the instance rows are
|
|
17
|
+
// doubles.
|
|
18
|
+
//
|
|
19
|
+
// THE KNOWN GAP. The refusals the owner's rule asks for are `todo` tests: today
|
|
20
|
+
// the owner's runner session is ADMITTED by the owner door, and an Archon
|
|
21
|
+
// runner's by the core.pin.move atom, so a worker running as either could queue
|
|
22
|
+
// a live move. Filed as private security report 1000030 (task 1004974); the
|
|
23
|
+
// todo tests turn into ordinary tests when the fix lands. The control test below
|
|
24
|
+
// proves this harness CAN see a refusal, so a todo failing here is the gap and
|
|
25
|
+
// not a broken harness.
|
|
26
|
+
//
|
|
27
|
+
// Run: node --test tests/autobongos_tweak_pin_identity.mjs
|
|
28
|
+
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import { test, after } from 'node:test';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
|
|
35
|
+
// The session double must be installed BEFORE auth.js loads (it destructures
|
|
36
|
+
// lookupSession at require time — the tests/core_upgrade_owner_door.mjs precedent).
|
|
37
|
+
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
38
|
+
// The runner builder is Metic+ (the lane's floor). 7 owns the hosted project 41;
|
|
39
|
+
// 1 is the hub's Archon, who owns nothing hosted here; 8 is a stranger.
|
|
40
|
+
const SESSIONS = {
|
|
41
|
+
ownerrunner: { builder_id: 7, github_id: '9001', rank: 'metic', source: 'cli', status: 'active' },
|
|
42
|
+
archonrunner: { builder_id: 1, github_id: '9000', rank: 'archon', source: 'cli', status: 'active' },
|
|
43
|
+
strangerrunner: { builder_id: 8, github_id: '9002', rank: 'metic', source: 'cli', status: 'active' },
|
|
44
|
+
};
|
|
45
|
+
dbKernel.lookupSession = async (token) => SESSIONS[token] || null;
|
|
46
|
+
|
|
47
|
+
const seams = require('../src/module-seams.js');
|
|
48
|
+
const catalog = require('../modules/government/catalog.js');
|
|
49
|
+
const api = require('../src/module-api.js');
|
|
50
|
+
const prov = require('../modules/provisioning/provisioning.js');
|
|
51
|
+
const runner = require('../scripts/bongos/autobongos-run.js');
|
|
52
|
+
|
|
53
|
+
const TENANT = Object.freeze({ id: 41, slug: 'hosted-shop', status: 'active', hosting_shape: 'cloud-host', owner_builder_id: 7, update_channel: 'pinned' });
|
|
54
|
+
const ROWS = new Map([[41, TENANT]]);
|
|
55
|
+
|
|
56
|
+
seams._reset();
|
|
57
|
+
seams.registerProvider('government', {
|
|
58
|
+
resolveBuilderPermissions: async (id) => catalog.seedForRank(Object.values(SESSIONS).find((s) => s.builder_id === id).rank),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const queued = [];
|
|
62
|
+
const saved = { get: prov.getInstanceById, enq: prov.enqueueIntent, rec: prov.recordEvent, pool: Object.getOwnPropertyDescriptor(api, 'pool') };
|
|
63
|
+
prov.getInstanceById = async (_db, n) => ROWS.get(Number(n)) || null;
|
|
64
|
+
prov.enqueueIntent = async (_db, id, action, requestedBy, targetVersion) => {
|
|
65
|
+
queued.push({ id, action, requestedBy, targetVersion });
|
|
66
|
+
return { intent: { id: queued.length, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
67
|
+
};
|
|
68
|
+
prov.recordEvent = async () => {};
|
|
69
|
+
const channelWrites = [];
|
|
70
|
+
Object.defineProperty(api, 'pool', { value: { query: async (sql, params) => { channelWrites.push(params); return { rows: [] }; } }, configurable: true, writable: true });
|
|
71
|
+
|
|
72
|
+
const routePath = require.resolve('../modules/provisioning/routes/core-upgrade.js');
|
|
73
|
+
delete require.cache[routePath];
|
|
74
|
+
const express = require('express');
|
|
75
|
+
const app = express();
|
|
76
|
+
app.use(express.json());
|
|
77
|
+
app.use((req, res, next) => { require('../src/bongos/middleware/error-envelope.js').attachFail(req, res, next); });
|
|
78
|
+
app.use(require(routePath)());
|
|
79
|
+
const server = app.listen(0);
|
|
80
|
+
await new Promise((r) => server.once('listening', r));
|
|
81
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
82
|
+
|
|
83
|
+
after(() => {
|
|
84
|
+
server.close();
|
|
85
|
+
prov.getInstanceById = saved.get; prov.enqueueIntent = saved.enq; prov.recordEvent = saved.rec;
|
|
86
|
+
Object.defineProperty(api, 'pool', saved.pool);
|
|
87
|
+
delete require.cache[routePath];
|
|
88
|
+
seams._reset();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// A real live move: no dry_run, a named target.
|
|
92
|
+
const move = (token, id = 41) => fetch(`${base}/provisioning/instances/${id}/core-upgrade`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
95
|
+
body: JSON.stringify({ to: '1.19.1033' }),
|
|
96
|
+
});
|
|
97
|
+
const channel = (token, id = 41) => fetch(`${base}/provisioning/instances/${id}/update-channel`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
100
|
+
body: JSON.stringify({ channel: 'minor' }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the worker carries its builder\'s own CLI session: the home directories pass through', () => {
|
|
104
|
+
const env = runner.workerEnv({ HOME: '/home/runner', USERPROFILE: 'C:\\Users\\runner', DATABASE_URL: 'postgres://x' });
|
|
105
|
+
assert.equal(env.HOME, '/home/runner');
|
|
106
|
+
assert.equal(env.USERPROFILE, 'C:\\Users\\runner');
|
|
107
|
+
assert.equal(env.DATABASE_URL, undefined, 'the allowlist is live, so the home dirs are deliberate');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('control: a stranger\'s runner session is refused a live move, so this harness can see a refusal', async () => {
|
|
111
|
+
const r = await move('strangerrunner');
|
|
112
|
+
assert.equal(r.status, 403);
|
|
113
|
+
assert.equal(queued.length, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const GAP = 'known gap, task 1004974: the server cannot tell an unattended worker from its builder at a terminal';
|
|
117
|
+
|
|
118
|
+
test('the OWNER\'s runner session is refused a live move on the owner\'s own project', { todo: GAP }, async () => {
|
|
119
|
+
const before = queued.length;
|
|
120
|
+
const r = await move('ownerrunner');
|
|
121
|
+
assert.equal(r.status, 403, `a worker running as the owner queued a live move (status ${r.status})`);
|
|
122
|
+
assert.equal(queued.length, before);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the OWNER\'s runner session is refused a change to the project\'s update rule', { todo: GAP }, async () => {
|
|
126
|
+
const before = channelWrites.length;
|
|
127
|
+
const r = await channel('ownerrunner');
|
|
128
|
+
assert.equal(r.status, 403, `a worker running as the owner changed which moves the runner makes (status ${r.status})`);
|
|
129
|
+
assert.equal(channelWrites.length, before);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('an ARCHON runner session is refused a live move on a hosted project', { todo: GAP }, async () => {
|
|
133
|
+
const before = queued.length;
|
|
134
|
+
const r = await move('archonrunner');
|
|
135
|
+
assert.equal(r.status, 403, `a worker running as the Archon queued a live move (status ${r.status})`);
|
|
136
|
+
assert.equal(queued.length, before);
|
|
137
|
+
});
|
|
@@ -16,7 +16,7 @@ import { makeRunner } from './helpers.mjs';
|
|
|
16
16
|
|
|
17
17
|
const require = createRequire(import.meta.url);
|
|
18
18
|
const {
|
|
19
|
-
CLASSES, DEDUP_FIX_AT, classifySession, buildBackfillPlan, renderReport,
|
|
19
|
+
CLASSES, CLASS_NOTE, DEDUP_FIX_AT, classifySession, buildBackfillPlan, renderReport,
|
|
20
20
|
} = require('../scripts/bongos/backfill-subagent-rewards.js');
|
|
21
21
|
const {
|
|
22
22
|
computeSessionTokenReward, sessionTrueCostUsd, awardSessionTokenReward,
|
|
@@ -105,6 +105,22 @@ await test('no_subagent_record: no cost row means unrecoverable — never guesse
|
|
|
105
105
|
assert.equal(r.subagentUsd, undefined, 'no invented figure');
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
// Task 1003640: most sessions in this bucket simply never ran a subagent, so the
|
|
109
|
+
// reason must lead with that ordinary case, and name the failed-hook case only as
|
|
110
|
+
// the rarer possibility the data cannot tell apart, never as the cause.
|
|
111
|
+
await test('no_subagent_record: the reason does not blame a failed cost hook for a session that used no subagent', () => {
|
|
112
|
+
const note = CLASS_NOTE[CLASSES.NO_SUBAGENT_RECORD];
|
|
113
|
+
assert.match(note, /^[^.]*no subagent/i, 'the ordinary case (no subagent used) is named first');
|
|
114
|
+
assert.match(note, /nothing (is )?owed/i, 'says the ordinary case owes nothing');
|
|
115
|
+
assert.doesNotMatch(note, /: the cost hook never ran/, 'does not assert the hook failure as the cause');
|
|
116
|
+
assert.match(note, /cannot (be )?tell|cannot tell|indistinguishable|same trace/i, 'says the two cases look the same in the data');
|
|
117
|
+
const plan = buildBackfillPlan([row({ subagent_usd: null, subagent_recorded_at: null })],
|
|
118
|
+
{ since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
|
|
119
|
+
const { text } = renderReport(plan);
|
|
120
|
+
assert.doesNotMatch(text, /Sessions whose cost hook never ran left no trace at all/,
|
|
121
|
+
'the closing summary no longer reads every no-record session as a lost hook');
|
|
122
|
+
});
|
|
123
|
+
|
|
108
124
|
await test('pre_split: a legacy single `session` row lumps both legs and cannot be separated', () => {
|
|
109
125
|
const r = classifySession(
|
|
110
126
|
row({ subagent_usd: null, subagent_recorded_at: null, has_pre_split_cost_row: true }), OPTS
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/backup_trigger_lock.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004924 — POST /backup/trigger runs a ~2 minute bash dump. Two at once on
|
|
4
|
+
// one disk is the thing to prevent, so the route holds a single-flight lock: a
|
|
5
|
+
// second trigger while one runs is 409 backup_already_running, and the lock frees
|
|
6
|
+
// when the dump finishes. The lock existed but nothing pinned it; and a spawn
|
|
7
|
+
// that threw synchronously never reached the callback, so the lock stuck and
|
|
8
|
+
// every later trigger 409'd until a restart.
|
|
9
|
+
//
|
|
10
|
+
// Hermetic: the auth gates are stubbed (the rank gate itself is tested in its own
|
|
11
|
+
// suites), and execFile is the router's injected seam — no real dump runs.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/backup_trigger_lock.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import { makeRunner } from './helpers.mjs';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const express = require('express');
|
|
22
|
+
const auth = require('../src/bongos/auth.js');
|
|
23
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
24
|
+
|
|
25
|
+
// Stub the gates BEFORE building: the router reads them at build time.
|
|
26
|
+
const pass = (req, _res, next) => { req.builder = { id: 'b-test' }; next(); };
|
|
27
|
+
auth.requireBuilder = pass;
|
|
28
|
+
auth.requirePermission = () => (_req, _res, next) => next();
|
|
29
|
+
|
|
30
|
+
const buildBackupRouter = require('../src/bongos/routes/backup.js');
|
|
31
|
+
const { test, summary } = makeRunner();
|
|
32
|
+
|
|
33
|
+
async function withServer(execFile, fn) {
|
|
34
|
+
const app = express();
|
|
35
|
+
app.use(attachFail);
|
|
36
|
+
app.use(buildBackupRouter({ execFile }));
|
|
37
|
+
const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
|
|
38
|
+
const port = server.address().port;
|
|
39
|
+
// node:http with no keep-alive agent, not fetch: fetch's pooled sockets are
|
|
40
|
+
// still closing when summary() calls process.exit, which aborts on Windows
|
|
41
|
+
// (libuv UV_HANDLE_CLOSING assertion) after every case has passed.
|
|
42
|
+
const trigger = () => new Promise((resolve, reject) => {
|
|
43
|
+
const req = http.request({ host: '127.0.0.1', port, path: '/backup/trigger', method: 'POST', agent: false }, (res) => {
|
|
44
|
+
let data = '';
|
|
45
|
+
res.on('data', (c) => { data += c; });
|
|
46
|
+
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(data) }));
|
|
47
|
+
});
|
|
48
|
+
req.on('error', reject);
|
|
49
|
+
req.end();
|
|
50
|
+
});
|
|
51
|
+
try { await fn(trigger); } finally { await new Promise((r) => server.close(r)); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await test('a second trigger while a dump runs is 409; once it finishes the next one starts', async () => {
|
|
55
|
+
const spawned = [];
|
|
56
|
+
const execFile = (cmd, args, opts, cb) => { spawned.push(cb); };
|
|
57
|
+
await withServer(execFile, async (trigger) => {
|
|
58
|
+
assert.equal((await trigger()).status, 202);
|
|
59
|
+
const second = await trigger();
|
|
60
|
+
assert.equal(second.status, 409);
|
|
61
|
+
assert.equal(second.body.error.code, 'backup_already_running');
|
|
62
|
+
assert.equal(spawned.length, 1, 'never two dumps at once');
|
|
63
|
+
|
|
64
|
+
spawned[0](null, '', ''); // the dump finishes
|
|
65
|
+
assert.equal((await trigger()).status, 202);
|
|
66
|
+
assert.equal(spawned.length, 2);
|
|
67
|
+
spawned[1](new Error('pg_dump failed'), '', 'boom'); // a FAILED dump frees the lock too
|
|
68
|
+
assert.equal((await trigger()).status, 202);
|
|
69
|
+
spawned[2](null, '', '');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('a spawn that throws synchronously frees the lock instead of wedging it', async () => {
|
|
74
|
+
let calls = 0;
|
|
75
|
+
let throwIt = true;
|
|
76
|
+
const execFile = (cmd, args, opts, cb) => {
|
|
77
|
+
calls++;
|
|
78
|
+
if (throwIt) throw new Error('spawn EAGAIN');
|
|
79
|
+
setImmediate(() => cb(null, '', ''));
|
|
80
|
+
};
|
|
81
|
+
await withServer(execFile, async (trigger) => {
|
|
82
|
+
const first = await trigger();
|
|
83
|
+
assert.equal(first.status, 500);
|
|
84
|
+
assert.equal(first.body.error.code, 'backup_start_failed');
|
|
85
|
+
throwIt = false;
|
|
86
|
+
const second = await trigger();
|
|
87
|
+
assert.equal(second.status, 202, 'the lock was released, so a retry starts a dump');
|
|
88
|
+
assert.equal(calls, 2);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// task 1004966 — the in-memory flag above cannot see the nightly timer, which runs
|
|
93
|
+
// the script outside this process. The cross-process guard is the flock INSIDE
|
|
94
|
+
// db-backup-nightly.sh, so it only covers the manual trigger if the trigger runs
|
|
95
|
+
// that very script — the one the systemd unit's ExecStart names.
|
|
96
|
+
await test('the trigger runs the same script as the nightly unit, so both take its flock', async () => {
|
|
97
|
+
const fs = require('node:fs');
|
|
98
|
+
const path = require('node:path');
|
|
99
|
+
const { BACKUP_SCRIPT, SKIPPED_MARK } = buildBackupRouter;
|
|
100
|
+
const root = path.resolve(path.dirname(require('node:url').fileURLToPath(import.meta.url)), '..');
|
|
101
|
+
const script = path.join(root, 'scripts', 'bongos', 'db-backup-nightly.sh');
|
|
102
|
+
assert.equal(path.resolve(BACKUP_SCRIPT), path.resolve(script));
|
|
103
|
+
const unit = fs.readFileSync(path.join(root, 'scripts', 'bongos', 'gds-db-backup.service'), 'utf8');
|
|
104
|
+
assert.match(unit, /^ExecStart=.*\/db-backup-nightly\.sh\s*$/m, 'the timer runs the same script');
|
|
105
|
+
const src = fs.readFileSync(script, 'utf8');
|
|
106
|
+
assert.match(src, /flock -n 9/, 'the script takes the lock');
|
|
107
|
+
assert.ok(src.includes(SKIPPED_MARK), 'the script prints the skip line the trigger listens for');
|
|
108
|
+
|
|
109
|
+
const runs = [];
|
|
110
|
+
const execFile = (cmd, args, opts, cb) => { runs.push([cmd, args]); setImmediate(() => cb(null, `${SKIPPED_MARK} another backup is running`, '')); };
|
|
111
|
+
await withServer(execFile, async (trigger) => {
|
|
112
|
+
assert.equal((await trigger()).status, 202);
|
|
113
|
+
await new Promise((r) => setImmediate(r));
|
|
114
|
+
assert.deepEqual(runs[0], ['bash', [BACKUP_SCRIPT]]);
|
|
115
|
+
assert.equal((await trigger()).status, 202, 'a skipped run frees the in-memory flag too');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
summary();
|