@bongos/core 1.23.21 → 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 +983 -428
- 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 +6 -3
- 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 +94 -0
- package/clients/bongos-client/index.cjs +94 -0
- package/clients/bongos-client/index.d.ts +154 -4
- package/clients/bongos-client/index.mjs +94 -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 +87 -0
- package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
- package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
- package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
- package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
- 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 +10 -1
- package/docs/api/openapi.json +8619 -5885
- package/docs/api-reference.md +68 -9
- 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 +4 -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 +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/CLAUDE.md +15 -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/module.json +26 -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 +252 -0
- 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 +108 -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 +50 -18
- package/modules/government/routing.js +39 -5
- package/modules/government/waiting.js +74 -0
- package/modules/grading/grader-subagent.js +30 -11
- 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/city.html +15 -15
- 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-analytics.js +10 -3
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +48 -18
- package/modules/lifecycle/db-shared.js +9 -0
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/db-versions.js +21 -8
- 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/ship-assurance.js +4 -1
- package/modules/lifecycle/ship-card.js +8 -2
- 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/onboarding/howto-moments.js +8 -1
- 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 +8 -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 +286 -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 +4 -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 +129 -12
- 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 +16 -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/src/platform-server.js +27 -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/batched_writes_parity.mjs +340 -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/conductor_main_e2e.mjs +4 -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/per-row-loops-legacy.cjs +245 -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_archive_disposition.mjs +5 -5
- 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 +146 -23
- package/tests/grader.mjs +31 -0
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_city.mjs +8 -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/hook_shell_tools.mjs +8 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/howto_moments.mjs +17 -0
- 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/module_skills_hook.mjs +69 -0
- 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/public_landing_projects.mjs +37 -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/session_start_freshness.mjs +66 -1
- package/tests/shell_hooks.mjs +209 -0
- package/tests/ship_assurance.mjs +28 -0
- package/tests/ship_card.mjs +20 -5
- 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 +58 -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_close_route.mjs +1 -1
- 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,322 @@
|
|
|
1
|
+
// tests/payout_gates.mjs — who may be paid at all (task 1004914, ADR 0385 D8).
|
|
2
|
+
// DB-free: the gate's decision, its wiring into every money route and pay-time
|
|
3
|
+
// check, the holds a person reads and appeals, and the needs list that tells them.
|
|
4
|
+
// Pins: under 13 and 13-15 are paid no money; 16-17 only with an active guardian
|
|
5
|
+
// link to a guardian-controlled account AND the guardian's co-signature; a
|
|
6
|
+
// sanctions match or a missing/expired clear screening stops pay; a screening the
|
|
7
|
+
// processor reports does not count until its scope is confirmed in writing; owed
|
|
8
|
+
// pay that is refused always files a hold with a notice and an appeal route.
|
|
9
|
+
// Run: node tests/payout_gates.mjs
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const express = require('express');
|
|
16
|
+
const gates = require('../src/bongos/payout-gates');
|
|
17
|
+
const approvals = require('../src/bongos/payout-approvals');
|
|
18
|
+
const buildGates = require('../src/bongos/routes/payout-gates');
|
|
19
|
+
const buildMine = require('../src/bongos/routes/my-payouts');
|
|
20
|
+
const buildApprovals = require('../src/bongos/routes/payout-approvals');
|
|
21
|
+
const needs = require('../modules/builder-settings/builder-needs');
|
|
22
|
+
|
|
23
|
+
let n = 0;
|
|
24
|
+
const ok = (name) => { n++; console.log(`ok - ${name}`); };
|
|
25
|
+
const NOW = new Date('2026-10-29T12:00:00Z');
|
|
26
|
+
const LATER = '2027-01-01T00:00:00Z';
|
|
27
|
+
const adult = [{ id: '1', tier: 'adult' }];
|
|
28
|
+
const clear = [{ id: '1', result: 'clear', screened_by: 'person:owner', valid_until: LATER }];
|
|
29
|
+
const d = (f) => gates.decide({ ageRecords: adult, screenings: clear, now: NOW, ...f });
|
|
30
|
+
|
|
31
|
+
// ── 1. the decision ──────────────────────────────────────────────────────
|
|
32
|
+
{
|
|
33
|
+
assert.deepEqual(d({}), { ok: true, pay_to: 'self', needs_guardian_cosign: false }); ok('an adult with a clear screening is paid to themselves');
|
|
34
|
+
assert.equal(d({ ageRecords: [] }).reason, 'age_unknown'); ok('no age recorded: not paid');
|
|
35
|
+
assert.equal(d({ ageRecords: [{ tier: 'under_13' }] }).reason, 'age_no_money'); ok('under 13: no money');
|
|
36
|
+
assert.equal(d({ ageRecords: [{ tier: '13_15' }] }).reason, 'age_no_money'); ok('13 to 15: no money at all');
|
|
37
|
+
assert.equal(d({ ageRecords: [{ tier: '16_17' }] }).reason, 'age_needs_guardian'); ok('16-17 with no guardian: not paid');
|
|
38
|
+
assert.equal(d({ ageRecords: [{ tier: '16_17' }], guardians: [{ guardian_id: '9', guardian_account: 'acct_g', active: false }] }).reason, 'age_needs_guardian'); ok('16-17 with an ended guardian link: not paid');
|
|
39
|
+
const minor = d({ ageRecords: [{ tier: '16_17' }], guardians: [{ guardian_id: '9', guardian_account: 'acct_g', active: true }] });
|
|
40
|
+
assert.deepEqual(minor.pay_to, { guardian_id: '9', guardian_account: 'acct_g' }); assert.equal(minor.needs_guardian_cosign, true); ok('16-17 is paid only into the guardian\'s account, and needs their co-signature');
|
|
41
|
+
assert.equal(d({ ageRecords: [{ tier: 'adult' }, { tier: '13_15' }] }).reason, 'age_no_money'); ok('the latest age record wins');
|
|
42
|
+
assert.equal(d({ screenings: [] }).reason, 'sanctions_unscreened'); ok('never screened: not paid');
|
|
43
|
+
assert.equal(d({ screenings: [{ ...clear[0], valid_until: '2026-01-01T00:00:00Z' }] }).reason, 'sanctions_unscreened'); ok('an expired clear screening does not count');
|
|
44
|
+
assert.equal(d({ screenings: [{ result: 'error', screened_by: 'person:o' }] }).reason, 'sanctions_unscreened'); ok('a screening that errored does not count as clear');
|
|
45
|
+
assert.equal(d({ screenings: [...clear, { result: 'match', screened_by: 'person:o' }] }).reason, 'sanctions_match'); ok('a sanctions match stops pay');
|
|
46
|
+
assert.equal(gates.PROCESSOR_SCREENING_CONFIRMED, null);
|
|
47
|
+
assert.equal(d({ screenings: [{ result: 'clear', screened_by: 'processor:any', valid_until: LATER }] }).reason, 'sanctions_unscreened'); ok('a processor screening is not relied on until what it screens is confirmed');
|
|
48
|
+
const f = d({ freezes: [{ id: '4', reason: 'manual' }] });
|
|
49
|
+
assert.equal(f.reason, 'frozen'); assert.equal(f.freeze_id, '4'); ok('an open hold stops pay');
|
|
50
|
+
assert.equal(d({ freezes: [{ id: '4', reason: 'manual' }], freezeEvents: [{ freeze_id: '4', kind: 'lifted' }] }).ok, true); ok('a lifted hold does not');
|
|
51
|
+
assert.equal(d({ freezes: [{ id: '4' }], freezeEvents: [{ freeze_id: '4', kind: 'appealed' }] }).reason, 'frozen'); ok('an appeal alone does not lift a hold');
|
|
52
|
+
for (const r of ['sanctions_match', 'sanctions_unscreened', 'age_no_money', 'age_needs_guardian', 'age_unknown']) {
|
|
53
|
+
assert.ok(gates.NOTICES[r] && gates.NOTICES[r].length >= 20 && gates.NOTICES[r].length <= 1000, r);
|
|
54
|
+
assert.match(gates.NOTICES[r], /appeal/i, r);
|
|
55
|
+
}
|
|
56
|
+
ok('every automatic hold has a notice that names the appeal, within the table\'s length rule');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 2. gateOwedPay files a hold, never a silent refusal ────────────────────
|
|
60
|
+
function fakeDb({ facts = {}, open = null } = {}) {
|
|
61
|
+
const calls = [];
|
|
62
|
+
const pool = {
|
|
63
|
+
query: async (sql, params) => {
|
|
64
|
+
calls.push(sql);
|
|
65
|
+
// the hold's INSERT names payout_freeze_events in its NOT EXISTS, so it is matched first
|
|
66
|
+
if (/INSERT INTO payout_freezes/.test(sql)) {
|
|
67
|
+
if (open) return { rows: [] };
|
|
68
|
+
assert.ok(params[2] && params[3], 'a hold is filed with its notice and appeal route');
|
|
69
|
+
return { rows: [{ id: 77 }] };
|
|
70
|
+
}
|
|
71
|
+
if (/SELECT f\.id FROM payout_freezes/.test(sql)) return { rows: open ? [{ id: open }] : [] };
|
|
72
|
+
if (/FROM payout_age_records/.test(sql)) return { rows: facts.ageRecords || [] };
|
|
73
|
+
if (/FROM payout_guardians/.test(sql)) return { rows: facts.guardians || [] };
|
|
74
|
+
if (/FROM payout_screenings/.test(sql)) return { rows: facts.screenings || [] };
|
|
75
|
+
if (/FROM payout_freezes WHERE builder_id/.test(sql)) return { rows: facts.freezes || [] };
|
|
76
|
+
if (/FROM payout_freeze_events/.test(sql)) return { rows: [] };
|
|
77
|
+
throw new Error('unexpected sql ' + sql);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return { pool, calls };
|
|
81
|
+
}
|
|
82
|
+
{
|
|
83
|
+
let db = fakeDb({ facts: { ageRecords: adult, screenings: [] } });
|
|
84
|
+
let g = await gates.gateOwedPay('8', { owedCents: 500, now: NOW, pool: db.pool });
|
|
85
|
+
assert.equal(g.ok, false); assert.equal(g.reason, 'sanctions_unscreened'); assert.equal(g.freeze_id, '77'); assert.match(g.notice, /sanctions check/);
|
|
86
|
+
assert.ok(db.calls.some((s) => /INSERT INTO payout_freezes/.test(s))); ok('refused owed pay files a hold with its notice');
|
|
87
|
+
db = fakeDb({ facts: { ageRecords: adult, screenings: [] }, open: 55 });
|
|
88
|
+
g = await gates.gateOwedPay('8', { owedCents: 500, now: NOW, pool: db.pool });
|
|
89
|
+
assert.equal(g.freeze_id, '55'); ok('a second refusal for the same reason reuses the open hold');
|
|
90
|
+
db = fakeDb({ facts: { ageRecords: adult, screenings: clear } });
|
|
91
|
+
g = await gates.gateOwedPay('8', { owedCents: 500, now: NOW, pool: db.pool });
|
|
92
|
+
assert.equal(g.ok, true); assert.ok(!db.calls.some((s) => /INSERT/.test(s))); ok('payable pay files nothing');
|
|
93
|
+
db = fakeDb({ facts: { ageRecords: adult, screenings: clear, freezes: [{ id: 3, reason: 'manual' }] } });
|
|
94
|
+
g = await gates.gateOwedPay('8', { now: NOW, pool: db.pool });
|
|
95
|
+
assert.equal(g.reason, 'frozen'); assert.ok(!db.calls.some((s) => /INSERT/.test(s))); ok('an existing hold is reported, not filed twice');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 3. the routes over real HTTP ─────────────────────────────────────────
|
|
99
|
+
const recorded = [];
|
|
100
|
+
const freezes = { 5: { id: '5', builder_id: '8', lifted: false, events: [] }, 6: { id: '6', builder_id: '8', lifted: true, events: [] } };
|
|
101
|
+
let verdict = { ok: false, reason: 'sanctions_unscreened', freeze_id: '5', facts: { freezes: [{ id: '5', reason: 'sanctions_unscreened', notice_text: gates.NOTICES.sanctions_unscreened, frozen_at: 'x' }], freezeEvents: [] } };
|
|
102
|
+
const fakeGates = {
|
|
103
|
+
...gates,
|
|
104
|
+
gateRecipient: async () => verdict,
|
|
105
|
+
gateOwedPay: async () => (verdict.ok ? { ok: true, pay_to: 'self' } : { ok: false, reason: verdict.reason, freeze_id: verdict.freeze_id }),
|
|
106
|
+
gateOwedPayMany: async (m) => new Map([...m.keys()].map((k) => [k, verdict.ok ? { ok: true, pay_to: 'self' } : { ok: false, reason: verdict.reason, freeze_id: verdict.freeze_id }])),
|
|
107
|
+
readFreeze: async (id) => freezes[id] || null,
|
|
108
|
+
recordAge: async (r) => { recorded.push(['age', r]); return { id: '1', ...r }; },
|
|
109
|
+
recordGuardian: async (r) => { recorded.push(['guardian', r]); return { id: '1' }; },
|
|
110
|
+
recordScreening: async (r) => { recorded.push(['screening', r]); return { id: '1' }; },
|
|
111
|
+
freezeManually: async (r) => { recorded.push(['freeze', r]); return { id: '9' }; },
|
|
112
|
+
addFreezeEvent: async (r) => { recorded.push(['event', r]); return { id: '1', ...r }; },
|
|
113
|
+
recordCosign: async (r) => { recorded.push(['cosign', r]); return { id: '1' }; },
|
|
114
|
+
cosignAsGuardian: async (r) => { recorded.push(['cosign', r]); return r.approvalId === 404 ? [] : [{ id: '1', builder_id: '8' }]; },
|
|
115
|
+
};
|
|
116
|
+
let who = { builder: { id: '7', rank: 'archon', system_role: null, github_login: 'owner' }, gdsSession: { source: 'web', session_id: '91' } };
|
|
117
|
+
const gated = [];
|
|
118
|
+
const app = express();
|
|
119
|
+
app.use(express.json());
|
|
120
|
+
app.use((req, res, next) => { res.fail = (code, o = {}) => res.status(o.status || 400).json({ error: code, message: o.message, details: o.details }); next(); });
|
|
121
|
+
app.use((req, res, next) => { req.builder = who.builder; req.gdsSession = who.gdsSession; next(); });
|
|
122
|
+
app.use('/', buildGates({ gates: fakeGates, requireBuilder: (q, r, nx) => nx(), requirePermission: (...p) => { gated.push(p.join(',')); return (q, r, nx) => nx(); } }));
|
|
123
|
+
app.use('/', buildMine({ gates: fakeGates, requireBuilder: (q, r, nx) => nx() }));
|
|
124
|
+
const store = { ...approvals, insertEvent: async (e) => { recorded.push(['approval', e.kind]); return { id: '50', kind: e.kind, created_at: NOW.toISOString() }; }, readRule: async () => ({ rule: { id: '1', active: true, terms: { caps: { per_payout_cents: 1000, per_run_cents: 5000 }, currency: 'USD', pause_hours: 1 } }, events: [] }) };
|
|
125
|
+
app.use('/', buildApprovals({ store, gates: fakeGates, requireBuilder: (q, r, nx) => nx(), requirePermission: () => (q, r, nx) => nx() }));
|
|
126
|
+
const server = http.createServer(app).listen(0);
|
|
127
|
+
const port = server.address().port;
|
|
128
|
+
const call = (method, path, body) => new Promise((resolve) => {
|
|
129
|
+
const r = http.request({ port, path, method, headers: { 'content-type': 'application/json' } }, (res) => {
|
|
130
|
+
let s = ''; res.on('data', (c) => (s += c)); res.on('end', () => { let b = null; try { b = JSON.parse(s); } catch { /* not json */ } resolve({ status: res.statusCode, body: b }); });
|
|
131
|
+
});
|
|
132
|
+
if (body) r.write(JSON.stringify(body));
|
|
133
|
+
r.end();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
assert.ok(gated.length >= 6 && gated.every((g) => g === 'payout.approve')); ok('every approver route gates on payout.approve');
|
|
138
|
+
|
|
139
|
+
// every money route refuses a gated recipient, with the reason and the hold
|
|
140
|
+
let r = await call('POST', '/payouts/approve', { recipient_id: 8, amount_cents: 100, currency: 'USD' });
|
|
141
|
+
assert.equal(r.status, 409); assert.equal(r.body.error, 'payout_gated'); assert.equal(r.body.details.freeze_id, '5'); assert.ok(!recorded.some((x) => x[0] === 'approval')); ok('approving a payout to a gated person is refused, naming the hold they were told about');
|
|
142
|
+
r = await call('POST', '/payouts/rules/1/runs', { run_key: 'w1', payouts: [{ recipient_id: 8, amount_cents: 100 }] });
|
|
143
|
+
assert.equal(r.status, 409); assert.equal(r.body.details.held[0].reason, 'sanctions_unscreened'); ok('a run with a gated recipient is refused, listing who and why');
|
|
144
|
+
r = await call('POST', '/payouts/grants', { recipient_id: 8, terms: { title: 'Grant', summary: 'one grant' } });
|
|
145
|
+
assert.equal(r.status, 409); ok('an equity grant to a gated person is refused');
|
|
146
|
+
r = await call('POST', '/payouts/contracts', { recipient_id: 8, terms: { title: 'Contract', summary: 'one contract' } });
|
|
147
|
+
assert.equal(r.status, 409); ok('a contract with a gated person is refused');
|
|
148
|
+
verdict = { ok: true, pay_to: 'self', facts: {} };
|
|
149
|
+
r = await call('POST', '/payouts/approve', { recipient_id: 8, amount_cents: 100, currency: 'USD' });
|
|
150
|
+
assert.equal(r.status, 201); ok('a payable person passes');
|
|
151
|
+
|
|
152
|
+
// recording the facts: a person's own web session only
|
|
153
|
+
who = { ...who, gdsSession: { source: 'cli', session_id: '92' } };
|
|
154
|
+
for (const [p, b] of [['/payouts/age-records', { builder_id: 8, tier: 'adult' }], ['/payouts/guardians', { builder_id: 8, guardian_id: 9, guardian_account: 'acct', active: true }], ['/payouts/screenings', { builder_id: 8, result: 'clear', valid_days: 30 }], ['/payouts/freezes', { builder_id: 8, notice_text: 'Your pay is held while we check something.' }], ['/payouts/freezes/5/decide', { decision: 'lifted', statement: 'ok' }], ['/me/payout-age', { tier: 'adult' }], ['/me/payout-freezes/5/appeal', { statement: 'wrong' }], ['/me/guardian-cosigns', { approval_id: 50 }]]) {
|
|
155
|
+
const before = recorded.length;
|
|
156
|
+
r = await call('POST', p, b);
|
|
157
|
+
assert.equal(r.status, 403, p); assert.equal(r.body.error, 'personal_approval_required'); assert.equal(recorded.length, before, p);
|
|
158
|
+
}
|
|
159
|
+
ok('an AI (CLI) session can record no age, guardian, screening, hold, decision, appeal or co-signature');
|
|
160
|
+
who = { builder: { id: '7', rank: 'archon', system_role: null, github_login: 'owner' }, gdsSession: { source: 'web', session_id: '91' } };
|
|
161
|
+
|
|
162
|
+
r = await call('POST', '/payouts/age-records', { builder_id: 8, tier: 'adult', basis: 'self_declared' });
|
|
163
|
+
assert.equal(r.status, 400); ok('an approver cannot choose the basis of an age record: it is always verified_by_person');
|
|
164
|
+
r = await call('POST', '/payouts/age-records', { builder_id: 8, tier: '13_15' });
|
|
165
|
+
assert.equal(r.status, 201); assert.equal(recorded.at(-1)[1].basis, 'verified_by_person'); ok('a person records a verified age tier');
|
|
166
|
+
r = await call('POST', '/me/payout-age', { tier: 'adult' });
|
|
167
|
+
assert.equal(r.status, 201); assert.equal(recorded.at(-1)[1].builderId, '7'); assert.equal(recorded.at(-1)[1].basis, 'self_declared'); ok('a builder declares only their own age, marked self-declared');
|
|
168
|
+
r = await call('POST', '/payouts/guardians', { builder_id: 8, guardian_id: 9, active: true });
|
|
169
|
+
assert.equal(r.status, 400); ok('an active guardian link must name the guardian-controlled account');
|
|
170
|
+
r = await call('POST', '/payouts/guardians', { builder_id: 8, guardian_id: 8, guardian_account: 'acct', active: true });
|
|
171
|
+
assert.equal(r.status, 400); ok('nobody is their own guardian');
|
|
172
|
+
r = await call('POST', '/payouts/screenings', { builder_id: 8, result: 'clear' });
|
|
173
|
+
assert.equal(r.status, 400); ok('a clear screening must say how long it counts');
|
|
174
|
+
r = await call('POST', '/payouts/screenings', { builder_id: 8, result: 'clear', valid_days: 90 });
|
|
175
|
+
assert.equal(r.status, 201); assert.equal(recorded.at(-1)[1].screenedBy, 'person:owner'); assert.ok(recorded.at(-1)[1].validUntil); ok('a person records a screening under their own name, with an expiry');
|
|
176
|
+
r = await call('POST', '/payouts/freezes', { builder_id: 8, notice_text: 'short' });
|
|
177
|
+
assert.equal(r.status, 400); ok('a hand-made hold needs a real notice: there is no silent freeze');
|
|
178
|
+
|
|
179
|
+
r = await call('POST', '/payouts/freezes/5/decide', { decision: 'lifted', statement: 'Screening came back clear.' });
|
|
180
|
+
assert.equal(r.status, 201); ok('an approver lifts a hold with a reason the person reads');
|
|
181
|
+
r = await call('POST', '/payouts/freezes/6/decide', { decision: 'upheld', statement: 'x' });
|
|
182
|
+
assert.equal(r.status, 409); ok('a lifted hold is not decided again');
|
|
183
|
+
who = { ...who, builder: { ...who.builder, id: '8' } };
|
|
184
|
+
r = await call('POST', '/payouts/freezes/5/decide', { decision: 'lifted', statement: 'mine' });
|
|
185
|
+
assert.equal(r.status, 403); ok('nobody decides the hold on their own pay');
|
|
186
|
+
r = await call('POST', '/me/payout-freezes/5/appeal', { statement: 'I am not that person.' });
|
|
187
|
+
assert.equal(r.status, 201); assert.equal(recorded.at(-1)[1].kind, 'appealed'); ok('the person appeals their own hold');
|
|
188
|
+
who = { ...who, builder: { ...who.builder, id: '7' } };
|
|
189
|
+
r = await call('POST', '/me/payout-freezes/5/appeal', { statement: 'not mine' });
|
|
190
|
+
assert.equal(r.status, 404); ok('nobody appeals a hold on someone else\'s pay');
|
|
191
|
+
|
|
192
|
+
r = await call('POST', '/me/guardian-cosigns', { approval_id: 50, rule_id: 1, run_key: 'w1' });
|
|
193
|
+
assert.equal(r.status, 400); ok('a co-signature names one payout, not two');
|
|
194
|
+
r = await call('POST', '/me/guardian-cosigns', { builder_id: 8, approval_id: 50 });
|
|
195
|
+
assert.equal(r.status, 400); ok('a guardian cannot name whose payout it is: that comes from the payout');
|
|
196
|
+
r = await call('POST', '/me/guardian-cosigns', { approval_id: 50 });
|
|
197
|
+
assert.equal(r.status, 201); assert.equal(recorded.at(-1)[1].guardianId, '7'); ok('a guardian co-signs as themselves (the database checks they are the guardian)');
|
|
198
|
+
r = await call('POST', '/me/guardian-cosigns', { approval_id: 404 });
|
|
199
|
+
assert.equal(r.status, 404); ok('a payout to nobody they are guardian for is refused');
|
|
200
|
+
|
|
201
|
+
verdict = { ok: false, reason: 'sanctions_unscreened', facts: { ageRecords: adult, freezes: [{ id: '5', reason: 'sanctions_unscreened', notice_text: gates.NOTICES.sanctions_unscreened, frozen_at: 'x' }], freezeEvents: [{ freeze_id: '5', kind: 'appealed', statement: 'wrong', created_at: 'y' }] } };
|
|
202
|
+
r = await call('GET', '/me/payouts');
|
|
203
|
+
assert.equal(r.status, 200); assert.equal(r.body.payable, false); assert.equal(r.body.holds[0].notice, gates.NOTICES.sanctions_unscreened);
|
|
204
|
+
assert.equal(r.body.holds[0].events[0].kind, 'appealed'); assert.match(r.body.holds[0].appeal, /payout-freezes\/5\/appeal/); ok('the person reads each hold, its notice, what happened since and where to appeal');
|
|
205
|
+
} finally {
|
|
206
|
+
server.close();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── 4. pay-time checks hold the line too ─────────────────────────────────
|
|
210
|
+
{
|
|
211
|
+
const at = (h) => new Date(Date.UTC(2026, 9, 10) + h * 3600_000).toISOString();
|
|
212
|
+
const TERMS = { caps: { per_payout_cents: 5000, per_run_cents: 9000 }, currency: 'USD', formula: { kind: 'task_pay_terms' }, schedule: 'weekly', pause_hours: 1 };
|
|
213
|
+
const P = [{ recipient_id: '8', amount_cents: 100 }, { recipient_id: '9', amount_cents: 200 }, { recipient_id: '10', amount_cents: 300 }];
|
|
214
|
+
const events = [
|
|
215
|
+
{ id: '1', kind: 'rule_signed', terms: TERMS, actor_id: '7', created_at: at(0) },
|
|
216
|
+
{ id: '2', kind: 'run_noticed', rule_id: '1', run_key: 'w1', amount_cents: 600, currency: 'USD', terms: { payouts: P }, created_at: at(0) },
|
|
217
|
+
];
|
|
218
|
+
const pool = { query: async (sql) => (/FROM payout_approvals WHERE id = \$1 OR rule_id/.test(sql) ? { rows: events } : { rows: [] }) };
|
|
219
|
+
const g = {
|
|
220
|
+
gateOwedPay: async (rid) => (rid === '9' ? { ok: false, reason: 'age_no_money', freeze_id: '31' } : rid === '10' ? { ok: true, pay_to: { guardian_account: 'acct_g' }, needs_guardian_cosign: true } : { ok: true, pay_to: 'self' }),
|
|
221
|
+
};
|
|
222
|
+
g.gateOwedPayMany = async (m) => new Map(await Promise.all([...m.keys()].map(async (k) => [k, await g.gateOwedPay(k)])));
|
|
223
|
+
g.cosignedInRun = async () => new Set();
|
|
224
|
+
const v = await approvals.checkRun({ ruleId: '1', runKey: 'w1', payouts: P, now: new Date(at(5)) }, { pool, gates: g });
|
|
225
|
+
assert.equal(v.ok, true);
|
|
226
|
+
assert.deepEqual(v.payouts.map((p) => p.recipient_id), ['8']); assert.equal(v.payouts[0].pay_to, 'self');
|
|
227
|
+
assert.deepEqual(v.held.map((h) => [h.recipient_id, h.reason]), [['9', 'age_no_money'], ['10', 'guardian_cosign_missing']]);
|
|
228
|
+
ok('at pay time a run pays only the lines that pass; the rest are held with their reason, never dropped silently');
|
|
229
|
+
const v2 = await approvals.checkRun({ ruleId: '1', runKey: 'w1', payouts: P, now: new Date(at(5)) }, { pool, gates: { ...g, cosignedInRun: async ({ builderIds }) => new Set(builderIds) } });
|
|
230
|
+
assert.deepEqual(v2.payouts.find((p) => p.recipient_id === '10').pay_to, { guardian_account: 'acct_g' }); ok('a co-signed 16-17 line is paid into the guardian\'s account');
|
|
231
|
+
|
|
232
|
+
const row = { id: 5, kind: 'payout_approved', recipient_id: 8, amount_cents: '100', currency: 'USD', terms: {}, actor_id: 7, created_at: 'x' };
|
|
233
|
+
const apool = { query: async () => ({ rows: [row] }) };
|
|
234
|
+
let c = await approvals.checkApproval({ id: 5, recipientId: 8, amountCents: 100, currency: 'USD' }, { pool: apool, gates: { gateOwedPay: async () => ({ ok: false, reason: 'sanctions_match', freeze_id: '40' }) } });
|
|
235
|
+
assert.equal(c.reason, 'recipient_gated'); assert.equal(c.gate.freeze_id, '40'); ok('an approved payout to someone since gated does not pay, and names the hold');
|
|
236
|
+
c = await approvals.checkApproval({ id: 5, recipientId: 8, amountCents: 100, currency: 'USD' }, { pool: apool, gates: { gateOwedPay: async () => ({ ok: true, pay_to: { guardian_account: 'g' }, needs_guardian_cosign: true }), hasCosign: async () => false } });
|
|
237
|
+
assert.equal(c.reason, 'guardian_cosign_missing'); ok('a 16-17 payout without the guardian\'s co-signature does not pay');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── 5. the person is told: the needs list ────────────────────────────────
|
|
241
|
+
{
|
|
242
|
+
assert.equal(needs.computeNeeds({}).items.some((i) => i.id === 'payout_holds'), false); ok('no holds: no need');
|
|
243
|
+
const r = needs.computeNeeds({ openPayoutHolds: { count: 2, first_id: '5' }, ownKeySet: false, rank: 'archon', artPipeline: true });
|
|
244
|
+
const h = r.items.find((i) => i.id === 'payout_holds');
|
|
245
|
+
assert.equal(h.state, 'action_needed'); assert.equal(h.count, 2); assert.match(h.message, /appeal/);
|
|
246
|
+
assert.equal(r.items[0].id, 'payout_holds'); ok('open holds raise a need that names the appeal, ahead of the person\'s own setup chores');
|
|
247
|
+
const me = readFileSync(new URL('../src/bongos/routes/me.js', import.meta.url), 'utf8');
|
|
248
|
+
assert.match(me, /openPayoutHolds: await openPayoutHoldsP/); assert.match(me, /openHoldsFor\(req\.builder\.id\)/); ok('GET /me feeds the open holds into the needs list');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── 6. the migration says what the code relies on ────────────────────────
|
|
252
|
+
{
|
|
253
|
+
const sql = readFileSync(new URL('../migrations/core_284_payout_gates.sql', import.meta.url), 'utf8');
|
|
254
|
+
assert.match(sql, /notice_text\s+text\s+NOT NULL/); assert.match(sql, /appeal_route\s+text\s+NOT NULL/); ok('a hold cannot be stored without its notice and appeal route');
|
|
255
|
+
for (const t of ['payout_age_records', 'payout_guardians', 'payout_screenings', 'payout_freezes', 'payout_freeze_events', 'payout_guardian_cosigns']) assert.ok(sql.includes(`'${t}'`), t);
|
|
256
|
+
assert.match(sql, /BEFORE UPDATE OR DELETE ON %I/); assert.match(sql, /BEFORE TRUNCATE ON %I/); ok('every gate table is append-only by trigger');
|
|
257
|
+
assert.match(sql, /only the person whose pay is frozen may appeal it/); assert.match(sql, /may not decide the appeal on their own freeze/); assert.match(sql, /only the builder''s current guardian may co-sign/); ok('the database repeats who may appeal, decide and co-sign');
|
|
258
|
+
for (const t of gates.AGE_TIERS) assert.ok(sql.includes(`'${t}'`), t);
|
|
259
|
+
for (const r of gates.FREEZE_REASONS) assert.ok(sql.includes(`'${r}'`), r);
|
|
260
|
+
ok('every tier and hold reason the code writes is one the table accepts');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
// ── 7. hardening from review: one hold per race, no stray database text ───
|
|
265
|
+
{
|
|
266
|
+
const log = [];
|
|
267
|
+
const client = {
|
|
268
|
+
query: async (sql) => {
|
|
269
|
+
log.push(sql.trim().split(/\s+/).slice(0, 2).join(' '));
|
|
270
|
+
if (/INSERT INTO payout_freezes/.test(sql)) return { rows: [{ id: 12 }] };
|
|
271
|
+
return { rows: [] };
|
|
272
|
+
},
|
|
273
|
+
release: () => log.push('release'),
|
|
274
|
+
};
|
|
275
|
+
const pool = { connect: async () => client, query: async () => { throw new Error('a pool must not be queried outside the lock'); } };
|
|
276
|
+
const h = await gates.holdOwedPay('8', 'age_unknown', { pool });
|
|
277
|
+
assert.equal(h.freeze_id, '12');
|
|
278
|
+
assert.deepEqual(log, ['BEGIN', 'SELECT pg_advisory_xact_lock($1::int,', 'INSERT INTO', 'COMMIT', 'release']);
|
|
279
|
+
ok('filing a hold takes a per-builder lock in one transaction, so a race cannot file two');
|
|
280
|
+
const bare = { query: async (sql) => (/INSERT/.test(sql) ? { rows: [{ id: 13 }] } : { rows: [] }), release: () => {}, connect: () => { throw new Error('a checked-out client is not reconnected'); } };
|
|
281
|
+
assert.equal((await gates.holdOwedPay('8', 'age_unknown', { pool: bare })).freeze_id, '13'); ok('a checked-out client runs the statements as they are');
|
|
282
|
+
|
|
283
|
+
const fail = (message) => ({ ...gates, readFreeze: async () => ({ id: '5', builder_id: '8', lifted: false }), addFreezeEvent: async () => { const e = new Error(message); e.code = '23514'; throw e; } });
|
|
284
|
+
const mk = (g) => { const a = express(); a.use(express.json()); a.use((req, res, next) => { res.fail = (code, o = {}) => res.status(o.status || 400).json({ error: code, message: o.message }); req.builder = { id: '8' }; req.gdsSession = { source: 'web', session_id: '1' }; next(); }); a.use('/', buildMine({ gates: g, requireBuilder: (q, r, nx) => nx() })); return http.createServer(a).listen(0); };
|
|
285
|
+
const post = (srv, path, body) => new Promise((resolve) => { const r = http.request({ port: srv.address().port, path, method: 'POST', headers: { 'content-type': 'application/json' } }, (res) => { let s = ''; res.on('data', (c) => (s += c)); res.on('end', () => resolve(JSON.parse(s))); }); r.write(JSON.stringify(body)); r.end(); });
|
|
286
|
+
let srv = mk(fail('only the person whose pay is frozen may appeal it'));
|
|
287
|
+
assert.equal((await post(srv, '/me/payout-freezes/5/appeal', { statement: 'x' })).message, 'only the person whose pay is frozen may appeal it'); srv.close();
|
|
288
|
+
ok('a refusal in our own words is passed on');
|
|
289
|
+
srv = mk(fail('relation "secret_table" violates something internal'));
|
|
290
|
+
const m = (await post(srv, '/me/payout-freezes/5/appeal', { statement: 'x' })).message; srv.close();
|
|
291
|
+
assert.equal(m, 'The payout rules refused this.'); ok('any other database text stays out of the response');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── 8. from the second review: real refusal words, and one read per run ──
|
|
295
|
+
{
|
|
296
|
+
const sql = readFileSync(new URL('../migrations/core_284_payout_gates.sql', import.meta.url), 'utf8');
|
|
297
|
+
const raised = [...sql.matchAll(/RAISE EXCEPTION '((?:[^']|'')+)'/g)].map((x) => x[1].replace(/''/g, "'"));
|
|
298
|
+
assert.ok(raised.length >= 5, 'the migration raises its refusals');
|
|
299
|
+
// fill each % the way Postgres would: the table name, the operation, a freeze id
|
|
300
|
+
const filled = (t) => t.replace(/^% is append-only: %/, 'payout_freezes is append-only: UPDATE').replace('%', '41');
|
|
301
|
+
for (const t of raised) assert.ok(buildGates.OUR_REFUSALS.some((re) => re.test(filled(t))), 'not passed on: ' + t);
|
|
302
|
+
ok('every refusal the gate triggers raise, filled in as Postgres would, is one the route passes on in its own words');
|
|
303
|
+
assert.ok(buildGates.OUR_REFUSALS.some((re) => re.test('payout_guardian_cosigns is append-only: DELETE refused (a correction is a new row)')));
|
|
304
|
+
assert.ok(!buildGates.OUR_REFUSALS.some((re) => re.test('payout freeze d is already lifted')));
|
|
305
|
+
ok('the numbered and table-named refusals match real values, not a stray letter');
|
|
306
|
+
|
|
307
|
+
const queries = [];
|
|
308
|
+
const pool = { query: async (q, params) => {
|
|
309
|
+
queries.push(q);
|
|
310
|
+
const rows = (t) => params[0].map((b, i) => ({ id: String(i + 1), builder_id: b, ...t }));
|
|
311
|
+
if (/FROM payout_age_records/.test(q)) return { rows: rows({ tier: 'adult' }) };
|
|
312
|
+
if (/FROM payout_screenings/.test(q)) return { rows: rows({ result: 'clear', screened_by: 'person:o', valid_until: LATER }) };
|
|
313
|
+
return { rows: [] };
|
|
314
|
+
} };
|
|
315
|
+
const owed = new Map(Array.from({ length: 40 }, (_, i) => [String(100 + i), 10]));
|
|
316
|
+
const out = await gates.gateOwedPayMany(owed, { now: NOW, pool });
|
|
317
|
+
assert.equal(out.size, 40); assert.ok([...out.values()].every((v) => v.ok));
|
|
318
|
+
assert.equal(queries.length, 4); ok('forty recipients are checked with four reads, not four per person');
|
|
319
|
+
assert.equal((await gates.readFacts('7', { pool })).ageRecords.length, 1); ok('a single recipient reads through the same batched path');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
console.log(`\n${n} passed`);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// tests/payout_gates_db.mjs — task 1004914 (ADR 0385 D8). Real DB.
|
|
2
|
+
// The payout gate tables built from the real migrations (core_283 then core_284)
|
|
3
|
+
// in a throwaway schema, proving the DATABASE refuses: any edit or delete of a
|
|
4
|
+
// gate record; a hold with no notice; an appeal by anyone but the frozen person;
|
|
5
|
+
// a decision by the frozen person; a co-signature by anyone but the current
|
|
6
|
+
// guardian; anything after a lift. Then the gate end to end over real rows.
|
|
7
|
+
// Self-skips without a reachable Postgres.
|
|
8
|
+
// Run: DATABASE_URL=... node tests/payout_gates_db.mjs
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { Pool } = require('pg');
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
19
|
+
let client;
|
|
20
|
+
try { client = await pgPool.connect(); } catch (e) {
|
|
21
|
+
console.log(`payout_gates_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
22
|
+
await pgPool.end().catch(() => {});
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
const SCHEMA = `t1004914_${process.pid}`;
|
|
26
|
+
let pass = 0;
|
|
27
|
+
const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
|
|
28
|
+
const refused = async (sql, params, m) => {
|
|
29
|
+
await client.query('SAVEPOINT s');
|
|
30
|
+
let threw = false;
|
|
31
|
+
try { await client.query(sql, params); } catch { threw = true; }
|
|
32
|
+
await client.query('ROLLBACK TO SAVEPOINT s');
|
|
33
|
+
ok(threw, m);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await client.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
38
|
+
await client.query(`SET search_path TO ${SCHEMA}`);
|
|
39
|
+
await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, system_role text)');
|
|
40
|
+
for (const m of ['core_283_payout_approvals.sql', 'core_284_payout_gates.sql']) {
|
|
41
|
+
await client.query(readFileSync(join(here, '..', 'migrations', m), 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
// 1 owner, 2 a 16-17 builder, 3 their guardian, 4 an adult, 5 someone else
|
|
44
|
+
await client.query('INSERT INTO builders (system_role) VALUES (NULL), (NULL), (NULL), (NULL), (NULL)');
|
|
45
|
+
await client.query('BEGIN');
|
|
46
|
+
|
|
47
|
+
const g = require('../src/bongos/payout-gates.js');
|
|
48
|
+
const o = { pool: client };
|
|
49
|
+
|
|
50
|
+
const age = await g.recordAge({ builderId: 4, tier: 'adult', basis: 'verified_by_person', recordedBy: 1 }, o);
|
|
51
|
+
await refused('UPDATE payout_age_records SET tier = $1 WHERE id = $2', ['under_13', age.id], 'an age record cannot be edited');
|
|
52
|
+
await refused('DELETE FROM payout_age_records WHERE id = $1', [age.id], 'an age record cannot be deleted');
|
|
53
|
+
await refused('TRUNCATE payout_screenings', [], 'screenings cannot be truncated');
|
|
54
|
+
await refused("INSERT INTO payout_age_records (builder_id, tier, basis) VALUES (4, 'old', 'self_declared')", [], 'an unknown age tier is refused');
|
|
55
|
+
await refused("INSERT INTO payout_screenings (builder_id, result, screened_by) VALUES (4, 'clear', 'person:o')", [], 'a clear screening with no expiry is refused');
|
|
56
|
+
|
|
57
|
+
let v = await g.gateOwedPay(4, { owedCents: 1000, pool: client });
|
|
58
|
+
ok(v.ok === false && v.reason === 'sanctions_unscreened' && v.freeze_id, 'an unscreened adult owed money is held, with a hold filed');
|
|
59
|
+
const hold = await g.readFreeze(v.freeze_id, o);
|
|
60
|
+
ok(hold.notice_text === g.NOTICES.sanctions_unscreened && hold.appeal_route && hold.owed_cents === 1000, 'the hold carries its notice, appeal route and what is owed');
|
|
61
|
+
ok((await g.gateOwedPay(4, { owedCents: 1000, pool: client })).freeze_id === v.freeze_id, 'a second refusal reuses the open hold');
|
|
62
|
+
ok((await g.openHoldsFor(4, o)).count === 1, 'the needs list sees one open hold');
|
|
63
|
+
await refused("INSERT INTO payout_freezes (builder_id, reason, notice_text, appeal_route) VALUES (4, 'manual', NULL, 'x')", [], 'a hold with no notice is refused: no silent freeze');
|
|
64
|
+
await refused("INSERT INTO payout_freezes (builder_id, reason, notice_text, appeal_route) VALUES (4, 'manual', 'short', 'x')", [], 'a hold with a token notice is refused');
|
|
65
|
+
|
|
66
|
+
await refused("INSERT INTO payout_freeze_events (freeze_id, kind, actor_id, statement) VALUES ($1, 'appealed', 5, 'x')", [v.freeze_id], 'only the frozen person may appeal');
|
|
67
|
+
await g.addFreezeEvent({ freezeId: v.freeze_id, kind: 'appealed', actorId: 4, statement: 'I was screened last week.' }, o);
|
|
68
|
+
ok(true, 'the frozen person appeals');
|
|
69
|
+
await refused("INSERT INTO payout_freeze_events (freeze_id, kind, actor_id, statement) VALUES ($1, 'lifted', 4, 'x')", [v.freeze_id], 'the frozen person cannot lift their own hold');
|
|
70
|
+
|
|
71
|
+
await g.recordScreening({ builderId: 4, result: 'clear', screenedBy: 'person:owner', validUntil: new Date(Date.now() + 86_400_000).toISOString(), recordedBy: 1 }, o);
|
|
72
|
+
ok((await g.gateOwedPay(4, { pool: client })).reason === 'frozen', 'a clear screening does not lift a hold by itself: a person does');
|
|
73
|
+
await g.addFreezeEvent({ freezeId: v.freeze_id, kind: 'lifted', actorId: 1, statement: 'Screening came back clear.' }, o);
|
|
74
|
+
await refused("INSERT INTO payout_freeze_events (freeze_id, kind, actor_id, statement) VALUES ($1, 'upheld', 1, 'x')", [v.freeze_id], 'nothing follows a lift');
|
|
75
|
+
v = await g.gateOwedPay(4, { pool: client });
|
|
76
|
+
ok(v.ok === true && v.pay_to === 'self', 'lifted and screened clear: the adult is paid to themselves');
|
|
77
|
+
ok((await g.openHoldsFor(4, o)).count === 0, 'the need goes away when the hold is lifted');
|
|
78
|
+
|
|
79
|
+
await g.recordAge({ builderId: 2, tier: '16_17', basis: 'verified_by_person', recordedBy: 1 }, o);
|
|
80
|
+
await g.recordScreening({ builderId: 2, result: 'clear', screenedBy: 'person:owner', validUntil: new Date(Date.now() + 86_400_000).toISOString(), recordedBy: 1 }, o);
|
|
81
|
+
ok((await g.gateRecipient(2, o)).reason === 'age_needs_guardian', '16-17 with no guardian is not payable');
|
|
82
|
+
await refused("INSERT INTO payout_guardians (builder_id, guardian_id, active, recorded_by) VALUES (2, 3, true, 1)", [], 'an active guardian link must name the guardian-controlled account');
|
|
83
|
+
await refused("INSERT INTO payout_guardians (builder_id, guardian_id, guardian_account, active, recorded_by) VALUES (2, 2, 'acct', true, 1)", [], 'nobody is their own guardian');
|
|
84
|
+
await g.recordGuardian({ builderId: 2, guardianId: 3, guardianAccount: 'acct_guardian', active: true, recordedBy: 1 }, o);
|
|
85
|
+
v = await g.gateRecipient(2, o);
|
|
86
|
+
ok(v.ok && v.needs_guardian_cosign && v.pay_to.guardian_account === 'acct_guardian', '16-17 with a guardian is paid only into the guardian\'s account, co-signed');
|
|
87
|
+
|
|
88
|
+
const approval = (await client.query("INSERT INTO payout_approvals (kind, actor_id, session_id, session_source, recipient_id, amount_cents, currency) VALUES ('payout_approved', 1, 11, 'web', 2, 500, 'USD') RETURNING id")).rows[0].id;
|
|
89
|
+
await refused("INSERT INTO payout_guardian_cosigns (builder_id, guardian_id, approval_id, session_id, session_source) VALUES (2, 5, $1, 12, 'web')", [approval], 'only the current guardian may co-sign');
|
|
90
|
+
await refused("INSERT INTO payout_guardian_cosigns (builder_id, guardian_id, approval_id, session_id, session_source) VALUES (2, 3, $1, 12, 'cli')", [approval], 'a co-signature from a CLI session is refused');
|
|
91
|
+
const a = require('../src/bongos/payout-approvals.js');
|
|
92
|
+
ok((await a.checkApproval({ id: approval, recipientId: 2, amountCents: 500, currency: 'USD' }, o)).reason === 'guardian_cosign_missing', 'the approved payout does not pay before the guardian co-signs');
|
|
93
|
+
ok((await g.cosignAsGuardian({ guardianId: 5, sessionId: 12, approvalId: approval }, o)).length === 0, 'someone who is not the guardian co-signs nothing');
|
|
94
|
+
const signed = await g.cosignAsGuardian({ guardianId: 3, sessionId: 12, approvalId: approval }, o);
|
|
95
|
+
ok(signed.length === 1 && signed[0].builder_id === '2', 'the guardian co-signs, and whose payout it is comes from the payout');
|
|
96
|
+
const c = await a.checkApproval({ id: approval, recipientId: 2, amountCents: 500, currency: 'USD' }, o);
|
|
97
|
+
ok(c.ok && c.pay_to.guardian_account === 'acct_guardian', 'after the co-signature it pays, into the guardian\'s account');
|
|
98
|
+
await refused("INSERT INTO payout_guardian_cosigns (builder_id, guardian_id, approval_id, session_id, session_source) VALUES (2, 3, $1, 13, 'web')", [approval], 'a payout is co-signed once');
|
|
99
|
+
|
|
100
|
+
await g.recordGuardian({ builderId: 2, guardianId: 3, guardianAccount: null, active: false, recordedBy: 1 }, o);
|
|
101
|
+
ok((await g.gateRecipient(2, o)).reason === 'age_needs_guardian', 'ending the guardian link stops pay again');
|
|
102
|
+
|
|
103
|
+
await g.recordAge({ builderId: 4, tier: '13_15', basis: 'verified_by_person', recordedBy: 1 }, o);
|
|
104
|
+
v = await g.gateOwedPay(4, { owedCents: 200, pool: client });
|
|
105
|
+
ok(v.reason === 'age_no_money' && v.freeze_id, '13-15 owed money is held and told why');
|
|
106
|
+
await client.query('ROLLBACK');
|
|
107
|
+
} finally {
|
|
108
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
109
|
+
await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
110
|
+
client.release();
|
|
111
|
+
await pgPool.end();
|
|
112
|
+
}
|
|
113
|
+
console.log(`payout_gates_db.mjs: ${pass} passed`);
|
|
@@ -40,7 +40,15 @@ test('the self-check is described as failing loudly on any mismatch, zero includ
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
test('the reference implementation is named', () => {
|
|
43
|
-
assert.ok(NORM.includes('seed-
|
|
43
|
+
assert.ok(NORM.includes('scripts/bongos/seed-tasks.js'), 'names the one seeder (task 1004979)');
|
|
44
|
+
assert.ok(NORM.includes('scripts/bongos/seed-tasks.example.json'), 'names the example spec to start from');
|
|
45
|
+
assert.ok(NORM.includes('--dry-run'), 'tells the session to dry-run first');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the per-version seed scripts are no longer the template', () => {
|
|
49
|
+
for (const old of ['seed-bongos-v1-idea-routing-tasks.js', 'seed-bv1-profiles-tasks.js', 'seed-bv2-ideator-surface-tasks.js', 'seed-<ver_slug>-tasks.js']) {
|
|
50
|
+
assert.ok(!NORM.includes(old), `the skill no longer points at ${old}`);
|
|
51
|
+
}
|
|
44
52
|
});
|
|
45
53
|
|
|
46
54
|
test('the retro incident (RETRO2 / learning 1000188) is cited as the reason', () => {
|
|
@@ -48,8 +56,10 @@ test('the retro incident (RETRO2 / learning 1000188) is cited as the reason', ()
|
|
|
48
56
|
assert.ok(NORM.includes('goal 1000071'));
|
|
49
57
|
});
|
|
50
58
|
|
|
51
|
-
test('step 5 (run the seed
|
|
52
|
-
const
|
|
59
|
+
test('step 5 (run the seed) points at the self-check rather than treating exit 0 as sufficient on its own', () => {
|
|
60
|
+
const at = NORM.indexOf('Run the seed:');
|
|
61
|
+
assert.ok(at >= 0, 'step 5 exists');
|
|
62
|
+
const step5 = NORM.slice(at);
|
|
53
63
|
assert.ok(step5.slice(0, 400).includes('verifySeedCriteriaLinks'), 'step 5 references the self-check by name');
|
|
54
64
|
});
|
|
55
65
|
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
// 1. The route works: one read returns all five rows, the timer round-trips through
|
|
7
7
|
// the shared parser, and the category set can be renamed, reordered and retired.
|
|
8
8
|
//
|
|
9
|
-
// 2. AN ABSENT FUNDAMENTAL IS REPORTED ABSENT. `upmarket_percent`
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// 2. AN ABSENT FUNDAMENTAL IS REPORTED ABSENT. `upmarket_percent` (the credit-weighted
|
|
10
|
+
// pool) has no knob behind it and is gated shut by ADR 0385, and credits are a
|
|
11
|
+
// settled owner decision that they are NOT equity (ADR 0387; task 1004967). Both
|
|
12
|
+
// must render as themselves. A plausible zero on either would be a
|
|
12
13
|
// fabricated claim about the project's economics on the page its owner trusts most
|
|
13
14
|
// — a worse failure than the illegibility this goal exists to fix.
|
|
14
15
|
//
|
|
@@ -130,14 +131,28 @@ test('an absent fundamental is reported absent, never as a plausible zero', asyn
|
|
|
130
131
|
await handlerFor('get', '/project-settings')({ query: {} }, res);
|
|
131
132
|
const by = Object.fromEntries(res.payload.rows.map((r) => [r.key, r]));
|
|
132
133
|
|
|
133
|
-
|
|
134
|
+
// The credit-weighted pool (task 1004967, ADR 0385 pay-type gates): renamed from
|
|
135
|
+
// "share of upmarket revenue", reported gated, never a number.
|
|
136
|
+
assert.equal(by.upmarket_percent.title, 'Credit-weighted pool');
|
|
137
|
+
assert.equal(by.upmarket_percent.state, 'gated');
|
|
134
138
|
assert.equal(by.upmarket_percent.value, undefined, 'an unconfigured knob must carry NO value');
|
|
135
|
-
assert.match(by.upmarket_percent.sentence,
|
|
139
|
+
assert.match(by.upmarket_percent.sentence, /^not available \(gated\)/i);
|
|
136
140
|
assert.doesNotMatch(by.upmarket_percent.sentence, /\b0\s*%/, 'never render the absent knob as 0%');
|
|
141
|
+
assert.doesNotMatch(by.upmarket_percent.title, /upmarket/i);
|
|
137
142
|
|
|
143
|
+
// Credits are a contribution meter, not ownership; equity is the project's own
|
|
144
|
+
// separate grant (ADR 0385 decisions 2-3, ADR 0387). The old row said a credit
|
|
145
|
+
// IS a unit of ownership — that must never come back.
|
|
138
146
|
assert.equal(by.equity_calculation.state, 'not-yet-real');
|
|
147
|
+
assert.match(by.equity_calculation.sentence, /contribution meter, not ownership/i);
|
|
148
|
+
assert.match(by.equity_calculation.sentence, /granted by the project separately/i);
|
|
139
149
|
assert.match(by.equity_calculation.sentence, /no legal equity is issued/i);
|
|
140
150
|
assert.match(by.equity_calculation.sentence, /no cap table/i);
|
|
151
|
+
assert.doesNotMatch(by.equity_calculation.sentence, /ownership stream|unit of ownership/i);
|
|
152
|
+
// The ADR link points at the file that exists (the old path 404'd).
|
|
153
|
+
assert.equal(by.equity_calculation.reads.file, 'docs/adr/0054-session-token-reward-cost-plus.md');
|
|
154
|
+
assert.ok(fs.existsSync(path.join(ROOT, by.equity_calculation.reads.file)), 'the equity row links a real ADR');
|
|
155
|
+
assert.ok(fs.existsSync(path.join(ROOT, by.upmarket_percent.reads.file)), 'the pool row links a real ADR');
|
|
141
156
|
} finally { restore(); }
|
|
142
157
|
});
|
|
143
158
|
|
|
@@ -151,9 +166,18 @@ test('the credit row reports the formula in force, and says the estimate is not
|
|
|
151
166
|
assert.match(credit.sentence, /× 1\.20/, 'the multiplier comes from the resolved policy');
|
|
152
167
|
// cost-plus-only: the per-task estimate is advertised and never paid (ADR 0146).
|
|
153
168
|
assert.match(credit.sentence, /advertised guide only/i);
|
|
169
|
+
// ADR 0387 wording (task 1004967): USD sizes the credit, it is not its worth.
|
|
170
|
+
assert.match(credit.sentence, /sized by the session’s cost in USD/);
|
|
171
|
+
assert.match(credit.sentence, /not money/i);
|
|
172
|
+
assert.doesNotMatch(credit.sentence, /\bpays\b|worth \$|= \$1/i);
|
|
154
173
|
} finally { restore(); }
|
|
155
174
|
});
|
|
156
175
|
|
|
176
|
+
test('the client draws the gated pool as "not available (gated)", not as unconfigured', () => {
|
|
177
|
+
const page = read('modules', 'hall-ui', 'public', 'project-settings.js');
|
|
178
|
+
assert.match(page, /gated:\s*\['ps-pill--absent',\s*'not available \(gated\)'\]/);
|
|
179
|
+
});
|
|
180
|
+
|
|
157
181
|
test('the credit row degrades named when the economy module is absent', async () => {
|
|
158
182
|
const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS, reward: null });
|
|
159
183
|
try {
|