@bongos/core 1.23.22 → 1.24.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +933 -413
- package/.claude/skills/blocker-review/SKILL.md +9 -1
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +1 -1
- package/.claude/skills/builder-redteam/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/.claude/skills/collab-review/SKILL.md +5 -2
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +2 -2
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/owner-review/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +11 -11
- package/.claude/skills/read-session-export/SKILL.md +1 -1
- package/.claude/skills/recall/SKILL.md +1 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/strand-fix/SKILL.md +13 -0
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/AGENTS.md +15 -0
- package/GEMINI.md +5 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +96 -0
- package/clients/bongos-client/index.cjs +96 -0
- package/clients/bongos-client/index.d.ts +155 -4
- package/clients/bongos-client/index.mjs +96 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
- package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
- package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
- package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
- package/docs/adr/README.md +6 -2
- package/docs/api/openapi.json +8651 -5833
- package/docs/api-reference.md +66 -10
- package/docs/architecture.md +37 -1
- package/docs/canonical-permissions.md +1 -1
- package/docs/copy-inventory.md +427 -339
- package/docs/copy-registry.json +1376 -500
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +7 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
- package/docs/onboarding/diagrams/README.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +15 -15
- package/docs/onboarding/primer.md +2 -2
- package/docs/page-inventory.json +57 -7
- package/docs/page-readings.json +1210 -1027
- package/docs/recipes/google-sign-in.md +60 -0
- package/docs/recipes/ops-gotchas.md +1 -1
- package/migrations/core_282_builders_lower_login_idx.sql +33 -0
- package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
- package/migrations/core_283_payout_approvals.sql +183 -0
- package/migrations/core_284_payout_gates.sql +179 -0
- package/migrations/core_285_task_needs.sql +74 -0
- package/modules/ai-studio/billing.js +67 -0
- package/modules/ai-studio/context.js +29 -0
- package/modules/ai-studio/db.js +172 -0
- package/modules/ai-studio/gemini.js +75 -0
- package/modules/ai-studio/generate.js +119 -0
- package/modules/ai-studio/keys.js +73 -0
- package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
- package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
- package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
- package/modules/ai-studio/openrouter.js +90 -0
- package/modules/ai-studio/payments.js +105 -0
- package/modules/ai-studio/pricing.js +92 -0
- package/modules/ai-studio/routes/ai-studio.js +235 -5
- package/modules/ai-studio/studio.js +121 -0
- package/modules/autonomy/fence.js +64 -1
- package/modules/builder-settings/builder-needs.js +27 -1
- package/modules/copy-desk/routes/copy-desk.js +5 -0
- package/modules/economy/achievements.js +22 -10
- package/modules/economy/cost-classify.js +9 -0
- package/modules/economy/cost-write-guard.js +52 -0
- package/modules/economy/cost.js +2 -1
- package/modules/economy/credits.js +18 -15
- package/modules/economy/goal-reward.js +161 -0
- package/modules/economy/reward.js +4 -0
- package/modules/economy/routes/cost.js +33 -7
- package/modules/government/access-routes.js +70 -0
- package/modules/government/access.js +88 -0
- package/modules/government/asks.js +5 -2
- package/modules/government/catalog.js +6 -0
- package/modules/government/db.js +72 -0
- package/modules/government/docket.js +48 -2
- package/modules/government/migrations/government_035_payout_approve.sql +29 -0
- package/modules/government/migrations/government_036_access_holders.sql +33 -0
- package/modules/government/pollers/board-expiry.js +108 -20
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/government/routes/government.js +31 -0
- package/modules/government/routing.js +49 -7
- package/modules/government/waiting.js +78 -0
- package/modules/hall-ui/public/ai-studio-lib.js +268 -0
- package/modules/hall-ui/public/ai-studio.css +193 -0
- package/modules/hall-ui/public/ai-studio.html +98 -0
- package/modules/hall-ui/public/ai-studio.js +556 -0
- package/modules/hall-ui/public/ai-studio.states.json +94 -0
- package/modules/hall-ui/public/blockers-by-person.js +158 -0
- package/modules/hall-ui/public/blockers-lib.js +15 -1
- package/modules/hall-ui/public/blockers.html +8 -0
- package/modules/hall-ui/public/blockers.js +1 -1
- package/modules/hall-ui/public/claim-action.js +3 -0
- package/modules/hall-ui/public/collab.html +12 -0
- package/modules/hall-ui/public/craft-mode.js +4 -1
- package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals-render.js +4 -0
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/gov-living.js +6 -2
- package/modules/hall-ui/public/government-access.js +108 -0
- package/modules/hall-ui/public/government.html +21 -0
- package/modules/hall-ui/public/hall-ask.css +5 -0
- package/modules/hall-ui/public/hall-ask.js +39 -3
- package/modules/hall-ui/public/notes-panel.css +20 -0
- package/modules/hall-ui/public/notes-panel.js +146 -0
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +7 -5
- package/modules/hall-ui/public/settings-ai-keys.js +124 -0
- package/modules/hall-ui/public/settings.html +15 -0
- package/modules/hall-ui/public/street-deck.js +12 -3
- package/modules/hall-ui/public/studio.css +90 -8
- package/modules/hall-ui/public/studio.html +39 -10
- package/modules/hall-ui/public/studio.js +148 -18
- package/modules/hall-ui/public/task.html +2 -0
- package/modules/hall-ui/public/task.js +4 -0
- package/modules/hall-ui/public/tweak-editor.js +12 -3
- package/modules/hall-ui/public/welcome-boot.css +42 -6
- package/modules/hall-ui/public/welcome-boot.js +20 -6
- package/modules/hall-ui/public/work.js +20 -0
- package/modules/hall-ui/records/ai-studio.md +36 -0
- package/modules/hall-ui/records/blockers-people.md +38 -0
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +18 -5
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/done-when.js +23 -1
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/notes.js +219 -0
- package/modules/lifecycle/page-notice.js +124 -0
- package/modules/lifecycle/page-tweak-artist.js +18 -1
- package/modules/lifecycle/publish-reconciler.js +67 -20
- package/modules/lifecycle/routes/claims.js +5 -2
- package/modules/lifecycle/routes/goals.js +3 -1
- package/modules/lifecycle/routes/notes.js +91 -0
- package/modules/lifecycle/routes/project-settings.js +43 -35
- package/modules/lifecycle/routes/task-route-helpers.js +21 -0
- package/modules/lifecycle/routes/task-write-routes.js +21 -3
- package/modules/lifecycle/routes/tasks.js +12 -12
- package/modules/lifecycle/task-needs-gate.js +70 -0
- package/modules/market-watch/CLAUDE.md +15 -0
- package/modules/market-watch/db.js +340 -0
- package/modules/market-watch/key.js +58 -0
- package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
- package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
- package/modules/market-watch/module.json +26 -0
- package/modules/market-watch/pollers/hub-seed.js +54 -0
- package/modules/market-watch/pollers/weekly-run.js +100 -0
- package/modules/market-watch/research.js +350 -0
- package/modules/market-watch/run.js +245 -0
- package/modules/market-watch/seed/market-data.json +38 -0
- package/modules/market-watch/seed/partners-data.json +497 -0
- package/modules/market-watch/seed.js +252 -0
- package/modules/market-watch/store.js +230 -0
- package/modules/memory/routes/memory.js +14 -2
- package/modules/memory/routes/search.js +8 -2
- package/modules/platform-identity/account-logins.js +192 -0
- package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
- package/modules/platform-identity/platform-identity.js +5 -4
- package/modules/platform-identity/routes/my-projects.js +6 -0
- package/modules/platform-identity/routes/profile.js +18 -0
- package/modules/platform-identity/routes/public-profile.js +10 -1
- package/modules/platform-identity/routes/sso.js +26 -1
- package/modules/platform-identity/tests/platform-identity.mjs +2 -1
- package/modules/provisioning/credential-preflight.js +13 -3
- package/modules/provisioning/pollers/app-liveness.js +25 -5
- package/modules/provisioning/routes/provisioning.js +1 -1
- package/modules/provisioning/starter-bundles.js +6 -2
- package/modules/public-landing/public/account.html +10 -2
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
- package/modules/public-landing/public/community.html +1 -1
- package/modules/public-landing/public/connections.html +2 -2
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +2 -2
- package/modules/public-landing/public/projects.html +5 -5
- package/modules/raise/views/nda.html +2 -0
- package/modules/specialities/db.js +32 -1
- package/modules/specialities/routes/specialities.js +10 -6
- package/modules/status-ui/public/index.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +230 -0
- package/scripts/bongos/autobongos-tweak-lane.js +46 -1
- package/scripts/bongos/backfill-subagent-rewards.js +2 -2
- package/scripts/bongos/claim.js +22 -1
- package/scripts/bongos/db-backup-nightly.sh +29 -0
- package/scripts/bongos/deploy-divergence.js +7 -0
- package/scripts/bongos/doc-cli-guard.js +6 -0
- package/scripts/bongos/fitness.js +3 -2
- package/scripts/bongos/gen-diagrams.js +3 -3
- package/scripts/bongos/government-walk.js +3 -0
- package/scripts/bongos/lib.sh +4 -5
- package/scripts/bongos/migration-namespace.js +13 -1
- package/scripts/bongos/plain-cards.js +4 -0
- package/scripts/bongos/provision-github-setup.js +3 -1
- package/scripts/bongos/provision-units.js +6 -1
- package/scripts/bongos/publish-manifest.js +3 -0
- package/scripts/bongos/release.js +2 -0
- package/scripts/bongos/run-unit-tests.js +268 -82
- package/scripts/bongos/seed-tasks.example.json +56 -0
- package/scripts/bongos/seed-tasks.js +222 -0
- package/scripts/bongos/seed-verify-criteria.js +1 -2
- package/scripts/bongos/ship-finish.js +2 -0
- package/scripts/bongos/ship-resume.js +2 -0
- package/scripts/bongos/strand-lease.js +250 -0
- package/scripts/bongos/strand-watch.js +26 -5
- package/scripts/bongos/version-literal-guard.js +2 -2
- package/scripts/discord/post.js +4 -3
- package/scripts/gds/seed-tasks.js +7 -0
- package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/auth-config.js +29 -1
- package/src/bongos/auth-discord.js +3 -3
- package/src/bongos/auth-github.js +3 -6
- package/src/bongos/auth-google.js +217 -0
- package/src/bongos/device-approval.js +140 -0
- package/src/bongos/llm-pricing.js +17 -1
- package/src/bongos/metrics.js +48 -2
- package/src/bongos/middleware/rate-limit.js +30 -11
- package/src/bongos/module-scope-map.js +12 -1
- package/src/bongos/optimizer.js +15 -1
- package/src/bongos/payout-approvals.js +342 -0
- package/src/bongos/payout-gates.js +364 -0
- package/src/bongos/prelaunch-gate.js +7 -9
- package/src/bongos/prelaunch-switch.js +20 -0
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/auth-google.js +246 -0
- package/src/bongos/routes/auth.js +38 -1
- package/src/bongos/routes/backup.js +33 -9
- package/src/bongos/routes/builders.js +57 -30
- package/src/bongos/routes/github.js +9 -0
- package/src/bongos/routes/me.js +18 -0
- package/src/bongos/routes/my-payouts.js +133 -0
- package/src/bongos/routes/payout-approvals.js +312 -0
- package/src/bongos/routes/payout-gates.js +179 -0
- package/src/bongos/routes.js +15 -1
- package/src/bongos/secret-box.js +18 -3
- package/src/bongos/serve-internal.js +47 -19
- package/src/bongos/sign-in-pages.js +138 -0
- package/src/bongos/task-needs.js +146 -0
- package/src/bongos-downloads.js +18 -1
- package/src/instance-config.js +28 -0
- package/src/module-api.js +14 -1
- package/tests/access_holders.mjs +214 -0
- package/tests/account_logins.mjs +326 -0
- package/tests/account_logins_db.mjs +161 -0
- package/tests/ai_studio_billing.mjs +253 -0
- package/tests/ai_studio_generate.mjs +282 -0
- package/tests/ai_studio_keys.mjs +159 -0
- package/tests/ai_studio_requests.mjs +319 -0
- package/tests/auth_backchannel_logout_route.mjs +201 -0
- package/tests/auto_satisfy_criteria.mjs +10 -1
- package/tests/autobongos_tweak_lane.mjs +144 -0
- package/tests/autobongos_tweak_pin_identity.mjs +137 -0
- package/tests/backfill_subagent_rewards.mjs +17 -1
- package/tests/backup_trigger_lock.mjs +119 -0
- package/tests/blockers_people.mjs +198 -0
- package/tests/bounded_rate_maps.mjs +184 -0
- package/tests/claimable_blocker_status.mjs +1 -1
- package/tests/claims_batch_routes.mjs +421 -0
- package/tests/config_read_cache.mjs +171 -0
- package/tests/copy_desk_page_editor.mjs +12 -0
- package/tests/cost_write_guard.mjs +200 -0
- package/tests/craft_achievements.mjs +33 -10
- package/tests/criterion_goal_attach.mjs +1 -1
- package/tests/db_backup_nightly.mjs +73 -1
- package/tests/deploy_divergence_line.mjs +7 -0
- package/tests/effective_visibility_predicate.mjs +12 -0
- package/tests/fitness.mjs +19 -0
- package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
- package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
- package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
- package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
- package/tests/github_one_trip.mjs +34 -0
- package/tests/github_repo_visibility.mjs +20 -0
- package/tests/goal_achievement.mjs +30 -1
- package/tests/goal_achievement_reward.mjs +205 -0
- package/tests/goal_sort_staleness.mjs +2 -1
- package/tests/goal_tier.mjs +11 -1
- package/tests/google_fonts_nonblocking.mjs +84 -0
- package/tests/google_sign_in.mjs +401 -0
- package/tests/government_board_expiry.mjs +94 -0
- package/tests/government_board_membership.mjs +4 -1
- package/tests/government_protected_surfaces.mjs +1 -1
- package/tests/government_routes.mjs +1 -1
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_studio_home.mjs +61 -2
- package/tests/home_attention_cards.mjs +1 -1
- package/tests/human_signal.mjs +141 -0
- package/tests/install_script_reader.mjs +51 -0
- package/tests/integration_fixture_ids.mjs +82 -0
- package/tests/invite_expiry_db.mjs +13 -1
- package/tests/lib_sh_resolution.mjs +0 -4
- package/tests/market_watch_data.mjs +244 -0
- package/tests/market_watch_data_db.mjs +308 -0
- package/tests/market_watch_module.mjs +114 -0
- package/tests/market_watch_run.mjs +503 -0
- package/tests/metrics.mjs +124 -0
- package/tests/migration_namespace.mjs +14 -0
- package/tests/module_api.mjs +2 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/notes.mjs +182 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/page_tweak_marker_sql.mjs +76 -0
- package/tests/page_tweak_no_block.mjs +135 -0
- package/tests/page_tweak_notice.mjs +103 -0
- package/tests/payout_approvals.mjs +253 -0
- package/tests/payout_approvals_db.mjs +106 -0
- package/tests/payout_gates.mjs +322 -0
- package/tests/payout_gates_db.mjs +113 -0
- package/tests/planning_session_skill.mjs +13 -3
- package/tests/project_settings_page.mjs +29 -5
- package/tests/project_settings_spend_cap_route.mjs +235 -0
- package/tests/promotion_sweep.mjs +29 -1
- package/tests/provision.mjs +3 -2
- package/tests/provisioning_app_status.mjs +18 -0
- package/tests/provisioning_credential_preflight.mjs +53 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
- package/tests/publish_reconciler_overlap.mjs +60 -0
- package/tests/rank_change_broadcast_port.mjs +151 -0
- package/tests/seed_tasks.mjs +176 -0
- package/tests/seed_verify_criteria.mjs +2 -6
- package/tests/site_classified_once.mjs +142 -0
- package/tests/speciality_db_shape.mjs +23 -0
- package/tests/speciality_session_skills.mjs +48 -2
- package/tests/strand_lease.mjs +249 -0
- package/tests/strand_watch.mjs +10 -0
- package/tests/task_criteria_routes.mjs +275 -0
- package/tests/task_criteria_unlink_db.mjs +123 -0
- package/tests/task_needs.mjs +233 -0
- package/tests/task_needs_db.mjs +110 -0
- package/tests/unit_runner_serial.mjs +266 -43
- package/tests/version_literals.mjs +5 -7
- package/tests/welcome_boot.mjs +16 -5
- package/tests/welcome_boot_browser.mjs +36 -4
- package/docs/recipes/bongos-cli-release.md +0 -66
- package/scripts/bongos/backfill-disciplines.js +0 -144
- package/scripts/bongos/backfill-session-earnings.js +0 -123
- package/scripts/bongos/backfill-session-types.js +0 -87
- package/scripts/bongos/build-bongos.js +0 -94
- package/scripts/bongos/cleanup-orphans.js +0 -74
- package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
- package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
- package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
- package/scripts/bongos/smoke-batch-claim.sh +0 -238
- package/scripts/bongos/smoke-batch-release.sh +0 -316
- package/scripts/bongos/smoke-claim-consistency.sh +0 -222
- package/scripts/bongos/smoke-dependencies.sh +0 -143
- package/scripts/gds/backfill-disciplines.js +0 -7
- package/scripts/gds/backfill-session-earnings.js +0 -7
- package/scripts/gds/backfill-session-types.js +0 -7
- package/scripts/gds/cleanup-orphans.js +0 -7
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
- package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
- package/scripts/gds/smoke-batch-claim.sh +0 -8
- package/scripts/gds/smoke-batch-release.sh +0 -8
- package/scripts/gds/smoke-claim-consistency.sh +0 -8
- package/scripts/gds/smoke-dependencies.sh +0 -8
- package/tests/smoke_dependencies_witness.mjs +0 -46
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/routes/my-payouts.js — the person's own side of the payout gates
|
|
4
|
+
// (task 1004914, goal 1000092 N10; ADR 0385 decision 8).
|
|
5
|
+
//
|
|
6
|
+
// WHY. When pay someone is owed is held, the owner's framework (cited by Article
|
|
7
|
+
// only) requires that they are told and can appeal: earned pay is never withheld
|
|
8
|
+
// silently. These are the routes for that person, kept apart from the approver's
|
|
9
|
+
// routes in routes/payout-gates.js so this whole file can be held to the
|
|
10
|
+
// own-scope rule (tests/own_scope_guard.mjs, the my-passkeys precedent): every
|
|
11
|
+
// route acts on req.builder.id and nothing else.
|
|
12
|
+
//
|
|
13
|
+
// GET /me/payouts my standing, every hold, its notice, its events
|
|
14
|
+
// POST /me/payout-age declare my own age tier (marked self-declared)
|
|
15
|
+
// POST /me/payout-freezes/:id/appeal appeal a hold on my own pay
|
|
16
|
+
// POST /me/guardian-cosigns co-sign, as myself, a payout to the builder
|
|
17
|
+
// I am guardian for (the database checks I am)
|
|
18
|
+
//
|
|
19
|
+
// Every write needs the person's own web session: an AI session may not declare
|
|
20
|
+
// anyone's age, appeal for them, or sign for a guardian.
|
|
21
|
+
|
|
22
|
+
const express = require('express');
|
|
23
|
+
const auth = require('../auth');
|
|
24
|
+
const { validateOrRespond } = require('./_helpers');
|
|
25
|
+
const { OUR_REFUSALS } = require('./payout-gates');
|
|
26
|
+
const log = require('../logger').logger.child({ src: 'my-payouts' });
|
|
27
|
+
|
|
28
|
+
const PERSONAL_REFUSAL = 'This must be done by the person themselves, signed in on the website. A command-line or AI session cannot do it.';
|
|
29
|
+
|
|
30
|
+
function buildMyPayoutsRouter({
|
|
31
|
+
gates = require('../payout-gates'),
|
|
32
|
+
approvals = require('../payout-approvals'),
|
|
33
|
+
requireBuilder = auth.requireBuilder,
|
|
34
|
+
} = {}) {
|
|
35
|
+
const router = express.Router();
|
|
36
|
+
|
|
37
|
+
function personal(req, res) {
|
|
38
|
+
const p = approvals.personalSession(req);
|
|
39
|
+
if (!p.ok) {
|
|
40
|
+
res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSAL });
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return p.actor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fault(res, err, what) {
|
|
47
|
+
// A trigger refusal is the database saying no. Its words are passed on only
|
|
48
|
+
// when they are one of core_284's own plain-language rules (OUR_REFUSALS); any
|
|
49
|
+
// other database text stays in the log, never in the response.
|
|
50
|
+
if (err && (err.code === '23514' || err.code === '42501')) {
|
|
51
|
+
const said = String(err.message || '');
|
|
52
|
+
const ours = OUR_REFUSALS.some((re) => re.test(said));
|
|
53
|
+
if (!ours) log.warn({ err: said }, `${what} refused by the database`);
|
|
54
|
+
return res.fail('payout_refused', { status: 409, message: ours ? said : 'The payout rules refused this.' });
|
|
55
|
+
}
|
|
56
|
+
if (err && err.code === '23505') return res.fail('already_recorded', { status: 409, message: 'This has already been recorded.' });
|
|
57
|
+
if (err && err.code === '23503') return res.fail('unknown_builder', { status: 400, message: 'That is not a builder on this project.' });
|
|
58
|
+
log.error({ err: err && err.message }, `${what} failed`);
|
|
59
|
+
return res.fail('payout_gate_failed', { status: 500, message: 'internal error' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const statement = (raw, max) => (typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '').slice(0, max + 1);
|
|
63
|
+
|
|
64
|
+
// GET /api/bongos/me/payouts — my payout standing: whether I can be paid, every
|
|
65
|
+
// hold on my pay with its notice and what happened since, and how to appeal.
|
|
66
|
+
router.get('/me/payouts', requireBuilder, async (req, res) => {
|
|
67
|
+
try {
|
|
68
|
+
const v = await gates.gateRecipient(req.builder.id);
|
|
69
|
+
res.json(gates.presentOwn(v));
|
|
70
|
+
} catch (err) { fault(res, err, 'GET /me/payouts'); }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// POST /api/bongos/me/payout-age — declare my own age tier. Body: { tier }.
|
|
74
|
+
router.post('/me/payout-age', requireBuilder, async (req, res) => {
|
|
75
|
+
const actor = personal(req, res);
|
|
76
|
+
if (!actor) return;
|
|
77
|
+
if (validateOrRespond(req, res, { tier: { required: true } })) return;
|
|
78
|
+
if (!gates.AGE_TIERS.includes(req.body.tier)) return res.fail('bad_tier', { status: 400, message: `tier must be one of ${gates.AGE_TIERS.join(', ')}` });
|
|
79
|
+
try {
|
|
80
|
+
res.status(201).json({ record: await gates.recordAge({ builderId: req.builder.id, tier: req.body.tier, basis: 'self_declared', recordedBy: req.builder.id }) });
|
|
81
|
+
} catch (err) { fault(res, err, 'POST /me/payout-age'); }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// POST /api/bongos/me/payout-freezes/:id/appeal — appeal a hold on my pay. Body: { statement }.
|
|
85
|
+
router.post('/me/payout-freezes/:id/appeal', requireBuilder, async (req, res) => {
|
|
86
|
+
const actor = personal(req, res);
|
|
87
|
+
if (!actor) return;
|
|
88
|
+
if (validateOrRespond(req, res, { statement: { required: true } })) return;
|
|
89
|
+
if (!approvals.isId(req.params.id)) return res.fail('bad_freeze_id', { status: 400, message: 'freeze id must be a number' });
|
|
90
|
+
const words = statement(req.body.statement, 2000);
|
|
91
|
+
if (!words || words.length > 2000) return res.fail('bad_statement', { status: 400, message: 'Say why, in up to 2000 characters.' });
|
|
92
|
+
try {
|
|
93
|
+
const f = await gates.readFreeze(req.params.id);
|
|
94
|
+
if (!f || String(f.builder_id) !== String(req.builder.id)) return res.fail('no_such_freeze', { status: 404, message: 'You have no hold with that id.' });
|
|
95
|
+
if (f.lifted) return res.fail('already_lifted', { status: 409, message: 'That hold is already lifted.' });
|
|
96
|
+
res.status(201).json({ event: await gates.addFreezeEvent({ freezeId: f.id, kind: 'appealed', actorId: req.builder.id, statement: words }) });
|
|
97
|
+
} catch (err) { fault(res, err, 'POST /me/payout-freezes/:id/appeal'); }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// POST /api/bongos/me/guardian-cosigns — as a guardian, co-sign one payout to the
|
|
101
|
+
// 16-17 builder(s) I am guardian for. Body: { approval_id } or { rule_id, run_key }.
|
|
102
|
+
// Whose payout it is comes from the payout itself (its recipient, or the run's
|
|
103
|
+
// lines), narrowed to the builders whose current guardian is req.builder — never
|
|
104
|
+
// from a builder id the caller sends. The database checks the guardianship again.
|
|
105
|
+
router.post('/me/guardian-cosigns', requireBuilder, async (req, res) => {
|
|
106
|
+
const actor = personal(req, res);
|
|
107
|
+
if (!actor) return;
|
|
108
|
+
if (validateOrRespond(req, res, { approval_id: {}, rule_id: {}, run_key: {} })) return;
|
|
109
|
+
const b = req.body;
|
|
110
|
+
const oneOff = b.approval_id != null;
|
|
111
|
+
const run = b.rule_id != null || b.run_key != null;
|
|
112
|
+
if (oneOff === run) return res.fail('bad_target', { status: 400, message: 'Co-sign either one approval_id, or one rule_id with its run_key.' });
|
|
113
|
+
if (oneOff && !approvals.isId(b.approval_id)) return res.fail('bad_target', { status: 400, message: 'approval_id must be a number' });
|
|
114
|
+
if (run && (!approvals.isId(b.rule_id) || typeof b.run_key !== 'string' || !approvals.RUN_KEY_RE.test(b.run_key))) {
|
|
115
|
+
return res.fail('bad_target', { status: 400, message: 'rule_id must be a number and run_key a run key' });
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const cosigns = await gates.cosignAsGuardian({
|
|
119
|
+
guardianId: req.builder.id,
|
|
120
|
+
sessionId: actor.session_id,
|
|
121
|
+
approvalId: oneOff ? b.approval_id : null,
|
|
122
|
+
ruleId: run ? b.rule_id : null,
|
|
123
|
+
runKey: run ? b.run_key : null,
|
|
124
|
+
});
|
|
125
|
+
if (!cosigns.length) return res.fail('nothing_to_cosign', { status: 404, message: 'That payout is not to anyone you are the guardian for.' });
|
|
126
|
+
res.status(201).json({ cosigns });
|
|
127
|
+
} catch (err) { fault(res, err, 'POST /me/guardian-cosigns'); }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return router;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = buildMyPayoutsRouter;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/routes/payout-approvals.js — the payout approval step and its log
|
|
4
|
+
// (task 1004913, goal 1000092 N9; ADR 0385 decisions 4 and 8).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Before any money-moving task can pay (M04, N6, N7), the project's
|
|
7
|
+
// authorised person must have personally approved it, and that approval must sit
|
|
8
|
+
// in an append-only log (Art. I of the owner's framework, cited by Article only).
|
|
9
|
+
// These routes are the only writers of that log. The rules live in
|
|
10
|
+
// ../payout-approvals.js; the database repeats the ones that matter
|
|
11
|
+
// (migrations/core_283_payout_approvals.sql).
|
|
12
|
+
//
|
|
13
|
+
// THE GATES, in order, on every route:
|
|
14
|
+
// 1. requireBuilder + requirePermission('payout.approve') — rank and grants read
|
|
15
|
+
// fresh from the DB per request (ADR 0016). Archon by default; a project may
|
|
16
|
+
// hand the atom to a treasurer rank of its own.
|
|
17
|
+
// 2. on every SIGNATURE: a personal session — the person's own web session,
|
|
18
|
+
// never a CLI token and never a machine principal. A 403
|
|
19
|
+
// `personal_approval_required` says so in words. Only noticing a run (which
|
|
20
|
+
// authorises nothing) and reading the log skip it.
|
|
21
|
+
//
|
|
22
|
+
// No batch route exists: each payout, grant and contract is one request and one
|
|
23
|
+
// row. A body carrying a list where one item is expected is refused.
|
|
24
|
+
//
|
|
25
|
+
// Dependencies are injectable so the router is testable without Postgres.
|
|
26
|
+
|
|
27
|
+
const express = require('express');
|
|
28
|
+
const auth = require('../auth');
|
|
29
|
+
const { validateOrRespond } = require('./_helpers');
|
|
30
|
+
const log = require('../logger').logger.child({ src: 'payout-approvals' });
|
|
31
|
+
|
|
32
|
+
const PERSONAL_REFUSALS = {
|
|
33
|
+
machine_principal: 'A machine identity can never approve money. A person must do this themselves.',
|
|
34
|
+
not_a_web_session: 'Approving money must be done by you, signed in on the website. A command-line or AI session cannot sign it.',
|
|
35
|
+
no_session: 'Sign in on the website to approve this.',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function cleanDoc(raw) {
|
|
39
|
+
// An equity grant or a contract: what it is and where the signed text lives.
|
|
40
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'terms must be an object with a title and summary' };
|
|
41
|
+
const known = new Set(['title', 'summary', 'document_ref']);
|
|
42
|
+
for (const k of Object.keys(raw)) if (!known.has(k)) return { error: `unknown term: ${k}` };
|
|
43
|
+
const text = (v) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '');
|
|
44
|
+
const title = text(raw.title);
|
|
45
|
+
const summary = text(raw.summary);
|
|
46
|
+
if (title.length < 3 || title.length > 120) return { error: 'terms.title must be 3 to 120 characters' };
|
|
47
|
+
if (summary.length < 3 || summary.length > 1000) return { error: 'terms.summary must be 3 to 1000 characters' };
|
|
48
|
+
const out = { title, summary };
|
|
49
|
+
if (raw.document_ref !== undefined && raw.document_ref !== null) {
|
|
50
|
+
const ref = text(raw.document_ref);
|
|
51
|
+
if (!ref || ref.length > 300) return { error: 'terms.document_ref must be up to 300 characters' };
|
|
52
|
+
out.document_ref = ref;
|
|
53
|
+
}
|
|
54
|
+
return { value: out };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = function buildPayoutApprovalsRouter({
|
|
58
|
+
store = require('../payout-approvals'),
|
|
59
|
+
gates = require('../payout-gates'),
|
|
60
|
+
requireBuilder = auth.requireBuilder,
|
|
61
|
+
requirePermission = auth.requirePermission,
|
|
62
|
+
} = {}) {
|
|
63
|
+
const router = express.Router();
|
|
64
|
+
|
|
65
|
+
function personal(req, res) {
|
|
66
|
+
const p = store.personalSession(req);
|
|
67
|
+
if (!p.ok) {
|
|
68
|
+
res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSALS[p.reason] || PERSONAL_REFUSALS.no_session });
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return p.actor;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dbRefusal(res, err, what) {
|
|
75
|
+
// The trigger's refusals (check_violation / insufficient_privilege) are the
|
|
76
|
+
// database saying no; anything else is a fault.
|
|
77
|
+
if (err && (err.code === '23514' || err.code === '42501')) {
|
|
78
|
+
res.fail('payout_refused', { status: 409, message: String(err.message || '').replace(/^payout_approvals:\s*/, '') });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (err && err.code === '23505') {
|
|
82
|
+
res.fail('already_recorded', { status: 409, message: 'This has already been recorded.' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (err && err.code === '23503') {
|
|
86
|
+
res.fail('unknown_builder', { status: 400, message: 'That recipient is not a builder on this project.' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
log.error({ err: err && err.message }, `${what} failed`);
|
|
90
|
+
res.fail('payout_write_failed', { status: 500, message: 'internal error' });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function single(body, res) {
|
|
94
|
+
if (Array.isArray(body) || (body && Object.values(body).some(Array.isArray))) {
|
|
95
|
+
res.fail('one_at_a_time', { status: 400, message: 'Approve one at a time: each payout, grant or contract is its own approval.' });
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GET /api/bongos/payouts/approvals — the log, newest first. ?rule_id= narrows it.
|
|
102
|
+
router.get('/payouts/approvals', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
103
|
+
const ruleId = req.query.rule_id;
|
|
104
|
+
if (ruleId !== undefined && !store.isId(ruleId)) return res.fail('bad_rule_id', { status: 400, message: 'rule_id must be a number' });
|
|
105
|
+
try {
|
|
106
|
+
res.json({ approvals: await store.listLog({ ruleId: ruleId || null, limit: req.query.limit }) });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
log.error({ err: err && err.message }, 'GET /payouts/approvals failed');
|
|
109
|
+
res.fail('payout_read_failed', { status: 500, message: 'internal error' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// GET /api/bongos/payouts/rules — every standing rule with its current terms.
|
|
114
|
+
router.get('/payouts/rules', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
115
|
+
try {
|
|
116
|
+
res.json({ rules: await store.listRules() });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
log.error({ err: err && err.message }, 'GET /payouts/rules failed');
|
|
119
|
+
res.fail('payout_read_failed', { status: 500, message: 'internal error' });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// POST /api/bongos/payouts/rules — sign a standing payout rule. Body: { terms, note? }.
|
|
124
|
+
router.post('/payouts/rules', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
125
|
+
const actor = personal(req, res);
|
|
126
|
+
if (!actor) return;
|
|
127
|
+
if (validateOrRespond(req, res, { terms: { required: true }, note: {} })) return;
|
|
128
|
+
const body = req.body || {};
|
|
129
|
+
const v = store.validateRuleTerms(body.terms);
|
|
130
|
+
if (v.errors) return res.fail('bad_terms', { status: 400, message: v.errors.join('; ') });
|
|
131
|
+
const note = store.cleanNote(body.note);
|
|
132
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
133
|
+
try {
|
|
134
|
+
const row = await store.insertEvent({ kind: 'rule_signed', actor, terms: v.terms, note: note.value });
|
|
135
|
+
res.status(201).json({ approval: row });
|
|
136
|
+
} catch (err) { dbRefusal(res, err, 'POST /payouts/rules'); }
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// POST /api/bongos/payouts/rules/:id/narrow — tighten a rule. Body: { terms, note? }.
|
|
140
|
+
router.post('/payouts/rules/:id/narrow', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
141
|
+
const actor = personal(req, res);
|
|
142
|
+
if (!actor) return;
|
|
143
|
+
if (validateOrRespond(req, res, { terms: { required: true }, note: {} })) return;
|
|
144
|
+
if (!store.isId(req.params.id)) return res.fail('bad_rule_id', { status: 400, message: 'rule id must be a number' });
|
|
145
|
+
const body = req.body || {};
|
|
146
|
+
const v = store.validateRuleTerms(body.terms);
|
|
147
|
+
if (v.errors) return res.fail('bad_terms', { status: 400, message: v.errors.join('; ') });
|
|
148
|
+
const note = store.cleanNote(body.note);
|
|
149
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
150
|
+
try {
|
|
151
|
+
const { rule } = await store.readRule(req.params.id);
|
|
152
|
+
if (!rule) return res.fail('no_such_rule', { status: 404, message: 'No payout rule has that id.' });
|
|
153
|
+
if (!rule.active) return res.fail('rule_revoked', { status: 409, message: 'That rule is revoked. Sign a new one instead.' });
|
|
154
|
+
const widen = store.wideningReasons(rule.terms, v.terms);
|
|
155
|
+
if (widen.length) {
|
|
156
|
+
return res.fail('would_widen', { status: 409, message: `A narrowing can only tighten a rule, but ${widen.join(', ')}. To change that, sign a new rule.` });
|
|
157
|
+
}
|
|
158
|
+
const row = await store.insertEvent({ kind: 'rule_narrowed', rule_id: rule.id, actor, terms: v.terms, note: note.value });
|
|
159
|
+
res.status(201).json({ approval: row });
|
|
160
|
+
} catch (err) { dbRefusal(res, err, 'POST /payouts/rules/:id/narrow'); }
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// POST /api/bongos/payouts/rules/:id/revoke — end a rule. Body: { note? }.
|
|
164
|
+
router.post('/payouts/rules/:id/revoke', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
165
|
+
const actor = personal(req, res);
|
|
166
|
+
if (!actor) return;
|
|
167
|
+
if (validateOrRespond(req, res, { note: {} })) return;
|
|
168
|
+
if (!store.isId(req.params.id)) return res.fail('bad_rule_id', { status: 400, message: 'rule id must be a number' });
|
|
169
|
+
const note = store.cleanNote((req.body || {}).note);
|
|
170
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
171
|
+
try {
|
|
172
|
+
const { rule } = await store.readRule(req.params.id);
|
|
173
|
+
if (!rule) return res.fail('no_such_rule', { status: 404, message: 'No payout rule has that id.' });
|
|
174
|
+
if (!rule.active) return res.fail('rule_revoked', { status: 409, message: 'That rule is already revoked.' });
|
|
175
|
+
const row = await store.insertEvent({ kind: 'rule_revoked', rule_id: rule.id, actor, note: note.value });
|
|
176
|
+
res.status(201).json({ approval: row });
|
|
177
|
+
} catch (err) { dbRefusal(res, err, 'POST /payouts/rules/:id/revoke'); }
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// POST /api/bongos/payouts/rules/:id/runs — notice a run under a rule, opening
|
|
181
|
+
// its pause window. Authorises nothing by itself, so the runner may file it.
|
|
182
|
+
// Body: { run_key, payouts: [{ recipient_id, amount_cents }], note? }.
|
|
183
|
+
router.post('/payouts/rules/:id/runs', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
184
|
+
if (validateOrRespond(req, res, { run_key: {}, payouts: {}, note: {} })) return;
|
|
185
|
+
if (!store.isId(req.params.id)) return res.fail('bad_rule_id', { status: 400, message: 'rule id must be a number' });
|
|
186
|
+
const body = req.body || {};
|
|
187
|
+
if (typeof body.run_key !== 'string' || !store.RUN_KEY_RE.test(body.run_key)) {
|
|
188
|
+
return res.fail('bad_run_key', { status: 400, message: 'run_key must be 1 to 80 letters, digits or . _ : -' });
|
|
189
|
+
}
|
|
190
|
+
const payouts = body.payouts;
|
|
191
|
+
if (!Array.isArray(payouts) || payouts.length === 0 || payouts.length > 500
|
|
192
|
+
|| payouts.some((p) => !p || !store.isId(p.recipient_id) || !store.isCents(p.amount_cents))) {
|
|
193
|
+
return res.fail('bad_payouts', { status: 400, message: 'payouts must list each recipient_id with a whole number of cents above zero' });
|
|
194
|
+
}
|
|
195
|
+
const note = store.cleanNote(body.note);
|
|
196
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
197
|
+
try {
|
|
198
|
+
const { rule } = await store.readRule(req.params.id);
|
|
199
|
+
if (!rule) return res.fail('no_such_rule', { status: 404, message: 'No payout rule has that id.' });
|
|
200
|
+
if (!rule.active) return res.fail('rule_revoked', { status: 409, message: 'That rule is revoked; nothing can run under it.' });
|
|
201
|
+
const total = payouts.reduce((s, p) => s + p.amount_cents, 0);
|
|
202
|
+
const caps = rule.terms.caps;
|
|
203
|
+
if (payouts.some((p) => p.amount_cents > caps.per_payout_cents) || total > caps.per_run_cents) {
|
|
204
|
+
return res.fail('over_cap', { status: 409, message: 'This run is over the caps the rule was signed with.' });
|
|
205
|
+
}
|
|
206
|
+
// N10: every recipient passes the age and sanctions gates. A recipient who
|
|
207
|
+
// does not is told why and how to appeal (a hold is filed for the money they
|
|
208
|
+
// are owed); the run is refused until it is re-noticed without them.
|
|
209
|
+
const owed = new Map();
|
|
210
|
+
for (const p of payouts) owed.set(String(p.recipient_id), (owed.get(String(p.recipient_id)) || 0) + p.amount_cents);
|
|
211
|
+
const held = [];
|
|
212
|
+
for (const [rid, g] of await gates.gateOwedPayMany(owed)) {
|
|
213
|
+
if (!g.ok) held.push({ recipient_id: rid, reason: g.reason, freeze_id: g.freeze_id });
|
|
214
|
+
}
|
|
215
|
+
if (held.length) {
|
|
216
|
+
return res.fail('payout_gated', { status: 409, message: 'Some recipients cannot be paid yet. Each has been told why and how to appeal; notice the run again without them, or once their hold is lifted.', details: { held } });
|
|
217
|
+
}
|
|
218
|
+
const row = await store.insertEvent({
|
|
219
|
+
kind: 'run_noticed',
|
|
220
|
+
rule_id: rule.id,
|
|
221
|
+
run_key: body.run_key,
|
|
222
|
+
actor: { id: req.builder.id, rank: req.builder.rank || null, session_id: req.gdsSession ? req.gdsSession.session_id : null, session_source: req.gdsSession ? req.gdsSession.source : null },
|
|
223
|
+
amount_cents: total,
|
|
224
|
+
currency: rule.terms.currency,
|
|
225
|
+
terms: { payouts: payouts.map((p) => ({ recipient_id: String(p.recipient_id), amount_cents: p.amount_cents })) },
|
|
226
|
+
note: note.value,
|
|
227
|
+
});
|
|
228
|
+
const opensAt = new Date(new Date(row.created_at).getTime() + rule.terms.pause_hours * 3600_000).toISOString();
|
|
229
|
+
res.status(201).json({ approval: row, pays_from: opensAt });
|
|
230
|
+
} catch (err) { dbRefusal(res, err, 'POST /payouts/rules/:id/runs'); }
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// POST /api/bongos/payouts/rules/:id/runs/:runKey/hold — a person stops a run
|
|
234
|
+
// in its pause window. Body: { note? }.
|
|
235
|
+
router.post('/payouts/rules/:id/runs/:runKey/hold', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
236
|
+
const actor = personal(req, res);
|
|
237
|
+
if (!actor) return;
|
|
238
|
+
if (validateOrRespond(req, res, { note: {} })) return;
|
|
239
|
+
if (!store.isId(req.params.id)) return res.fail('bad_rule_id', { status: 400, message: 'rule id must be a number' });
|
|
240
|
+
if (!store.RUN_KEY_RE.test(req.params.runKey)) return res.fail('bad_run_key', { status: 400, message: 'unknown run' });
|
|
241
|
+
const note = store.cleanNote((req.body || {}).note);
|
|
242
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
243
|
+
try {
|
|
244
|
+
const { rule, events } = await store.readRule(req.params.id);
|
|
245
|
+
// A run may be held whenever it exists, is not held already and its rule
|
|
246
|
+
// stands — inside the pause window or after it, until it is paid. Nothing
|
|
247
|
+
// else about the run (caps, its payouts) decides whether stopping it is allowed.
|
|
248
|
+
if (!rule) return res.fail('no_such_rule', { status: 404, message: 'No payout rule has that id.' });
|
|
249
|
+
if (!rule.active) return res.fail('rule_revoked', { status: 409, message: 'That rule is revoked; nothing under it can pay.' });
|
|
250
|
+
const mine = events.filter((e) => e.run_key === req.params.runKey);
|
|
251
|
+
if (!mine.some((e) => e.kind === 'run_noticed')) return res.fail('run_not_noticed', { status: 404, message: 'There is no run with that key to hold.' });
|
|
252
|
+
if (mine.some((e) => e.kind === 'run_held')) return res.fail('already_recorded', { status: 409, message: 'That run is already held.' });
|
|
253
|
+
const row = await store.insertEvent({ kind: 'run_held', rule_id: rule.id, run_key: req.params.runKey, actor, note: note.value });
|
|
254
|
+
res.status(201).json({ approval: row });
|
|
255
|
+
} catch (err) { dbRefusal(res, err, 'POST /payouts/rules/:id/runs/:runKey/hold'); }
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// POST /api/bongos/payouts/approve — approve ONE payout.
|
|
259
|
+
// Body: { recipient_id, amount_cents, currency, note? }.
|
|
260
|
+
router.post('/payouts/approve', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
261
|
+
const actor = personal(req, res);
|
|
262
|
+
if (!actor) return;
|
|
263
|
+
if (validateOrRespond(req, res, { recipient_id: {}, amount_cents: {}, currency: {}, note: {} })) return;
|
|
264
|
+
const body = req.body || {};
|
|
265
|
+
if (!single(body, res)) return;
|
|
266
|
+
if (!store.isId(body.recipient_id)) return res.fail('bad_recipient', { status: 400, message: 'recipient_id must be a builder id' });
|
|
267
|
+
if (!store.isCents(body.amount_cents)) return res.fail('bad_amount', { status: 400, message: 'amount_cents must be a whole number of cents above zero' });
|
|
268
|
+
if (typeof body.currency !== 'string' || !/^[A-Z]{3}$/.test(body.currency)) return res.fail('bad_currency', { status: 400, message: 'currency must be a three-letter code such as USD' });
|
|
269
|
+
const note = store.cleanNote(body.note);
|
|
270
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
271
|
+
try {
|
|
272
|
+
const g = await gates.gateOwedPay(body.recipient_id, { owedCents: body.amount_cents });
|
|
273
|
+
if (!g.ok) {
|
|
274
|
+
return res.fail('payout_gated', { status: 409, message: 'This person cannot be paid yet. They have been told why and how to appeal.', details: { reason: g.reason, freeze_id: g.freeze_id } });
|
|
275
|
+
}
|
|
276
|
+
const row = await store.insertEvent({ kind: 'payout_approved', actor, recipient_id: body.recipient_id, amount_cents: body.amount_cents, currency: body.currency, note: note.value });
|
|
277
|
+
res.status(201).json({ approval: row });
|
|
278
|
+
} catch (err) { dbRefusal(res, err, 'POST /payouts/approve'); }
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// POST /api/bongos/payouts/grants and /payouts/contracts — sign ONE equity grant
|
|
282
|
+
// or ONE contract. Body: { recipient_id, terms: { title, summary, document_ref? }, note? }.
|
|
283
|
+
async function signDoc(req, res, path, kind) {
|
|
284
|
+
const actor = personal(req, res);
|
|
285
|
+
if (!actor) return;
|
|
286
|
+
if (validateOrRespond(req, res, { recipient_id: {}, terms: { required: true }, note: {} })) return;
|
|
287
|
+
const body = req.body || {};
|
|
288
|
+
if (!single(body, res)) return;
|
|
289
|
+
if (!store.isId(body.recipient_id)) return res.fail('bad_recipient', { status: 400, message: 'recipient_id must be a builder id' });
|
|
290
|
+
const doc = cleanDoc(body.terms);
|
|
291
|
+
if (doc.error) return res.fail('bad_terms', { status: 400, message: doc.error });
|
|
292
|
+
const note = store.cleanNote(body.note);
|
|
293
|
+
if (note.error) return res.fail('bad_note', { status: 400, message: note.error });
|
|
294
|
+
try {
|
|
295
|
+
// A grant or contract is not money owed yet, so a refusal files no hold; it
|
|
296
|
+
// is refused on the same age and sanctions rules, with the reason.
|
|
297
|
+
const g = await gates.gateRecipient(body.recipient_id);
|
|
298
|
+
if (!g.ok) return res.fail('payout_gated', { status: 409, message: 'This person cannot receive this yet.', details: { reason: g.reason, freeze_id: g.freeze_id || null } });
|
|
299
|
+
const row = await store.insertEvent({ kind, actor, recipient_id: body.recipient_id, terms: doc.value, note: note.value });
|
|
300
|
+
res.status(201).json({ approval: row });
|
|
301
|
+
} catch (err) { dbRefusal(res, err, 'POST ' + path); }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// POST /api/bongos/payouts/grants — sign ONE equity grant.
|
|
305
|
+
// Body: { recipient_id, terms: { title, summary, document_ref? }, note? }.
|
|
306
|
+
router.post('/payouts/grants', requireBuilder, requirePermission('payout.approve'), (req, res) => signDoc(req, res, '/payouts/grants', 'grant_signed'));
|
|
307
|
+
|
|
308
|
+
// POST /api/bongos/payouts/contracts — sign ONE contract. Body as for grants.
|
|
309
|
+
router.post('/payouts/contracts', requireBuilder, requirePermission('payout.approve'), (req, res) => signDoc(req, res, '/payouts/contracts', 'contract_signed'));
|
|
310
|
+
|
|
311
|
+
return router;
|
|
312
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/routes/payout-gates.js — the approver's side of the payout gates
|
|
4
|
+
// (task 1004914, goal 1000092 N10; ADR 0385 decision 8).
|
|
5
|
+
//
|
|
6
|
+
// WHY. The gates themselves run inside every money route (../payout-gates.js,
|
|
7
|
+
// called from routes/payout-approvals.js and from checkRun / checkApproval). This
|
|
8
|
+
// file is where their facts are recorded — an age tier, a guardian link, a
|
|
9
|
+
// sanctions screening, a hand-made hold — and where an appeal is decided. The
|
|
10
|
+
// person whose pay is held reads why and appeals in routes/my-payouts.js (the
|
|
11
|
+
// framework, cited by Article only, forbids withholding earned pay silently).
|
|
12
|
+
//
|
|
13
|
+
// GATES. Recording a fact about someone else needs requireBuilder +
|
|
14
|
+
// requirePermission('payout.approve') (fresh per request, ADR 0016) AND the
|
|
15
|
+
// person's own web session — the same rule as signing a payout, because each of
|
|
16
|
+
// these decides whether money moves.
|
|
17
|
+
//
|
|
18
|
+
// Dependencies are injectable so the router is testable without Postgres.
|
|
19
|
+
|
|
20
|
+
const express = require('express');
|
|
21
|
+
const auth = require('../auth');
|
|
22
|
+
const { validateOrRespond } = require('./_helpers');
|
|
23
|
+
const log = require('../logger').logger.child({ src: 'payout-gates' });
|
|
24
|
+
|
|
25
|
+
// The refusals core_284's triggers raise, word for word. Only these reach the
|
|
26
|
+
// person; tests/payout_gates.mjs reads each one out of the migration and feeds it
|
|
27
|
+
// through the route, so a reworded trigger cannot silently fall back to the
|
|
28
|
+
// generic line.
|
|
29
|
+
const OUR_REFUSALS = Object.freeze([
|
|
30
|
+
/^only the person whose pay is frozen may appeal it$/,
|
|
31
|
+
/^a person may not decide the appeal on their own freeze$/,
|
|
32
|
+
/^only the builder's current guardian may co-sign their payout$/,
|
|
33
|
+
/^payout freeze \d+ is already lifted$/,
|
|
34
|
+
/^payout_[a-z_]+ is append-only: (UPDATE|DELETE|TRUNCATE) refused \(a correction is a new row\)$/,
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const PERSONAL_REFUSAL ='This must be done by the person themselves, signed in on the website. A command-line or AI session cannot do it.';
|
|
38
|
+
|
|
39
|
+
function buildPayoutGatesRouter({
|
|
40
|
+
gates = require('../payout-gates'),
|
|
41
|
+
approvals = require('../payout-approvals'),
|
|
42
|
+
requireBuilder = auth.requireBuilder,
|
|
43
|
+
requirePermission = auth.requirePermission,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const router = express.Router();
|
|
46
|
+
|
|
47
|
+
function personal(req, res) {
|
|
48
|
+
const p = approvals.personalSession(req);
|
|
49
|
+
if (!p.ok) {
|
|
50
|
+
res.fail('personal_approval_required', { status: 403, message: PERSONAL_REFUSAL });
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return p.actor;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fault(res, err, what) {
|
|
57
|
+
// A trigger refusal is the database saying no. Its words are passed on only
|
|
58
|
+
// when they are one of this file's own plain-language rules (core_284); any
|
|
59
|
+
// other database text stays in the log, never in the response.
|
|
60
|
+
if (err && (err.code === '23514' || err.code === '42501')) {
|
|
61
|
+
const said = String(err.message || '');
|
|
62
|
+
const ours = OUR_REFUSALS.some((re) => re.test(said));
|
|
63
|
+
if (!ours) log.warn({ err: said }, `${what} refused by the database`);
|
|
64
|
+
return res.fail('payout_refused', { status: 409, message: ours ? said : 'The payout rules refused this.' });
|
|
65
|
+
}
|
|
66
|
+
if (err && err.code === '23505') return res.fail('already_recorded', { status: 409, message: 'This has already been recorded.' });
|
|
67
|
+
if (err && err.code === '23503') return res.fail('unknown_builder', { status: 400, message: 'That is not a builder on this project.' });
|
|
68
|
+
log.error({ err: err && err.message }, `${what} failed`);
|
|
69
|
+
return res.fail('payout_gate_failed', { status: 500, message: 'internal error' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const statement = (raw, max) => (typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '').slice(0, max + 1);
|
|
73
|
+
|
|
74
|
+
// GET /api/bongos/payouts/gates/:builderId — may this builder be paid now, and why not.
|
|
75
|
+
router.get('/payouts/gates/:builderId', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
76
|
+
if (!approvals.isId(req.params.builderId)) return res.fail('bad_builder_id', { status: 400, message: 'builder id must be a number' });
|
|
77
|
+
try {
|
|
78
|
+
const v = await gates.gateRecipient(req.params.builderId);
|
|
79
|
+
res.json({ gate: gates.present(v) });
|
|
80
|
+
} catch (err) { fault(res, err, 'GET /payouts/gates/:builderId'); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// POST /api/bongos/payouts/age-records — record a builder's age tier, verified by a person.
|
|
84
|
+
// Body: { builder_id, tier }.
|
|
85
|
+
router.post('/payouts/age-records', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
86
|
+
const actor = personal(req, res);
|
|
87
|
+
if (!actor) return;
|
|
88
|
+
if (validateOrRespond(req, res, { builder_id: { required: true }, tier: { required: true } })) return;
|
|
89
|
+
const b = req.body;
|
|
90
|
+
if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
|
|
91
|
+
if (!gates.AGE_TIERS.includes(b.tier)) return res.fail('bad_tier', { status: 400, message: `tier must be one of ${gates.AGE_TIERS.join(', ')}` });
|
|
92
|
+
try {
|
|
93
|
+
res.status(201).json({ record: await gates.recordAge({ builderId: b.builder_id, tier: b.tier, basis: 'verified_by_person', recordedBy: actor.id }) });
|
|
94
|
+
} catch (err) { fault(res, err, 'POST /payouts/age-records'); }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// POST /api/bongos/payouts/guardians — link (or unlink) a 16-17 builder's guardian
|
|
98
|
+
// and the account the guardian controls. Body: { builder_id, guardian_id, guardian_account?, active }.
|
|
99
|
+
router.post('/payouts/guardians', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
100
|
+
const actor = personal(req, res);
|
|
101
|
+
if (!actor) return;
|
|
102
|
+
if (validateOrRespond(req, res, { builder_id: { required: true }, guardian_id: { required: true }, guardian_account: {}, active: { required: true } })) return;
|
|
103
|
+
const b = req.body;
|
|
104
|
+
if (!approvals.isId(b.builder_id) || !approvals.isId(b.guardian_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id and guardian_id must be numbers' });
|
|
105
|
+
if (String(b.builder_id) === String(b.guardian_id)) return res.fail('guardian_is_self', { status: 400, message: 'A builder cannot be their own guardian.' });
|
|
106
|
+
if (typeof b.active !== 'boolean') return res.fail('bad_active', { status: 400, message: 'active must be true or false' });
|
|
107
|
+
const account = b.guardian_account == null ? null : statement(b.guardian_account, 200);
|
|
108
|
+
if (b.active && (!account || account.length < 3 || account.length > 200)) {
|
|
109
|
+
return res.fail('bad_guardian_account', { status: 400, message: 'An active guardian link must name the account the guardian controls (3 to 200 characters).' });
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
res.status(201).json({ record: await gates.recordGuardian({ builderId: b.builder_id, guardianId: b.guardian_id, guardianAccount: account, active: b.active, recordedBy: actor.id }) });
|
|
113
|
+
} catch (err) { fault(res, err, 'POST /payouts/guardians'); }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// POST /api/bongos/payouts/screenings — record a sanctions screening a person ran.
|
|
117
|
+
// Body: { builder_id, result: clear|match|error, reference?, valid_days? }.
|
|
118
|
+
router.post('/payouts/screenings', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
119
|
+
const actor = personal(req, res);
|
|
120
|
+
if (!actor) return;
|
|
121
|
+
if (validateOrRespond(req, res, { builder_id: { required: true }, result: { required: true }, reference: {}, valid_days: {} })) return;
|
|
122
|
+
const b = req.body;
|
|
123
|
+
if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
|
|
124
|
+
if (!gates.SCREEN_RESULTS.includes(b.result)) return res.fail('bad_result', { status: 400, message: `result must be one of ${gates.SCREEN_RESULTS.join(', ')}` });
|
|
125
|
+
let validUntil = null;
|
|
126
|
+
if (b.result === 'clear') {
|
|
127
|
+
if (!Number.isSafeInteger(b.valid_days) || b.valid_days < 1 || b.valid_days > gates.MAX_SCREEN_DAYS) {
|
|
128
|
+
return res.fail('bad_valid_days', { status: 400, message: `A clear result needs valid_days from 1 to ${gates.MAX_SCREEN_DAYS}.` });
|
|
129
|
+
}
|
|
130
|
+
validUntil = new Date(Date.now() + b.valid_days * 86_400_000).toISOString();
|
|
131
|
+
}
|
|
132
|
+
const reference = b.reference == null ? null : statement(b.reference, 200);
|
|
133
|
+
if (reference !== null && reference.length > 200) return res.fail('bad_reference', { status: 400, message: 'reference must be up to 200 characters' });
|
|
134
|
+
const login = String(req.builder.github_login || actor.id).replace(/[^A-Za-z0-9._-]/g, '').slice(0, 60) || String(actor.id);
|
|
135
|
+
try {
|
|
136
|
+
res.status(201).json({ record: await gates.recordScreening({ builderId: b.builder_id, result: b.result, screenedBy: `person:${login}`, reference, validUntil, recordedBy: actor.id }) });
|
|
137
|
+
} catch (err) { fault(res, err, 'POST /payouts/screenings'); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// POST /api/bongos/payouts/freezes — a person freezes a builder's payouts by hand.
|
|
141
|
+
// The notice is required: there is no silent freeze. Body: { builder_id, notice_text, owed_cents? }.
|
|
142
|
+
router.post('/payouts/freezes', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
143
|
+
const actor = personal(req, res);
|
|
144
|
+
if (!actor) return;
|
|
145
|
+
if (validateOrRespond(req, res, { builder_id: { required: true }, notice_text: { required: true }, owed_cents: {} })) return;
|
|
146
|
+
const b = req.body;
|
|
147
|
+
if (!approvals.isId(b.builder_id)) return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a number' });
|
|
148
|
+
const notice = statement(b.notice_text, 1000);
|
|
149
|
+
if (notice.length < 20 || notice.length > 1000) return res.fail('bad_notice', { status: 400, message: 'notice_text must say, in 20 to 1000 characters, what is held and why. The person reads it.' });
|
|
150
|
+
if (b.owed_cents != null && !approvals.isCents(b.owed_cents)) return res.fail('bad_amount', { status: 400, message: 'owed_cents must be a whole number of cents above zero' });
|
|
151
|
+
try {
|
|
152
|
+
res.status(201).json({ freeze: await gates.freezeManually({ builderId: b.builder_id, noticeText: notice, owedCents: b.owed_cents ?? null, frozenBy: actor.id }) });
|
|
153
|
+
} catch (err) { fault(res, err, 'POST /payouts/freezes'); }
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// POST /api/bongos/payouts/freezes/:id/decide — uphold or lift a freeze, with the
|
|
157
|
+
// reason the person will read. Body: { decision: upheld|lifted, statement }.
|
|
158
|
+
router.post('/payouts/freezes/:id/decide', requireBuilder, requirePermission('payout.approve'), async (req, res) => {
|
|
159
|
+
const actor = personal(req, res);
|
|
160
|
+
if (!actor) return;
|
|
161
|
+
if (validateOrRespond(req, res, { decision: { required: true }, statement: { required: true } })) return;
|
|
162
|
+
if (!approvals.isId(req.params.id)) return res.fail('bad_freeze_id', { status: 400, message: 'freeze id must be a number' });
|
|
163
|
+
const { decision } = req.body;
|
|
164
|
+
if (decision !== 'upheld' && decision !== 'lifted') return res.fail('bad_decision', { status: 400, message: 'decision must be upheld or lifted' });
|
|
165
|
+
const words = statement(req.body.statement, 2000);
|
|
166
|
+
if (!words || words.length > 2000) return res.fail('bad_statement', { status: 400, message: 'statement must say why, in up to 2000 characters. The person reads it.' });
|
|
167
|
+
try {
|
|
168
|
+
const f = await gates.readFreeze(req.params.id);
|
|
169
|
+
if (!f) return res.fail('no_such_freeze', { status: 404, message: 'No freeze has that id.' });
|
|
170
|
+
if (f.lifted) return res.fail('already_lifted', { status: 409, message: 'That freeze is already lifted.' });
|
|
171
|
+
if (String(f.builder_id) === String(actor.id)) return res.fail('own_freeze', { status: 403, message: 'You cannot decide a freeze on your own pay.' });
|
|
172
|
+
res.status(201).json({ event: await gates.addFreezeEvent({ freezeId: f.id, kind: decision, actorId: actor.id, statement: words }) });
|
|
173
|
+
} catch (err) { fault(res, err, 'POST /payouts/freezes/:id/decide'); }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return router;
|
|
177
|
+
};
|
|
178
|
+
buildPayoutGatesRouter.OUR_REFUSALS = OUR_REFUSALS;
|
|
179
|
+
module.exports = buildPayoutGatesRouter;
|