@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,319 @@
|
|
|
1
|
+
// tests/ai_studio_requests.mjs — THE WHOLE PATH of an AI studio request through
|
|
2
|
+
// the real router (task 1004917, goal 1000136, ADR 0392): upload a sketch, send
|
|
3
|
+
// a request naming it, wait for it to finish, read the reply, the image and the
|
|
4
|
+
// exact price, and fetch the image back. Also: a builder can only send and see
|
|
5
|
+
// their own files, and our key appears in no response.
|
|
6
|
+
//
|
|
7
|
+
// Drives the real router over an in-memory db, the fake payments backend and a
|
|
8
|
+
// stubbed fetch standing in for OpenRouter, so it needs no Postgres and no network.
|
|
9
|
+
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const db = require('../modules/ai-studio/db.js');
|
|
17
|
+
const payments = require('../modules/ai-studio/payments.js');
|
|
18
|
+
const P = require('../modules/ai-studio/pricing.js');
|
|
19
|
+
const api = require('../src/module-api.js');
|
|
20
|
+
|
|
21
|
+
const OUR_KEY = 'sk-or-v1-OUR-SERVER-KEY-0123456789abcdef';
|
|
22
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
|
23
|
+
const OUT_PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
|
|
24
|
+
|
|
25
|
+
// ---- an in-memory db with the same ownership rules as the SQL ---------------
|
|
26
|
+
|
|
27
|
+
const files = new Map();
|
|
28
|
+
const requests = new Map();
|
|
29
|
+
let seq = 100;
|
|
30
|
+
const meta = (f) => ({ id: f.id, request_id: f.request_id, role: f.role, name: f.name, content_type: f.content_type, size_bytes: f.bytes.length, created_at: f.created_at });
|
|
31
|
+
Object.assign(db, {
|
|
32
|
+
appendLedger: async () => ({}),
|
|
33
|
+
addUpload: async (builderId, f) => {
|
|
34
|
+
const row = { id: String(++seq), builder_id: builderId, request_id: null, role: 'upload', name: f.name, content_type: f.content_type, bytes: f.bytes, created_at: 'now' };
|
|
35
|
+
files.set(row.id, row);
|
|
36
|
+
return meta(row);
|
|
37
|
+
},
|
|
38
|
+
createRequest: async (builderId, prompt, ids) => {
|
|
39
|
+
const own = ids.map((id) => files.get(id)).filter((f) => f && f.builder_id === builderId && f.role === 'upload' && !f.request_id);
|
|
40
|
+
if (own.length !== ids.length) return null;
|
|
41
|
+
const r = { id: String(++seq), builder_id: builderId, prompt, status: 'queued', key_source: null, created_at: 'now' };
|
|
42
|
+
requests.set(r.id, r);
|
|
43
|
+
own.forEach((f) => { f.request_id = r.id; });
|
|
44
|
+
return { ...r, attached: own.length };
|
|
45
|
+
},
|
|
46
|
+
markRunning: async (id, src) => Object.assign(requests.get(String(id)), { status: 'running', key_source: src }),
|
|
47
|
+
finishRequest: async (id, f) => Object.assign(requests.get(String(id)), f, { finished_at: 'now' }),
|
|
48
|
+
addOutput: async (requestId, builderId, img) => {
|
|
49
|
+
const row = { id: String(++seq), builder_id: builderId, request_id: String(requestId), role: 'output', name: 'generated.png', content_type: img.content_type, bytes: Buffer.from(img.base64, 'base64'), created_at: 'now' };
|
|
50
|
+
files.set(row.id, row);
|
|
51
|
+
return meta(row);
|
|
52
|
+
},
|
|
53
|
+
getRequest: async (builderId, id) => {
|
|
54
|
+
const r = requests.get(String(id));
|
|
55
|
+
if (!r || r.builder_id !== builderId) return null;
|
|
56
|
+
return { ...r, files: [...files.values()].filter((f) => f.request_id === r.id).map(meta) };
|
|
57
|
+
},
|
|
58
|
+
listRequests: async (builderId) => [...requests.values()].filter((r) => r.builder_id === builderId),
|
|
59
|
+
getFile: async (builderId, id) => { const f = files.get(String(id)); return f && f.builder_id === builderId ? f : null; },
|
|
60
|
+
requestUploads: async (id) => [...files.values()].filter((f) => f.request_id === String(id) && f.role === 'upload'),
|
|
61
|
+
countActiveRequests: async (builderId) => [...requests.values()].filter((r) => r.builder_id === builderId && ['queued', 'running'].includes(r.status)).length,
|
|
62
|
+
listKeyMeta: async (builderId) => [...ownKeys.entries()].filter(([k]) => k.startsWith(`${builderId}/`)).map(([k]) => ({ provider: k.split('/')[1], key_enc: true })),
|
|
63
|
+
getKeyEnc: async (builderId, provider) => ownKeys.get(`${builderId}/${provider}`) || null,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// A builder's own saved keys, as the secret box would store them.
|
|
67
|
+
const ownKeys = new Map();
|
|
68
|
+
const box = { isConfigured: () => true, encrypt: (p) => `v1.${Buffer.from(p).toString('base64url')}`, decrypt: (b) => Buffer.from(String(b).slice(3), 'base64url').toString() };
|
|
69
|
+
Object.defineProperty(api, 'secretBox', { value: box, configurable: true, writable: true });
|
|
70
|
+
|
|
71
|
+
// ---- OpenRouter stand-in: a reply, then an image, each with its cost --------
|
|
72
|
+
|
|
73
|
+
const providerCalls = [];
|
|
74
|
+
globalThis.fetch = async (url, init) => {
|
|
75
|
+
const body = JSON.parse(init.body);
|
|
76
|
+
providerCalls.push({ url, auth: init.headers.authorization, googKey: init.headers['x-goog-api-key'], body });
|
|
77
|
+
if (url.includes('generativelanguage.googleapis.com')) {
|
|
78
|
+
const part = body.generationConfig
|
|
79
|
+
? { inlineData: { mimeType: 'image/png', data: OUT_PNG.toString('base64') } }
|
|
80
|
+
: { text: 'From Gemini direct: lovely.' };
|
|
81
|
+
return { ok: true, status: 200, json: async () => ({ candidates: [{ content: { parts: [part] } }] }) };
|
|
82
|
+
}
|
|
83
|
+
const json = body.modalities
|
|
84
|
+
? { choices: [{ message: { images: [{ image_url: { url: `data:image/png;base64,${OUT_PNG.toString('base64')}` } }] } }], usage: { cost: 0.039 } }
|
|
85
|
+
: { choices: [{ message: { content: 'The tower reads well; push the dusk light.' } }], usage: { cost: 0.0114 } };
|
|
86
|
+
return { ok: true, status: 200, json: async () => json };
|
|
87
|
+
};
|
|
88
|
+
process.env.OPENROUTER_API_KEY = OUR_KEY;
|
|
89
|
+
const pay = payments.createFakeBackend();
|
|
90
|
+
payments._setBackend(pay);
|
|
91
|
+
await pay.topUp(7, { chargeMicro: 10 * P.MICRO_PER_USD + 1, creditedMicro: 10 * P.MICRO_PER_USD });
|
|
92
|
+
|
|
93
|
+
// ---- the app -----------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
const logged = [];
|
|
96
|
+
const capture = (...args) => logged.push(JSON.stringify(args));
|
|
97
|
+
const fakeLog = { info: capture, warn: capture, error: capture, debug: capture, child: () => fakeLog };
|
|
98
|
+
let viewer = { id: 7, rank: 'xenos' };
|
|
99
|
+
for (const [name, value] of Object.entries({
|
|
100
|
+
logger: () => fakeLog,
|
|
101
|
+
requireBuilder: (req, res, next) => { req.builder = viewer; next(); },
|
|
102
|
+
})) Object.defineProperty(api, name, { value, configurable: true, writable: true });
|
|
103
|
+
|
|
104
|
+
const express = require('express');
|
|
105
|
+
const app = express();
|
|
106
|
+
app.use((req, res, next) => {
|
|
107
|
+
res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code, message: opts && opts.message } });
|
|
108
|
+
next();
|
|
109
|
+
});
|
|
110
|
+
// Like src/bongos/routes.js: the tight global parser skips the upload path.
|
|
111
|
+
const small = express.json({ limit: '64kb' });
|
|
112
|
+
app.use((req, res, next) => (req.path === '/api/bongos/ai-studio/uploads' ? next() : small(req, res, next)));
|
|
113
|
+
app.use('/api/bongos', require('../modules/ai-studio/routes/ai-studio.js')());
|
|
114
|
+
const server = app.listen(0);
|
|
115
|
+
await new Promise((r) => server.once('listening', r));
|
|
116
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
117
|
+
test.after(() => server.close());
|
|
118
|
+
|
|
119
|
+
function call(method, path, body) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
122
|
+
const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
|
|
123
|
+
const chunks = [];
|
|
124
|
+
res.on('data', (c) => chunks.push(c));
|
|
125
|
+
res.on('end', () => {
|
|
126
|
+
const buf = Buffer.concat(chunks);
|
|
127
|
+
const raw = buf.toString('latin1');
|
|
128
|
+
let json = null;
|
|
129
|
+
try { json = JSON.parse(buf.toString()); } catch (_) { /* a file */ }
|
|
130
|
+
resolve({ status: res.statusCode, headers: res.headers, raw, buf, body: json });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
req.on('error', reject);
|
|
134
|
+
if (data) req.write(data);
|
|
135
|
+
req.end();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function waitDone(id) {
|
|
140
|
+
for (let i = 0; i < 100; i++) {
|
|
141
|
+
const r = await call('GET', `/ai-studio/requests/${id}`);
|
|
142
|
+
if (!['queued', 'running'].includes(r.body.request.status)) return r;
|
|
143
|
+
await new Promise((res) => setTimeout(res, 10));
|
|
144
|
+
}
|
|
145
|
+
throw new Error('the request never finished');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test('THE WHOLE PATH: upload a sketch, send it, get a reply, an image and the exact price', async () => {
|
|
149
|
+
// A sketch bigger than the 64 KB global cap still goes up.
|
|
150
|
+
const sketch = Buffer.concat([PNG, Buffer.alloc(200 * 1024)]);
|
|
151
|
+
const up = await call('POST', '/ai-studio/uploads', { name: 'tower.png', content_type: 'image/png', data: sketch.toString('base64') });
|
|
152
|
+
assert.equal(up.status, 201, up.raw);
|
|
153
|
+
assert.equal(up.body.file.size_bytes, sketch.length);
|
|
154
|
+
|
|
155
|
+
const sent = await call('POST', '/ai-studio/requests', { prompt: 'Finish this tower at dusk', file_ids: [up.body.file.id] });
|
|
156
|
+
assert.equal(sent.status, 202, sent.raw);
|
|
157
|
+
|
|
158
|
+
const done = await waitDone(sent.body.request.id);
|
|
159
|
+
const r = done.body.request;
|
|
160
|
+
assert.equal(r.status, 'done', r.error);
|
|
161
|
+
assert.equal(r.key_source, 'ours');
|
|
162
|
+
assert.equal(r.reply_md, 'The tower reads well; push the dusk light.');
|
|
163
|
+
// 0.0114 + 0.039 = $0.0504 cost; x 1.08 = $0.054432.
|
|
164
|
+
assert.deepEqual([r.provider_cost_micro, r.markup_micro, r.price_micro], [50400, 4032, 54432]);
|
|
165
|
+
assert.deepEqual((await pay.balance(7)).available_micro, 10 * P.MICRO_PER_USD - 54432);
|
|
166
|
+
|
|
167
|
+
const out = r.files.find((f) => f.role === 'output');
|
|
168
|
+
assert.ok(out, 'the generated image is on the request');
|
|
169
|
+
const img = await call('GET', out.url.replace('/api/bongos', ''));
|
|
170
|
+
assert.equal(img.status, 200);
|
|
171
|
+
assert.equal(img.headers['content-type'], 'image/png');
|
|
172
|
+
assert.equal(img.headers['x-content-type-options'], 'nosniff');
|
|
173
|
+
assert.ok(img.buf.equals(OUT_PNG));
|
|
174
|
+
|
|
175
|
+
// The provider got our key as a header, the sketch, and the server-built context.
|
|
176
|
+
assert.equal(providerCalls.length, 2);
|
|
177
|
+
assert.ok(providerCalls.every((c) => c.auth === `Bearer ${OUR_KEY}`));
|
|
178
|
+
assert.match(providerCalls[0].body.messages[0].content, /art director's assistant/);
|
|
179
|
+
assert.equal(providerCalls[0].body.messages[1].content[1].type, 'image_url');
|
|
180
|
+
|
|
181
|
+
// Our key is in nothing a builder can see, and in no log line.
|
|
182
|
+
for (const res of [up, sent, done]) assert.ok(!res.raw.includes(OUR_KEY));
|
|
183
|
+
assert.ok(logged.length > 0);
|
|
184
|
+
assert.ok(logged.every((l) => !l.includes(OUR_KEY)));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a builder can neither send nor read another builder\'s files or requests', async () => {
|
|
188
|
+
const up = await call('POST', '/ai-studio/uploads', { name: 'mine.png', content_type: 'image/png', data: PNG.toString('base64') });
|
|
189
|
+
const sent = await call('POST', '/ai-studio/requests', { prompt: 'my own request', file_ids: [up.body.file.id] });
|
|
190
|
+
await waitDone(sent.body.request.id);
|
|
191
|
+
|
|
192
|
+
viewer = { id: 8, rank: 'xenos' };
|
|
193
|
+
try {
|
|
194
|
+
const up2 = await call('POST', '/ai-studio/uploads', { name: 'other.png', content_type: 'image/png', data: PNG.toString('base64') });
|
|
195
|
+
const borrow = await call('POST', '/ai-studio/requests', { prompt: 'borrow it', file_ids: [up.body.file.id, up2.body.file.id] });
|
|
196
|
+
assert.equal(borrow.status, 404);
|
|
197
|
+
assert.equal((await call('GET', `/ai-studio/requests/${sent.body.request.id}`)).status, 404);
|
|
198
|
+
assert.equal((await call('GET', `/ai-studio/files/${up.body.file.id}`)).status, 404);
|
|
199
|
+
} finally {
|
|
200
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
201
|
+
}
|
|
202
|
+
// A file already sent cannot be sent again.
|
|
203
|
+
const again = await call('POST', '/ai-studio/requests', { prompt: 'again please', file_ids: [up.body.file.id] });
|
|
204
|
+
assert.equal(again.status, 404);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('refusals: a disguised file, a missing prompt, too many files', async () => {
|
|
208
|
+
const fake = await call('POST', '/ai-studio/uploads', { name: 'x.jpg', content_type: 'image/jpeg', data: PNG.toString('base64') });
|
|
209
|
+
assert.equal(fake.status, 400);
|
|
210
|
+
assert.equal(fake.body.error.code, 'type_mismatch');
|
|
211
|
+
const svg = await call('POST', '/ai-studio/uploads', { name: 'x.svg', content_type: 'image/svg+xml', data: Buffer.from('<svg/>').toString('base64') });
|
|
212
|
+
assert.equal(svg.body.error.code, 'unsupported_type');
|
|
213
|
+
assert.equal((await call('POST', '/ai-studio/requests', { prompt: '', file_ids: ['1'] })).body.error.code, 'prompt_required');
|
|
214
|
+
assert.equal((await call('POST', '/ai-studio/requests', { prompt: 'seven', file_ids: ['1', '2', '3', '4', '5', '6', '7'] })).status, 400);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ---- the builder's own key (task 1004918) ------------------------------------
|
|
218
|
+
|
|
219
|
+
const BUILDER_OR_KEY = 'sk-or-v1-BUILDER-OWN-KEY-fedcba9876543210';
|
|
220
|
+
const BUILDER_GOOGLE_KEY = 'test-builder-google-key-0123456789abcdef';
|
|
221
|
+
|
|
222
|
+
async function sendSketch(body = {}) {
|
|
223
|
+
const up = await call('POST', '/ai-studio/uploads', { name: 'own.png', content_type: 'image/png', data: PNG.toString('base64') });
|
|
224
|
+
return call('POST', '/ai-studio/requests', { prompt: 'Finish on my key', file_ids: [up.body.file.id], ...body });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test('OWN KEY: with an OpenRouter key saved, it runs on that key and charges nothing', async () => {
|
|
228
|
+
ownKeys.set('9/openrouter', box.encrypt(BUILDER_OR_KEY));
|
|
229
|
+
viewer = { id: 9, rank: 'xenos' };
|
|
230
|
+
const balanceBefore = await pay.balance(9);
|
|
231
|
+
const callsBefore = providerCalls.length;
|
|
232
|
+
try {
|
|
233
|
+
const sent = await sendSketch();
|
|
234
|
+
assert.equal(sent.status, 202, sent.raw);
|
|
235
|
+
const r = (await waitDone(sent.body.request.id)).body.request;
|
|
236
|
+
assert.equal(r.status, 'done', r.error);
|
|
237
|
+
assert.equal(r.key_source, 'own');
|
|
238
|
+
assert.equal(r.price_micro, 0, 'we charge nothing on their key');
|
|
239
|
+
assert.equal(r.markup_micro, 0);
|
|
240
|
+
assert.equal(r.provider_cost_micro, 50400, 'what it cost them is still shown');
|
|
241
|
+
assert.deepEqual(await pay.balance(9), balanceBefore, 'no money moved');
|
|
242
|
+
const used = providerCalls.slice(callsBefore);
|
|
243
|
+
assert.equal(used.length, 2);
|
|
244
|
+
assert.ok(used.every((c) => c.auth === `Bearer ${BUILDER_OR_KEY}`), 'their key, not ours');
|
|
245
|
+
// Their key appears in no response and no log line.
|
|
246
|
+
assert.ok(!sent.raw.includes(BUILDER_OR_KEY));
|
|
247
|
+
assert.ok(logged.every((l) => !l.includes(BUILDER_OR_KEY)));
|
|
248
|
+
} finally {
|
|
249
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('OWN KEY: a Google key goes to Gemini directly, as a header, and its unknown cost is shown as unknown', async () => {
|
|
254
|
+
ownKeys.set('10/gemini', box.encrypt(BUILDER_GOOGLE_KEY));
|
|
255
|
+
viewer = { id: 10, rank: 'xenos' };
|
|
256
|
+
const callsBefore = providerCalls.length;
|
|
257
|
+
try {
|
|
258
|
+
const sent = await sendSketch();
|
|
259
|
+
const r = (await waitDone(sent.body.request.id)).body.request;
|
|
260
|
+
assert.equal(r.status, 'done', r.error);
|
|
261
|
+
assert.equal(r.reply_md, 'From Gemini direct: lovely.');
|
|
262
|
+
assert.equal(r.provider_cost_micro, null);
|
|
263
|
+
assert.equal(r.price_micro, 0);
|
|
264
|
+
assert.ok(r.files.some((f) => f.role === 'output'));
|
|
265
|
+
const used = providerCalls.slice(callsBefore);
|
|
266
|
+
assert.equal(used.length, 2);
|
|
267
|
+
assert.ok(used.every((c) => c.url.startsWith('https://generativelanguage.googleapis.com/') && c.googKey === BUILDER_GOOGLE_KEY));
|
|
268
|
+
assert.ok(used.every((c) => !c.url.includes(BUILDER_GOOGLE_KEY)), 'the key is never in the URL');
|
|
269
|
+
} finally {
|
|
270
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('OWN KEY: pay_with ours uses the balance even with a key saved; own with none saved is refused', async () => {
|
|
275
|
+
viewer = { id: 9, rank: 'xenos' };
|
|
276
|
+
await pay.topUp(9, { chargeMicro: 5 * P.MICRO_PER_USD + 1, creditedMicro: 5 * P.MICRO_PER_USD });
|
|
277
|
+
try {
|
|
278
|
+
const sent = await sendSketch({ pay_with: 'ours' });
|
|
279
|
+
const r = (await waitDone(sent.body.request.id)).body.request;
|
|
280
|
+
assert.equal(r.key_source, 'ours');
|
|
281
|
+
assert.equal(r.price_micro, 54432);
|
|
282
|
+
} finally {
|
|
283
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
284
|
+
}
|
|
285
|
+
const none = await sendSketch({ pay_with: 'own' });
|
|
286
|
+
assert.equal(none.status, 409);
|
|
287
|
+
assert.equal(none.body.error.code, 'no_own_key');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('a builder may have only two requests running at once', async () => {
|
|
291
|
+
viewer = { id: 11, rank: 'xenos' };
|
|
292
|
+
const realFetch = globalThis.fetch;
|
|
293
|
+
let release;
|
|
294
|
+
const gate = new Promise((r) => { release = r; });
|
|
295
|
+
globalThis.fetch = async (...a) => { await gate; return realFetch(...a); };
|
|
296
|
+
try {
|
|
297
|
+
ownKeys.set('11/openrouter', box.encrypt(BUILDER_OR_KEY));
|
|
298
|
+
const a = await sendSketch();
|
|
299
|
+
const b = await sendSketch();
|
|
300
|
+
const c = await sendSketch();
|
|
301
|
+
assert.deepEqual([a.status, b.status, c.status], [202, 202, 429]);
|
|
302
|
+
assert.equal(c.body.error.code, 'too_many_running');
|
|
303
|
+
release();
|
|
304
|
+
await waitDone(a.body.request.id);
|
|
305
|
+
await waitDone(b.body.request.id);
|
|
306
|
+
} finally {
|
|
307
|
+
globalThis.fetch = realFetch;
|
|
308
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('an uploaded PDF downloads rather than opening in the page', async () => {
|
|
313
|
+
const pdf = Buffer.from('%PDF-1.7\n%test\n');
|
|
314
|
+
const up = await call('POST', '/ai-studio/uploads', { name: 'brief.pdf', content_type: 'application/pdf', data: pdf.toString('base64') });
|
|
315
|
+
const got = await call('GET', `/ai-studio/files/${up.body.file.id}`);
|
|
316
|
+
assert.match(got.headers['content-disposition'], /^attachment;/);
|
|
317
|
+
const img = await call('POST', '/ai-studio/uploads', { name: 'i.png', content_type: 'image/png', data: PNG.toString('base64') });
|
|
318
|
+
assert.match((await call('GET', `/ai-studio/files/${img.body.file.id}`)).headers['content-disposition'], /^inline;/);
|
|
319
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// tests/auth_backchannel_logout_route.mjs — POST /auth/backchannel-logout, executed
|
|
2
|
+
// for real (task 1004987, goal 1000094).
|
|
3
|
+
//
|
|
4
|
+
// This route needs no sign-in: the hub-signed logout token is the ONLY credential,
|
|
5
|
+
// and a valid one revokes every session the named builder holds. Until this file only
|
|
6
|
+
// the verifier (verifyHubLogoutToken) was tested, in tests/idp_device_exchange.mjs; the
|
|
7
|
+
// route around it — the not-federated 404, the missing-key 503, the 401 on a bad token,
|
|
8
|
+
// the single-use jti, and the no-leak answer for an unknown builder — was not.
|
|
9
|
+
//
|
|
10
|
+
// What is real: the router (src/bongos/routes/auth.js), verifyHubLogoutToken and its
|
|
11
|
+
// signature check, the logout jti store, the body validator and the error envelope.
|
|
12
|
+
// Tokens are minted by the REAL hub minter (modules/platform-identity/hub-keys.js) with
|
|
13
|
+
// a keypair generated here, the tests/idp_device_exchange.mjs keypair recipe. What is
|
|
14
|
+
// faked: auth.loadIdpConfig / auth.loadHubPublicKey (the instance's configuration),
|
|
15
|
+
// db.getBuilderByGithubId (who exists) and db.revokeAllSessionsForBuilder, which
|
|
16
|
+
// RECORDS instead of revoking. Every refusal asserts that nothing was revoked.
|
|
17
|
+
//
|
|
18
|
+
// Run: node --test tests/auth_backchannel_logout_route.mjs
|
|
19
|
+
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const express = require('express');
|
|
28
|
+
|
|
29
|
+
const auth = require('../src/bongos/auth.js');
|
|
30
|
+
const db = require('../src/bongos/db.js');
|
|
31
|
+
const hubKeys = require('../modules/platform-identity/hub-keys.js');
|
|
32
|
+
const buildAuthRouter = require('../src/bongos/routes/auth.js');
|
|
33
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
34
|
+
|
|
35
|
+
// ── keys: the hub's, and a forger's ──────────────────────────────────────────
|
|
36
|
+
const { privateKeyB64, publicKeyB64 } = hubKeys.generateHubKeypair();
|
|
37
|
+
const hubSigningKey = crypto.createPrivateKey({ key: Buffer.from(privateKeyB64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
38
|
+
const hubPublicKey = crypto.createPublicKey({ key: Buffer.from(publicKeyB64, 'base64'), format: 'der', type: 'spki' });
|
|
39
|
+
const forgerSigningKey = crypto.generateKeyPairSync('ed25519').privateKey;
|
|
40
|
+
|
|
41
|
+
const CLIENT_ID = 'client-under-test';
|
|
42
|
+
const KNOWN_GITHUB_ID = 4242;
|
|
43
|
+
const KNOWN_BUILDER = { id: 77, github_id: String(KNOWN_GITHUB_ID), github_login: 'known-builder' };
|
|
44
|
+
|
|
45
|
+
const mintLogout = ({ githubId = KNOWN_GITHUB_ID, clientId = CLIENT_ID } = {}, opts = {}) =>
|
|
46
|
+
hubKeys.mintHubLogoutToken({ clientId, githubId }, { signingKey: hubSigningKey, ...opts });
|
|
47
|
+
|
|
48
|
+
// ── the instance's configuration and data, per case ──────────────────────────
|
|
49
|
+
let idp = { clientId: CLIENT_ID };
|
|
50
|
+
let publicKey = hubPublicKey;
|
|
51
|
+
let revokes = [];
|
|
52
|
+
let lookups = [];
|
|
53
|
+
|
|
54
|
+
beforeEach(() => { idp = { clientId: CLIENT_ID }; publicKey = hubPublicKey; revokes = []; lookups = []; });
|
|
55
|
+
|
|
56
|
+
auth.loadIdpConfig = () => idp;
|
|
57
|
+
auth.loadHubPublicKey = () => publicKey;
|
|
58
|
+
db.getBuilderByGithubId = async (githubId) => {
|
|
59
|
+
lookups.push(String(githubId));
|
|
60
|
+
return String(githubId) === String(KNOWN_GITHUB_ID) ? { ...KNOWN_BUILDER } : null;
|
|
61
|
+
};
|
|
62
|
+
db.revokeAllSessionsForBuilder = async (builderId) => { revokes.push(builderId); return 3; };
|
|
63
|
+
|
|
64
|
+
const app = express();
|
|
65
|
+
app.use(express.json());
|
|
66
|
+
app.use(attachFail);
|
|
67
|
+
app.use('/api/gds', buildAuthRouter());
|
|
68
|
+
|
|
69
|
+
let server, port;
|
|
70
|
+
before(async () => {
|
|
71
|
+
server = http.createServer(app);
|
|
72
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
73
|
+
port = server.address().port;
|
|
74
|
+
});
|
|
75
|
+
after(async () => { await new Promise((r) => server.close(r)); });
|
|
76
|
+
|
|
77
|
+
function logout(body) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const payload = JSON.stringify(body);
|
|
80
|
+
const req = http.request({
|
|
81
|
+
host: '127.0.0.1', port, path: '/api/gds/auth/backchannel-logout', method: 'POST',
|
|
82
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
83
|
+
}, (res) => {
|
|
84
|
+
let raw = '';
|
|
85
|
+
res.on('data', (c) => { raw += c; });
|
|
86
|
+
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* text */ } resolve({ status: res.statusCode, json, raw }); });
|
|
87
|
+
});
|
|
88
|
+
req.on('error', reject);
|
|
89
|
+
req.write(payload);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── not a public surface unless the instance federates ───────────────────────
|
|
95
|
+
|
|
96
|
+
test('idp not configured → 404, no lookup, no revoke — even for a perfectly valid token', async () => {
|
|
97
|
+
idp = null;
|
|
98
|
+
const r = await logout({ logout_token: mintLogout() });
|
|
99
|
+
assert.equal(r.status, 404);
|
|
100
|
+
assert.equal(r.raw, 'not found', 'indistinguishable from a route that does not exist');
|
|
101
|
+
assert.deepEqual(lookups, []);
|
|
102
|
+
assert.deepEqual(revokes, []);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('no hub public key set → 503 idp_unconfigured and no revoke', async () => {
|
|
106
|
+
publicKey = null;
|
|
107
|
+
const r = await logout({ logout_token: mintLogout() });
|
|
108
|
+
assert.equal(r.status, 503);
|
|
109
|
+
assert.equal(r.json.error.code, 'idp_unconfigured');
|
|
110
|
+
assert.deepEqual(lookups, []);
|
|
111
|
+
assert.deepEqual(revokes, []);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── forged and wrong tokens: 401, nothing revoked ────────────────────────────
|
|
115
|
+
|
|
116
|
+
test('a forged token (signed by a key that is not the hub’s) → 401 and no revoke', async () => {
|
|
117
|
+
const forged = hubKeys.mintHubLogoutToken({ clientId: CLIENT_ID, githubId: KNOWN_GITHUB_ID }, { signingKey: forgerSigningKey });
|
|
118
|
+
const r = await logout({ logout_token: forged });
|
|
119
|
+
assert.equal(r.status, 401);
|
|
120
|
+
assert.equal(r.json.error.code, 'invalid_logout_token');
|
|
121
|
+
assert.match(r.json.error.message, /signature invalid/);
|
|
122
|
+
assert.deepEqual(lookups, [], 'the subject of an unverified token is never even looked up');
|
|
123
|
+
assert.deepEqual(revokes, []);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a genuine token with its subject swapped (payload re-encoded, signature kept) → 401 and no revoke', async () => {
|
|
127
|
+
const [h, p, s] = mintLogout({ githubId: 1 }).split('.');
|
|
128
|
+
const claims = JSON.parse(Buffer.from(p, 'base64url').toString('utf8'));
|
|
129
|
+
claims.sub = String(KNOWN_GITHUB_ID);
|
|
130
|
+
const tampered = `${h}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.${s}`;
|
|
131
|
+
const r = await logout({ logout_token: tampered });
|
|
132
|
+
assert.equal(r.status, 401);
|
|
133
|
+
assert.deepEqual(revokes, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a hub-signed token minted for ANOTHER instance → 401 and no revoke', async () => {
|
|
137
|
+
const r = await logout({ logout_token: mintLogout({ clientId: 'some-other-project' }) });
|
|
138
|
+
assert.equal(r.status, 401);
|
|
139
|
+
assert.match(r.json.error.message, /audience/);
|
|
140
|
+
assert.deepEqual(revokes, []);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('a hub LOGIN assertion (no logout purpose) cannot log anyone out → 401 and no revoke', async () => {
|
|
144
|
+
const assertion = hubKeys.mintHubAssertion(
|
|
145
|
+
{ clientId: CLIENT_ID, githubId: KNOWN_GITHUB_ID, githubLogin: 'known-builder', displayName: null, avatarUrl: null },
|
|
146
|
+
{ signingKey: hubSigningKey },
|
|
147
|
+
);
|
|
148
|
+
const r = await logout({ logout_token: assertion });
|
|
149
|
+
assert.equal(r.status, 401);
|
|
150
|
+
assert.deepEqual(revokes, []);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('an expired token → 401 and no revoke', async () => {
|
|
154
|
+
const r = await logout({ logout_token: mintLogout({}, { nowMs: Date.now() - 10 * 60 * 1000 }) });
|
|
155
|
+
assert.equal(r.status, 401);
|
|
156
|
+
assert.match(r.json.error.message, /expired/);
|
|
157
|
+
assert.deepEqual(revokes, []);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('no token at all → 400 and no revoke', async () => {
|
|
161
|
+
const r = await logout({});
|
|
162
|
+
assert.equal(r.status, 400);
|
|
163
|
+
assert.deepEqual(revokes, []);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── a valid token: once, and only once ───────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
test('a valid token for a known builder → 200 and exactly one revoke, of that builder', async () => {
|
|
169
|
+
const r = await logout({ logout_token: mintLogout() });
|
|
170
|
+
assert.equal(r.status, 200);
|
|
171
|
+
assert.deepEqual(r.json, { ok: true });
|
|
172
|
+
assert.deepEqual(lookups, [String(KNOWN_GITHUB_ID)]);
|
|
173
|
+
assert.deepEqual(revokes, [KNOWN_BUILDER.id]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('the same token again (same jti) → 200, and still only the one revoke', async () => {
|
|
177
|
+
const token = mintLogout();
|
|
178
|
+
const first = await logout({ logout_token: token });
|
|
179
|
+
const replay = await logout({ logout_token: token });
|
|
180
|
+
const replayAgain = await logout({ logout_token: token });
|
|
181
|
+
assert.equal(first.status, 200);
|
|
182
|
+
assert.equal(replay.status, 200, 'a replay is an idempotent no-op, not an error');
|
|
183
|
+
assert.equal(replay.raw, first.raw, 'and answers the same bytes');
|
|
184
|
+
assert.equal(replayAgain.status, 200);
|
|
185
|
+
assert.deepEqual(revokes, [KNOWN_BUILDER.id], 'a captured token cannot keep killing sessions made after it');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a fresh token for the same builder (a new jti) revokes again — the guard is per token, not per builder', async () => {
|
|
189
|
+
await logout({ logout_token: mintLogout() });
|
|
190
|
+
await logout({ logout_token: mintLogout() });
|
|
191
|
+
assert.deepEqual(revokes, [KNOWN_BUILDER.id, KNOWN_BUILDER.id]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a valid token for an unknown sub → 200, zero revokes, and the same body as the known case', async () => {
|
|
195
|
+
const known = await logout({ logout_token: mintLogout() });
|
|
196
|
+
revokes = [];
|
|
197
|
+
const unknown = await logout({ logout_token: mintLogout({ githubId: 999001 }) });
|
|
198
|
+
assert.equal(unknown.status, 200);
|
|
199
|
+
assert.deepEqual(revokes, []);
|
|
200
|
+
assert.equal(unknown.raw, known.raw, 'whether this builder exists here is never leaked');
|
|
201
|
+
});
|
|
@@ -223,7 +223,16 @@ await test('the ship hook cannot fail a ship, and the sweep exists to retry it',
|
|
|
223
223
|
assert.match(line, /auto-satisfy skipped \(sweep retries\)/, 'the failure is reported, not thrown');
|
|
224
224
|
assert.match(logged.join('\n'), /shipTask 5/, 'and it names the ship it happened on');
|
|
225
225
|
const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
|
|
226
|
-
|
|
226
|
+
// task 1004924: the tick runs the guarded SWEEPS list (fire-and-forget, a
|
|
227
|
+
// sweep still in flight is skipped), so the wiring is an entry there.
|
|
228
|
+
// task 1004965: an entry's third element is its cadence; these two have none, so
|
|
229
|
+
// they keep the every-tick cadence while promotion and the craft backfill go hourly.
|
|
230
|
+
assert.match(rec, /\['criterion sweep', runCriterionSweep\]/, 'the sweep is fire-and-forget on EVERY tick, like runCostSweep');
|
|
231
|
+
assert.match(rec, /\['cost sweep', runCostSweep\]/);
|
|
232
|
+
const reconciler = require('../modules/lifecycle/publish-reconciler.js');
|
|
233
|
+
const cadence = new Map(reconciler.SWEEPS.map(([name, , everyMs]) => [name, everyMs]));
|
|
234
|
+
assert.equal(cadence.get('criterion sweep'), undefined, 'the criterion sweep is not slowed to hourly');
|
|
235
|
+
assert.equal(cadence.get('cost sweep'), undefined, 'nor the cost sweep');
|
|
227
236
|
assert.match(rec, /BACKLOG DRAIN/, 'and documents why it is not merely belt-and-braces');
|
|
228
237
|
});
|
|
229
238
|
|