@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,214 @@
|
|
|
1
|
+
// tests/access_holders.mjs — who holds the keys OUTSIDE Bongos, and blockers that need
|
|
2
|
+
// them (task 1005000, goal 1000111).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT GUARDS (the task's done-when, one by one):
|
|
5
|
+
// 1. Only the owner can record a holder: the writes refuse anyone without
|
|
6
|
+
// government.manage; the read is the docket's atom.
|
|
7
|
+
// 2. An ask on access:<key> resolves to exactly the recorded holders, and to the
|
|
8
|
+
// owner when none is recorded.
|
|
9
|
+
// 3. Such a blocker shows under its holder as "needs repo-owner access".
|
|
10
|
+
// 4. No secret is stored: the table has no column a credential could live in.
|
|
11
|
+
// And the trap: it is a record of who to ask, never authority — no gate, rank check
|
|
12
|
+
// or permission atom reads it.
|
|
13
|
+
//
|
|
14
|
+
// Also: the vocabulary, the ask grammar (asks.SEAT) and both migration CHECKs agree.
|
|
15
|
+
// DB-free: the government db is stubbed by property. Run: node --test tests/access_holders.mjs
|
|
16
|
+
|
|
17
|
+
import { test, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const gov = (f) => path.join(ROOT, 'modules', 'government', f);
|
|
28
|
+
const access = require(gov('access.js'));
|
|
29
|
+
const asks = require(gov('asks.js'));
|
|
30
|
+
const routing = require(gov('routing.js'));
|
|
31
|
+
const waiting = require(gov('waiting.js'));
|
|
32
|
+
const leads = require(gov('leads.js'));
|
|
33
|
+
const people = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'blockers-by-person.js'));
|
|
34
|
+
const page = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government-access.js'));
|
|
35
|
+
const SQL = fs.readFileSync(gov('migrations/government_036_access_holders.sql'), 'utf8');
|
|
36
|
+
|
|
37
|
+
const owner = leads.ownerOf([{ id: 1, github_login: 'lars', display_name: 'Lars' }]);
|
|
38
|
+
const KIM = { access_key: 'github-admin', builder_id: 5, github_login: 'kim', display_name: 'Kim', granted_at: '2026-10-29T00:00:00Z' };
|
|
39
|
+
const blocker = (id, waitsOn) => ({ id: `blockers:${id}`, source: 'blockers', building: 'planning', state: 'needs', title: `Blocker ${id}`, opened_at: '2026-10-29T00:00:00Z', ref: { type: 'blocker', id: String(id) }, ask: { question: 'Allow it?', waits_on: waitsOn, if_nobody_answers: 'It stays stuck.' } });
|
|
40
|
+
|
|
41
|
+
test('one vocabulary: access.js, the holders CHECK and the asks CHECK name the same keys', () => {
|
|
42
|
+
const holders = /access_key IN \(([^)]+)\)/.exec(SQL)[1].match(/'([^']+)'/g).map((s) => s.slice(1, -1));
|
|
43
|
+
assert.deepEqual(holders, access.KEYS);
|
|
44
|
+
const seat = new RegExp(/CHECK \(waits_on ~ '([^']+)'\)/.exec(SQL)[1]);
|
|
45
|
+
for (const s of ['owner', 'lead:planning', 'board:charter', 'committee:review.testers', ...access.KEYS.map((k) => `access:${k}`)]) {
|
|
46
|
+
assert.equal(seat.test(s), true, `${s}: the table takes it`);
|
|
47
|
+
assert.equal(asks.SEAT.test(s), true, `${s}: the module takes it`);
|
|
48
|
+
}
|
|
49
|
+
for (const s of ['access:nope', 'access:', 'access:github-admin.x', 'lead:nowhere']) {
|
|
50
|
+
assert.equal(seat.test(s), false, `${s}: the table refuses it`);
|
|
51
|
+
assert.equal(asks.SEAT.test(s), false, `${s}: the module refuses it`);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('an ask can wait on an outside access, and reads as its holder', () => {
|
|
56
|
+
const ok = asks.checkAsk({
|
|
57
|
+
question: 'Allow the publish token to change CI workflow files?',
|
|
58
|
+
options: [{ key: 'yes', label: 'Add the scope', result: 'It lands on the next ship.' }, { key: 'no', label: 'Leave it', result: 'It stays stuck.' }],
|
|
59
|
+
recommend: { key: 'yes', why: 'One setting fixes it.' },
|
|
60
|
+
waits_on: 'access:github-admin',
|
|
61
|
+
if_nobody_answers: 'It stays stuck.',
|
|
62
|
+
});
|
|
63
|
+
assert.equal(ok.ok, true, JSON.stringify(ok.errors || []));
|
|
64
|
+
assert.equal(asks.seatWords('access:github-admin'), 'the repo owner');
|
|
65
|
+
assert.equal(asks.checkAsk({ ...ok.ask, waits_on: 'access:nope' }).ok, false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('access:<key> resolves to exactly the recorded holders, and to the owner when none is', () => {
|
|
69
|
+
const seats = { leads: [], owner, holders: {}, access: access.holdersByKey([KIM]) };
|
|
70
|
+
const held = routing.resolveSeat('access:github-admin', seats);
|
|
71
|
+
assert.deepEqual([held.seat, held.label, held.builder_ids, held.fell_to_owner], ['access:github-admin', 'the repo owner', ['5'], false]);
|
|
72
|
+
const unheld = routing.resolveSeat('access:dns', seats);
|
|
73
|
+
assert.deepEqual([unheld.seat, unheld.builder_ids, unheld.fell_to_owner], ['owner', ['1'], true]);
|
|
74
|
+
const route = routing.routeMatter(blocker(1, 'access:github-admin'), '5', seats, Date.now());
|
|
75
|
+
assert.deepEqual([route.yours, route.people], [true, [{ id: '5', login: 'kim', name: 'Kim' }]]);
|
|
76
|
+
assert.equal(routing.routeMatter(blocker(1, 'access:github-admin'), '1', seats, Date.now()).yours, false, 'not the owner\'s while a holder is recorded');
|
|
77
|
+
// no access read at all (an instance that has not migrated): every access seat falls to the owner
|
|
78
|
+
assert.deepEqual(routing.resolveSeat('access:github-admin', { leads: [], owner, holders: {} }).builder_ids, ['1']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a blocker on an access seat shows under its holder as "needs repo-owner access"', () => {
|
|
82
|
+
const g = waiting.groupByPerson([blocker(7, 'access:github-admin'), blocker(8, 'access:payments')], { leads: [], owner, holders: {}, access: access.holdersByKey([KIM]) });
|
|
83
|
+
const kim = g.people.find((p) => p.person.name === 'Kim');
|
|
84
|
+
assert.deepEqual(kim.items.map((i) => [i.id, i.needs]), [['7', 'needs repo-owner access']]);
|
|
85
|
+
const lars = g.people.find((p) => p.person.name === 'Lars');
|
|
86
|
+
assert.deepEqual(lars.items.map((i) => [i.id, i.needs, i.fell_to_owner]), [['8', 'needs access to payments', true]]);
|
|
87
|
+
const esc = (s) => String(s);
|
|
88
|
+
const html = people.peopleHtml({ available: true, ...g }, { esc, fmtDate: (d) => d.slice(0, 10) });
|
|
89
|
+
assert.match(html, /needs repo-owner access · filed/);
|
|
90
|
+
assert.match(html, /needs access to payments, and nobody is recorded holding it/);
|
|
91
|
+
assert.equal(people.asWords({ as: 'the owner', asked: 'owner' }), 'as the owner', 'an ordinary seat reads as before');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('no secret is stored, and nothing that gates reads it', () => {
|
|
95
|
+
const cols = /CREATE TABLE IF NOT EXISTS government_access_holders \(([\s\S]*?)\n\);/.exec(SQL)[1]
|
|
96
|
+
.split('\n').map((l) => /^\s+([a-z_]+)\s/.exec(l)).filter(Boolean).map((m) => m[1]).filter((c) => c !== 'PRIMARY');
|
|
97
|
+
assert.deepEqual(cols, ['access_key', 'builder_id', 'granted_by', 'granted_at']);
|
|
98
|
+
for (const f of ['src/bongos/auth.js', 'modules/government/resolver.js', 'modules/government/catalog.js', 'src/bongos/act-alone.js']) {
|
|
99
|
+
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
100
|
+
assert.doesNotMatch(src, /access_holders|listAccessHolders|require\(['"][./]*access['"]\)/, `${f} never reads access holders`);
|
|
101
|
+
}
|
|
102
|
+
const catalog = require(gov('catalog.js'));
|
|
103
|
+
const keys = catalog.PERMISSIONS.map((p) => p.key || p);
|
|
104
|
+
assert.ok(keys.length > 50, 'the real catalog, not an empty list');
|
|
105
|
+
for (const k of access.KEYS) assert.ok(!keys.some((p) => String(p).includes(k)), `${k} is not a permission atom`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the access view: each access, what it is, its holders, or that it falls to the owner', () => {
|
|
109
|
+
const v = access.accessView([KIM, { ...KIM, access_key: 'not-a-key' }]);
|
|
110
|
+
assert.deepEqual(v.map((a) => a.key), access.KEYS);
|
|
111
|
+
const gh = v.find((a) => a.key === 'github-admin');
|
|
112
|
+
assert.deepEqual([gh.seat, gh.holders.map((h) => h.name), gh.falls_to_owner], ['access:github-admin', ['Kim'], false]);
|
|
113
|
+
assert.equal(v.find((a) => a.key === 'dns').falls_to_owner, true);
|
|
114
|
+
const html = page.accessHtml(v, (s) => String(s).replace(/</g, '<'));
|
|
115
|
+
assert.match(html, /Repo owner/);
|
|
116
|
+
assert.match(html, /data-act="revoke" data-key="github-admin" data-id="5"/);
|
|
117
|
+
assert.match(html, /Nobody recorded — it falls to the owner/);
|
|
118
|
+
// the picker and the list say the same name, capitalised the same way
|
|
119
|
+
const opts = page.optionsHtml(v, (s) => String(s));
|
|
120
|
+
for (const a of v) {
|
|
121
|
+
assert.match(opts, new RegExp('<option value="' + a.key + '">' + a.title + '</option>'));
|
|
122
|
+
assert.ok(html.includes('>' + a.title + '<'), a.key + ': the list shows the same name');
|
|
123
|
+
assert.match(a.title, /^[A-Z]/);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('every access reads as plain words when a blocker needs it — no "access access"', () => {
|
|
128
|
+
for (const k of access.KEYS) {
|
|
129
|
+
const w = access.needsWords(k);
|
|
130
|
+
assert.match(w, /^needs [a-z]/, k);
|
|
131
|
+
assert.doesNotMatch(w, /access access|-access access/, k + ': ' + w);
|
|
132
|
+
}
|
|
133
|
+
assert.equal(access.needsWords('github-admin'), 'needs repo-owner access');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the City keeps routing when the access read fails or is missing: every access seat falls to the owner', async () => {
|
|
137
|
+
const docket = require(gov('docket.js'));
|
|
138
|
+
const db = require(gov('db.js'));
|
|
139
|
+
const real = { leads: db.listLeads, archons: db.listActiveArchons, access: db.listAccessHolders };
|
|
140
|
+
db.listLeads = async () => [];
|
|
141
|
+
db.listActiveArchons = async () => [{ id: 1, github_login: 'lars', display_name: 'Lars' }];
|
|
142
|
+
try {
|
|
143
|
+
db.listAccessHolders = async () => { throw new Error('relation government_access_holders does not exist'); };
|
|
144
|
+
const failed = await docket.loadSeats({ holders: {} });
|
|
145
|
+
assert.ok(failed, 'seats still read');
|
|
146
|
+
assert.deepEqual(routing.resolveSeat('access:github-admin', failed).builder_ids, ['1'], 'falls to the owner');
|
|
147
|
+
assert.deepEqual(failed.access['github-admin'], []);
|
|
148
|
+
db.listAccessHolders = undefined;
|
|
149
|
+
const missing = await docket.loadSeats({ holders: {} });
|
|
150
|
+
assert.ok(missing, 'a db layer without the read still routes');
|
|
151
|
+
assert.deepEqual(routing.resolveSeat('access:dns', missing).builder_ids, ['1']);
|
|
152
|
+
db.listAccessHolders = async () => [KIM];
|
|
153
|
+
assert.deepEqual(routing.resolveSeat('access:github-admin', await docket.loadSeats({ holders: {} })).builder_ids, ['5']);
|
|
154
|
+
} finally {
|
|
155
|
+
db.listLeads = real.leads; db.listActiveArchons = real.archons; db.listAccessHolders = real.access;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- the routes, through the real router and the real permission gate ---------------
|
|
160
|
+
|
|
161
|
+
const express = require('express');
|
|
162
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
163
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
164
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
165
|
+
const govDb = require(gov('db.js'));
|
|
166
|
+
|
|
167
|
+
const rows = [];
|
|
168
|
+
const REAL = { list: govDb.listAccessHolders, grant: govDb.grantAccess, revoke: govDb.revokeAccess, who: govDb.getActiveBuilder };
|
|
169
|
+
govDb.listAccessHolders = async () => rows.slice();
|
|
170
|
+
govDb.grantAccess = async (k, id, by) => { if (rows.some((r) => r.access_key === k && r.builder_id === id)) return false; rows.push({ access_key: k, builder_id: id, granted_by: by, github_login: 'kim', display_name: 'Kim' }); return true; };
|
|
171
|
+
govDb.revokeAccess = async (k, id) => { const i = rows.findIndex((r) => r.access_key === k && r.builder_id === id); if (i < 0) return false; rows.splice(i, 1); return true; };
|
|
172
|
+
govDb.getActiveBuilder = async (id) => (id === 5 ? { id: 5, github_login: 'kim', display_name: 'Kim' } : null);
|
|
173
|
+
const realRequireBuilder = api.requireBuilder;
|
|
174
|
+
api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon' }; next(); };
|
|
175
|
+
function grant(keys) { seams._reset(); seams.registerProvider('government', { resolveBuilderPermissions: async () => keys }); }
|
|
176
|
+
grant(['government.manage', 'board.vote.cast']);
|
|
177
|
+
const buildRouter = require(gov('routes/government.js'));
|
|
178
|
+
const app = express();
|
|
179
|
+
app.use(express.json());
|
|
180
|
+
app.use(attachFail);
|
|
181
|
+
app.use(buildRouter());
|
|
182
|
+
const server = http.createServer(app);
|
|
183
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
184
|
+
const BASE = `http://127.0.0.1:${server.address().port}`;
|
|
185
|
+
after(() => {
|
|
186
|
+
server.close();
|
|
187
|
+
api.requireBuilder = realRequireBuilder;
|
|
188
|
+
govDb.listAccessHolders = REAL.list; govDb.grantAccess = REAL.grant; govDb.revokeAccess = REAL.revoke; govDb.getActiveBuilder = REAL.who;
|
|
189
|
+
seams._reset();
|
|
190
|
+
});
|
|
191
|
+
const call = async (method, p, body) => {
|
|
192
|
+
const res = await fetch(BASE + p, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
|
|
193
|
+
return { status: res.status, json: await res.json().catch(() => null) };
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
test('only the owner records a holder; everyone who can open the City can read who holds what', async () => {
|
|
197
|
+
grant(['board.vote.cast']);
|
|
198
|
+
assert.equal((await call('POST', '/government/access/github-admin/holders', { builder_id: 5 })).status, 403, 'a voter cannot record');
|
|
199
|
+
assert.equal((await call('DELETE', '/government/access/github-admin/holders/5')).status, 403, 'nor remove');
|
|
200
|
+
assert.equal((await call('GET', '/government/access')).status, 200, 'but can read');
|
|
201
|
+
grant(['government.manage', 'board.vote.cast']);
|
|
202
|
+
const added = await call('POST', '/government/access/github-admin/holders', { builder_id: 5 });
|
|
203
|
+
assert.deepEqual([added.status, added.json.added], [201, true]);
|
|
204
|
+
assert.equal(rows[0].granted_by, 1, 'who recorded it rides the row');
|
|
205
|
+
assert.deepEqual([(await call('POST', '/government/access/github-admin/holders', { builder_id: 5 })).status], [200], 'idempotent');
|
|
206
|
+
const read = await call('GET', '/government/access');
|
|
207
|
+
assert.deepEqual(read.json.access.find((a) => a.key === 'github-admin').holders.map((h) => h.id), ['5']);
|
|
208
|
+
assert.equal((await call('POST', '/government/access/nope/holders', { builder_id: 5 })).status, 400, 'an unknown access');
|
|
209
|
+
assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 99 })).status, 404, 'an unknown builder');
|
|
210
|
+
assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 'x' })).status, 400);
|
|
211
|
+
assert.equal((await call('POST', '/government/access/dns/holders', { builder_id: 5, token: 'ghp_x' })).status, 400, 'strict: an unknown field (a token, say) is refused, never stored');
|
|
212
|
+
const removed = await call('DELETE', '/government/access/github-admin/holders/5');
|
|
213
|
+
assert.deepEqual([removed.status, removed.json.removed, rows.length], [200, true, 0]);
|
|
214
|
+
});
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// tests/account_logins.mjs — a platform account is a person, and GitHub is one
|
|
2
|
+
// way in (task 1004992, ADR 0393).
|
|
3
|
+
//
|
|
4
|
+
// What this proves, DB-free (the real SQL is tests/account_logins_db.mjs):
|
|
5
|
+
//
|
|
6
|
+
// 1. A provider identity is only ever the provider's stable user id — never an
|
|
7
|
+
// email or a login — and GitHub's is a positive integer.
|
|
8
|
+
// 2. A person who arrives without GitHub gets a MINTED key that is negative,
|
|
9
|
+
// so it can never be mistaken for a GitHub id, and no GitHub login is
|
|
10
|
+
// invented for them.
|
|
11
|
+
// 3. A second sign-in by the same Google identity — or a racing double-click —
|
|
12
|
+
// reaches the SAME account; it never makes a second person.
|
|
13
|
+
// 4. GitHub accounts are never born here (one birth path per kind).
|
|
14
|
+
// 5. The person's own settings read lists which ways in they have, never the
|
|
15
|
+
// provider ids, and is keyed on the signed-in person only.
|
|
16
|
+
// 6. The hub's numeric-key guard now admits a minted key, and still refuses a
|
|
17
|
+
// system:* principal.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/account_logins.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test, before, after } from 'node:test';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const express = require('express');
|
|
28
|
+
|
|
29
|
+
// A recording pool. `script` answers each statement by the first matching
|
|
30
|
+
// pattern; connect() hands out a client that records into the same log, so a
|
|
31
|
+
// transaction's statements are visible in order.
|
|
32
|
+
function fakePool(script) {
|
|
33
|
+
const p = { log: [], script };
|
|
34
|
+
const run = async (sql, params = []) => {
|
|
35
|
+
p.log.push({ sql, params });
|
|
36
|
+
for (const [re, answer] of p.script) {
|
|
37
|
+
if (re.test(sql)) return typeof answer === 'function' ? answer(sql, params, p.log) : answer;
|
|
38
|
+
}
|
|
39
|
+
return { rows: [], rowCount: 0 };
|
|
40
|
+
};
|
|
41
|
+
p.query = run;
|
|
42
|
+
p.connect = async () => ({ query: run, release() {} });
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Swap what the route's pool answers. The route captured the pool object at
|
|
47
|
+
// require time, so the object stays and its script and log are replaced.
|
|
48
|
+
function scriptRoute(script) {
|
|
49
|
+
routePool.script = script;
|
|
50
|
+
routePool.log.length = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let ME = '4242';
|
|
54
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
55
|
+
const realApi = require(apiPath);
|
|
56
|
+
const routePool = fakePool([]);
|
|
57
|
+
require.cache[apiPath].exports = {
|
|
58
|
+
...realApi,
|
|
59
|
+
pool: routePool,
|
|
60
|
+
requireBuilder: (req, _res, next) => {
|
|
61
|
+
req.builder = { id: 1, github_id: ME, github_login: 'octo' };
|
|
62
|
+
next();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const logins = require('../modules/platform-identity/account-logins.js');
|
|
67
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
68
|
+
const profileRoutes = require('../modules/platform-identity/routes/profile.js');
|
|
69
|
+
const myProjectsRoutes = require('../modules/platform-identity/routes/my-projects.js');
|
|
70
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
71
|
+
|
|
72
|
+
test('a provider identity is the provider\'s stable user id, nothing else', () => {
|
|
73
|
+
assert.deepEqual(logins.normalizeLogin({ provider: 'github', subject: 583231 }), { provider: 'github', subject: '583231' });
|
|
74
|
+
assert.deepEqual(logins.normalizeLogin({ provider: 'google', subject: '110169484474386276334' }), { provider: 'google', subject: '110169484474386276334' });
|
|
75
|
+
for (const bad of [
|
|
76
|
+
{ provider: 'github', subject: 'octocat' }, // a login, not an id
|
|
77
|
+
{ provider: 'github', subject: '-5' }, // a minted key is not a GitHub id
|
|
78
|
+
{ provider: 'github', subject: '0' },
|
|
79
|
+
{ provider: 'google', subject: '' },
|
|
80
|
+
{ provider: 'google', subject: 'has space' },
|
|
81
|
+
{ provider: 'google', subject: 'x'.repeat(256) },
|
|
82
|
+
{ provider: 'email', subject: 'a@b.c' }, // not a provider (ADR 0393)
|
|
83
|
+
{ provider: 'apple', subject: '123' },
|
|
84
|
+
{},
|
|
85
|
+
]) assert.equal(logins.normalizeLogin(bad), null, JSON.stringify(bad));
|
|
86
|
+
assert.deepEqual([...logins.LOGIN_PROVIDERS], ['github', 'google']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a minted person key is negative, and only a minted one is', () => {
|
|
90
|
+
assert.equal(logins.isMintedPersonKey('-17'), true);
|
|
91
|
+
assert.equal(logins.isMintedPersonKey(-17), true);
|
|
92
|
+
assert.equal(logins.isMintedPersonKey('583231'), false);
|
|
93
|
+
assert.equal(logins.isMintedPersonKey('system:bfg'), false);
|
|
94
|
+
assert.equal(logins.isMintedPersonKey(null), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the hub key guard admits a minted key and still refuses a system principal', () => {
|
|
98
|
+
assert.equal(pi.isNumericGithubId('-17'), true);
|
|
99
|
+
assert.equal(pi.isNumericGithubId('583231'), true);
|
|
100
|
+
assert.equal(pi.isNumericGithubId('system:bfg'), false);
|
|
101
|
+
assert.equal(pi.isNumericGithubId('--1'), false);
|
|
102
|
+
assert.equal(pi.isNumericGithubId(''), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a person arriving through Google gets a new account with a minted key and no GitHub login', async () => {
|
|
106
|
+
const pool = fakePool([
|
|
107
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, { rows: [] }],
|
|
108
|
+
[/INSERT INTO platform_identity_accounts/, { rows: [{ id: 77, person_key: '-3' }] }],
|
|
109
|
+
[/INSERT INTO platform_identity_account_logins/, { rows: [], rowCount: 1 }],
|
|
110
|
+
[/UPDATE platform_identity_accounts\s+SET handle/, { rows: [{ handle: 'janedoe' }], rowCount: 1 }],
|
|
111
|
+
]);
|
|
112
|
+
const out = await logins.createAccountForLogin({
|
|
113
|
+
provider: 'google', subject: 'g-sub-1', email: ' jane@example.com ', displayName: 'Jane Doe',
|
|
114
|
+
avatarUrl: 'https://lh3.googleusercontent.com/a/x',
|
|
115
|
+
}, { pool });
|
|
116
|
+
assert.deepEqual(out, { accountId: 77, personKey: '-3', created: true });
|
|
117
|
+
|
|
118
|
+
const sqls = pool.log.map((q) => q.sql);
|
|
119
|
+
const insAcct = pool.log.find((q) => /INSERT INTO platform_identity_accounts/.test(q.sql));
|
|
120
|
+
assert.match(insAcct.sql, /-nextval\('platform_identity_person_key_seq'\)/, 'the key is minted negative from the sequence');
|
|
121
|
+
assert.match(insAcct.sql, /VALUES \(-nextval\('platform_identity_person_key_seq'\), NULL,/, 'no GitHub login is invented');
|
|
122
|
+
assert.match(insAcct.sql, /'provisional'/, 'not activated here — the sign-in route decides, as for GitHub');
|
|
123
|
+
assert.deepEqual(insAcct.params, ['Jane Doe', 'https://lh3.googleusercontent.com/a/x']);
|
|
124
|
+
|
|
125
|
+
const insLogin = pool.log.find((q) => /INSERT INTO platform_identity_account_logins/.test(q.sql));
|
|
126
|
+
assert.deepEqual(insLogin.params, [77, 'google', 'g-sub-1', 'jane@example.com']);
|
|
127
|
+
|
|
128
|
+
// Both inserts sit inside ONE transaction: an account never exists without its way in.
|
|
129
|
+
const b = sqls.indexOf('BEGIN');
|
|
130
|
+
const c = sqls.indexOf('COMMIT');
|
|
131
|
+
assert.ok(b >= 0 && c > b, 'a transaction ran');
|
|
132
|
+
assert.ok(sqls.indexOf(insAcct.sql) > b && sqls.indexOf(insLogin.sql) > sqls.indexOf(insAcct.sql) && c > sqls.indexOf(insLogin.sql),
|
|
133
|
+
'account then login, both between BEGIN and COMMIT');
|
|
134
|
+
|
|
135
|
+
const handle = pool.log.find((q) => /SET handle/.test(q.sql));
|
|
136
|
+
assert.deepEqual(handle.params, ['-3', 'janedoe'], 'the handle is seeded from the name, keyed on the minted key');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the same Google identity signing in again reaches the same person', async () => {
|
|
140
|
+
const pool = fakePool([
|
|
141
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, { rows: [{ account_id: 77, person_key: '-3' }] }],
|
|
142
|
+
]);
|
|
143
|
+
const out = await logins.createAccountForLogin({ provider: 'google', subject: 'g-sub-1' }, { pool });
|
|
144
|
+
assert.deepEqual(out, { accountId: 77, personKey: '-3', created: false });
|
|
145
|
+
assert.ok(!pool.log.some((q) => /INSERT/.test(q.sql)), 'nothing written');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a racing double sign-up rolls back and returns the account the other request made', async () => {
|
|
149
|
+
let lookups = 0;
|
|
150
|
+
const pool = fakePool([
|
|
151
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, () => (++lookups === 1 ? { rows: [] } : { rows: [{ account_id: 78, person_key: '-4' }] })],
|
|
152
|
+
[/INSERT INTO platform_identity_accounts/, { rows: [{ id: 79, person_key: '-5' }] }],
|
|
153
|
+
[/INSERT INTO platform_identity_account_logins/, () => { const e = new Error('duplicate key'); e.code = '23505'; throw e; }],
|
|
154
|
+
]);
|
|
155
|
+
const out = await logins.createAccountForLogin({ provider: 'google', subject: 'g-sub-2' }, { pool });
|
|
156
|
+
assert.deepEqual(out, { accountId: 78, personKey: '-4', created: false });
|
|
157
|
+
const sqls = pool.log.map((q) => q.sql);
|
|
158
|
+
assert.ok(sqls.includes('ROLLBACK'), 'our half-made account rolled back with its failed login');
|
|
159
|
+
assert.ok(!sqls.includes('COMMIT'));
|
|
160
|
+
assert.ok(!pool.log.some((q) => /SET handle/.test(q.sql)), 'no handle seeded for a rolled-back account');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('any other failure is not swallowed', async () => {
|
|
164
|
+
const pool = fakePool([
|
|
165
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, { rows: [] }],
|
|
166
|
+
[/INSERT INTO platform_identity_accounts/, () => { const e = new Error('boom'); e.code = '57014'; throw e; }],
|
|
167
|
+
]);
|
|
168
|
+
await assert.rejects(logins.createAccountForLogin({ provider: 'google', subject: 'g-sub-3' }, { pool }), /boom/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('GitHub accounts and malformed identities are never born here', async () => {
|
|
172
|
+
const pool = fakePool([]);
|
|
173
|
+
await assert.rejects(logins.createAccountForLogin({ provider: 'github', subject: '583231' }, { pool }), /GitHub account is born/);
|
|
174
|
+
await assert.rejects(logins.createAccountForLogin({ provider: 'google', subject: '' }, { pool }), /not a valid provider identity/);
|
|
175
|
+
await assert.rejects(logins.createAccountForLogin({ provider: 'email', subject: 'a@b.c' }, { pool }), /not a valid provider identity/);
|
|
176
|
+
assert.equal(pool.log.length, 0, 'nothing touched the database');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a lookup never runs for an identity that cannot be one', async () => {
|
|
180
|
+
const pool = fakePool([]);
|
|
181
|
+
assert.equal(await logins.findPersonByLogin({ provider: 'github', subject: 'octocat' }, { pool }), null);
|
|
182
|
+
assert.equal(pool.log.length, 0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('the lookup keys on provider and the provider\'s id only — never an email', async () => {
|
|
186
|
+
const pool = fakePool([]);
|
|
187
|
+
await logins.findPersonByLogin({ provider: 'google', subject: 'g-sub-9' }, { pool });
|
|
188
|
+
assert.equal(pool.log.length, 1);
|
|
189
|
+
assert.match(pool.log[0].sql, /WHERE l\.provider = \$1 AND l\.subject = \$2/);
|
|
190
|
+
assert.doesNotMatch(pool.log[0].sql, /email/);
|
|
191
|
+
assert.deepEqual(pool.log[0].params, ['google', 'g-sub-9']);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- signing in with Google (task 1004993, ADR 0396) -------------------------
|
|
195
|
+
|
|
196
|
+
test('a returning Google sign-in finds the person, stamps the way in and answers their handle', async () => {
|
|
197
|
+
const pool = fakePool([
|
|
198
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, { rows: [{ account_id: 77, person_key: '-3' }] }],
|
|
199
|
+
[/UPDATE platform_identity_account_logins SET last_used_at/, { rows: [], rowCount: 1 }],
|
|
200
|
+
[/SELECT handle, github_login FROM platform_identity_accounts/, { rows: [{ handle: 'janedoe', github_login: null }] }],
|
|
201
|
+
]);
|
|
202
|
+
const out = await logins.signInWithLogin({ provider: 'google', subject: 'g-sub-1', email: 'new@example.com' }, { pool });
|
|
203
|
+
assert.deepEqual(out, { personKey: '-3', handle: 'janedoe', githubLogin: null, created: false });
|
|
204
|
+
const stamp = pool.log.find((q) => /SET last_used_at/.test(q.sql));
|
|
205
|
+
assert.deepEqual(stamp.params, ['google', 'g-sub-1', 'new@example.com'], 'keyed on Google\'s id; the email is only stored');
|
|
206
|
+
assert.ok(!pool.log.some((q) => /INSERT/.test(q.sql)), 'no second person');
|
|
207
|
+
assert.ok(!pool.log.some((q) => /WHERE[^;]*email/.test(q.sql)), 'no statement looks a person up by email');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('a first Google sign-in creates the person and says so', async () => {
|
|
211
|
+
const pool = fakePool([
|
|
212
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, { rows: [] }],
|
|
213
|
+
[/INSERT INTO platform_identity_accounts/, { rows: [{ id: 80, person_key: '-6' }] }],
|
|
214
|
+
[/INSERT INTO platform_identity_account_logins/, { rows: [], rowCount: 1 }],
|
|
215
|
+
[/UPDATE platform_identity_accounts\s+SET handle/, { rows: [{ handle: 'ana' }], rowCount: 1 }],
|
|
216
|
+
[/SELECT handle, github_login FROM platform_identity_accounts/, { rows: [{ handle: 'ana', github_login: null }] }],
|
|
217
|
+
]);
|
|
218
|
+
const out = await logins.signInWithLogin({ provider: 'google', subject: 'g-new', displayName: 'Ana' }, { pool });
|
|
219
|
+
assert.deepEqual(out, { personKey: '-6', handle: 'ana', githubLogin: null, created: true });
|
|
220
|
+
assert.ok(!pool.log.some((q) => /SET last_used_at/.test(q.sql)), 'a new way in was written with last_used_at already');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('a person who links GitHub later answers their real GitHub login', async () => {
|
|
224
|
+
const pool = fakePool([
|
|
225
|
+
[/FROM platform_identity_account_logins l\s+JOIN/, { rows: [{ account_id: 9, person_key: '583231' }] }],
|
|
226
|
+
[/SELECT handle, github_login FROM platform_identity_accounts/, { rows: [{ handle: 'octo', github_login: 'octocat' }] }],
|
|
227
|
+
]);
|
|
228
|
+
const out = await logins.signInWithLogin({ provider: 'google', subject: 'g-linked' }, { pool });
|
|
229
|
+
assert.equal(out.githubLogin, 'octocat');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('a minted key never takes a GitHub login from a caller, on insert or update', async () => {
|
|
233
|
+
const pool = fakePool([[/INSERT INTO platform_identity_accounts/, { rows: [{ github_id: '-3', handle: 'ana' }] }]]);
|
|
234
|
+
await pi.upsertAccount({ githubId: '-3', githubLogin: '~ana', displayName: 'Ana', activate: true }, { pool });
|
|
235
|
+
const q = pool.log.find((x) => /INSERT INTO platform_identity_accounts/.test(x.sql));
|
|
236
|
+
assert.match(q.sql, /VALUES \(\$1, CASE WHEN \$1::bigint < 0 THEN NULL ELSE \$2 END,/, 'a minted key is inserted with no login');
|
|
237
|
+
assert.match(q.sql, /github_login = CASE WHEN platform_identity_accounts\.github_id < 0 THEN platform_identity_accounts\.github_login ELSE EXCLUDED\.github_login END/,
|
|
238
|
+
'a minted key keeps the login only linking sets');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// --- the route ---------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const app = express();
|
|
244
|
+
app.use(express.json());
|
|
245
|
+
app.use(attachFail);
|
|
246
|
+
app.use('/api/bongos', profileRoutes());
|
|
247
|
+
app.use('/api/bongos', myProjectsRoutes());
|
|
248
|
+
let server, port;
|
|
249
|
+
before(async () => {
|
|
250
|
+
server = http.createServer(app);
|
|
251
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
252
|
+
port = server.address().port;
|
|
253
|
+
});
|
|
254
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
255
|
+
|
|
256
|
+
function get(pathname) {
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
|
|
259
|
+
let data = '';
|
|
260
|
+
res.on('data', (c) => { data += c; });
|
|
261
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data ? JSON.parse(data) : null }));
|
|
262
|
+
}).on('error', reject);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test('GET /me/sign-in-methods lists the signed-in person\'s own ways in, and no provider ids', async () => {
|
|
267
|
+
ME = '-3';
|
|
268
|
+
scriptRoute([
|
|
269
|
+
[/FROM platform_identity_account_logins l\s+JOIN platform_identity_accounts a/, (_s, params) => ({
|
|
270
|
+
rows: params[0] === '-3'
|
|
271
|
+
? [{ provider: 'github', email: null, created_at: '2026-10-29T00:00:00Z', last_used_at: null },
|
|
272
|
+
{ provider: 'google', email: 'jane@example.com', created_at: '2026-10-28T00:00:00Z', last_used_at: '2026-10-29T00:00:00Z' }]
|
|
273
|
+
: [],
|
|
274
|
+
})],
|
|
275
|
+
]);
|
|
276
|
+
const res = await get('/api/bongos/me/sign-in-methods');
|
|
277
|
+
assert.equal(res.status, 200);
|
|
278
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
279
|
+
assert.deepEqual(res.body.providers, ['github', 'google']);
|
|
280
|
+
assert.deepEqual(res.body.methods.map((m) => m.provider), ['github', 'google']);
|
|
281
|
+
assert.ok(!JSON.stringify(res.body).includes('subject'), 'provider ids never cross');
|
|
282
|
+
const q = routePool.log[0];
|
|
283
|
+
assert.deepEqual(q.params, ['-3', ['github', 'google']], 'keyed on the signed-in person, never a caller-named one');
|
|
284
|
+
assert.doesNotMatch(q.sql, /l\.subject/, 'the provider id is not even selected');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('GET /me/sign-in-methods answers [] for a system principal without touching the database', async () => {
|
|
288
|
+
ME = 'system:bfg';
|
|
289
|
+
scriptRoute([]);
|
|
290
|
+
const res = await get('/api/bongos/me/sign-in-methods');
|
|
291
|
+
assert.equal(res.status, 200);
|
|
292
|
+
assert.deepEqual(res.body.methods, []);
|
|
293
|
+
assert.equal(routePool.log.length, 0);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('GET /me/sign-in-methods fails closed on a read error', async () => {
|
|
297
|
+
ME = '4242';
|
|
298
|
+
scriptRoute([[/./, () => { throw new Error('db down'); }]]);
|
|
299
|
+
const origError = console.error;
|
|
300
|
+
console.error = () => {};
|
|
301
|
+
try {
|
|
302
|
+
const res = await get('/api/bongos/me/sign-in-methods');
|
|
303
|
+
assert.equal(res.status, 500);
|
|
304
|
+
assert.ok(!('methods' in (res.body || {})));
|
|
305
|
+
} finally {
|
|
306
|
+
console.error = origError;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('a Google-only person is not relayed to a project until halls can take one (task 1004994)', async () => {
|
|
311
|
+
ME = '-3';
|
|
312
|
+
scriptRoute([]);
|
|
313
|
+
try {
|
|
314
|
+
const res = await new Promise((resolve, reject) => {
|
|
315
|
+
const body = JSON.stringify({ origin: 'https://proj.example.com' });
|
|
316
|
+
const r = http.request({ host: '127.0.0.1', port, path: '/api/bongos/my-projects/join', method: 'POST',
|
|
317
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }, (resp) => {
|
|
318
|
+
let data = ''; resp.on('data', (c) => { data += c; }); resp.on('end', () => resolve({ status: resp.statusCode, body: JSON.parse(data) }));
|
|
319
|
+
});
|
|
320
|
+
r.on('error', reject); r.end(body);
|
|
321
|
+
});
|
|
322
|
+
assert.equal(res.status, 409);
|
|
323
|
+
assert.equal(res.body.error.code, 'google_join_not_yet');
|
|
324
|
+
assert.equal(routePool.log.length, 0, 'nothing was read or relayed');
|
|
325
|
+
} finally { ME = '4242'; }
|
|
326
|
+
});
|