@bongos/core 1.23.22 → 1.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +902 -407
- package/.claude/skills/blocker-review/SKILL.md +9 -1
- package/.claude/skills/blocker-solve/SKILL.md +1 -1
- package/.claude/skills/builder-claim/SKILL.md +1 -1
- package/.claude/skills/builder-reauth/SKILL.md +1 -1
- package/.claude/skills/builder-redteam/SKILL.md +1 -1
- package/.claude/skills/builder-sequence/SKILL.md +1 -1
- package/.claude/skills/builder-setup/SKILL.md +1 -1
- package/.claude/skills/builder-ship/SKILL.md +1 -1
- package/.claude/skills/collab-review/SKILL.md +5 -2
- package/.claude/skills/design/SKILL.md +1 -1
- package/.claude/skills/goal-create/SKILL.md +2 -2
- package/.claude/skills/grade-recover/SKILL.md +1 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/.claude/skills/owner-review/SKILL.md +1 -1
- package/.claude/skills/planning-session/SKILL.md +11 -11
- package/.claude/skills/read-session-export/SKILL.md +1 -1
- package/.claude/skills/recall/SKILL.md +1 -1
- package/.claude/skills/scan-before-install/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/strand-fix/SKILL.md +13 -0
- package/.claude/skills/worktree-clean/SKILL.md +1 -1
- package/AGENTS.md +15 -0
- package/GEMINI.md +5 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +90 -0
- package/clients/bongos-client/index.cjs +90 -0
- package/clients/bongos-client/index.d.ts +149 -4
- package/clients/bongos-client/index.mjs +90 -0
- package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
- package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
- package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
- package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
- package/docs/adr/README.md +5 -1
- package/docs/api/openapi.json +8555 -5876
- package/docs/api-reference.md +63 -10
- package/docs/architecture.md +37 -1
- package/docs/canonical-permissions.md +1 -1
- package/docs/copy-inventory.md +420 -337
- package/docs/copy-registry.json +1321 -500
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +5 -0
- package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
- package/docs/onboarding/diagrams/README.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +15 -15
- package/docs/onboarding/primer.md +2 -2
- package/docs/page-inventory.json +55 -6
- package/docs/page-readings.json +1185 -1011
- package/docs/recipes/google-sign-in.md +60 -0
- package/docs/recipes/ops-gotchas.md +1 -1
- package/migrations/core_282_builders_lower_login_idx.sql +33 -0
- package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
- package/migrations/core_283_payout_approvals.sql +183 -0
- package/migrations/core_284_payout_gates.sql +179 -0
- package/migrations/core_285_task_needs.sql +74 -0
- package/modules/ai-studio/billing.js +67 -0
- package/modules/ai-studio/context.js +29 -0
- package/modules/ai-studio/db.js +172 -0
- package/modules/ai-studio/gemini.js +75 -0
- package/modules/ai-studio/generate.js +119 -0
- package/modules/ai-studio/keys.js +73 -0
- package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
- package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
- package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
- package/modules/ai-studio/openrouter.js +90 -0
- package/modules/ai-studio/payments.js +105 -0
- package/modules/ai-studio/pricing.js +92 -0
- package/modules/ai-studio/routes/ai-studio.js +235 -5
- package/modules/ai-studio/studio.js +121 -0
- package/modules/autonomy/fence.js +64 -1
- package/modules/builder-settings/builder-needs.js +27 -1
- package/modules/copy-desk/routes/copy-desk.js +5 -0
- package/modules/economy/achievements.js +22 -10
- package/modules/economy/cost-classify.js +9 -0
- package/modules/economy/cost-write-guard.js +52 -0
- package/modules/economy/cost.js +2 -1
- package/modules/economy/credits.js +18 -15
- package/modules/economy/goal-reward.js +161 -0
- package/modules/economy/reward.js +4 -0
- package/modules/economy/routes/cost.js +33 -7
- package/modules/government/catalog.js +6 -0
- package/modules/government/db.js +34 -0
- package/modules/government/docket.js +36 -0
- package/modules/government/migrations/government_035_payout_approve.sql +29 -0
- package/modules/government/pollers/board-expiry.js +108 -20
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/government/routes/government.js +22 -0
- package/modules/government/routing.js +39 -5
- package/modules/government/waiting.js +74 -0
- package/modules/hall-ui/public/ai-studio-lib.js +268 -0
- package/modules/hall-ui/public/ai-studio.css +193 -0
- package/modules/hall-ui/public/ai-studio.html +98 -0
- package/modules/hall-ui/public/ai-studio.js +556 -0
- package/modules/hall-ui/public/ai-studio.states.json +94 -0
- package/modules/hall-ui/public/blockers-by-person.js +156 -0
- package/modules/hall-ui/public/blockers-lib.js +15 -1
- package/modules/hall-ui/public/blockers.html +8 -0
- package/modules/hall-ui/public/blockers.js +1 -1
- package/modules/hall-ui/public/claim-action.js +3 -0
- package/modules/hall-ui/public/collab.html +12 -0
- package/modules/hall-ui/public/craft-mode.js +4 -1
- package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
- package/modules/hall-ui/public/goal-inbox.js +1 -1
- package/modules/hall-ui/public/goals-render.js +4 -0
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/gov-living.js +6 -2
- package/modules/hall-ui/public/hall-ask.css +5 -0
- package/modules/hall-ui/public/hall-ask.js +39 -3
- package/modules/hall-ui/public/notes-panel.css +20 -0
- package/modules/hall-ui/public/notes-panel.js +146 -0
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +7 -5
- package/modules/hall-ui/public/settings-ai-keys.js +124 -0
- package/modules/hall-ui/public/settings.html +15 -0
- package/modules/hall-ui/public/street-deck.js +12 -3
- package/modules/hall-ui/public/studio.css +90 -8
- package/modules/hall-ui/public/studio.html +39 -10
- package/modules/hall-ui/public/studio.js +148 -18
- package/modules/hall-ui/public/task.html +2 -0
- package/modules/hall-ui/public/task.js +4 -0
- package/modules/hall-ui/public/tweak-editor.js +12 -3
- package/modules/hall-ui/public/welcome-boot.css +42 -6
- package/modules/hall-ui/public/welcome-boot.js +20 -6
- package/modules/hall-ui/public/work.js +20 -0
- package/modules/hall-ui/records/ai-studio.md +36 -0
- package/modules/hall-ui/records/blockers-people.md +24 -0
- package/modules/lifecycle/db-claims.js +32 -4
- package/modules/lifecycle/db-goals.js +18 -5
- package/modules/lifecycle/db-tasks.js +15 -6
- package/modules/lifecycle/done-when.js +23 -1
- package/modules/lifecycle/lifecycle.js +5 -0
- package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/notes.js +219 -0
- package/modules/lifecycle/page-notice.js +124 -0
- package/modules/lifecycle/page-tweak-artist.js +18 -1
- package/modules/lifecycle/publish-reconciler.js +67 -20
- package/modules/lifecycle/routes/claims.js +5 -2
- package/modules/lifecycle/routes/goals.js +3 -1
- package/modules/lifecycle/routes/notes.js +91 -0
- package/modules/lifecycle/routes/project-settings.js +43 -35
- package/modules/lifecycle/routes/task-route-helpers.js +21 -0
- package/modules/lifecycle/routes/task-write-routes.js +21 -3
- package/modules/lifecycle/routes/tasks.js +12 -12
- package/modules/lifecycle/task-needs-gate.js +70 -0
- package/modules/market-watch/CLAUDE.md +15 -0
- package/modules/market-watch/db.js +340 -0
- package/modules/market-watch/key.js +58 -0
- package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
- package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
- package/modules/market-watch/module.json +26 -0
- package/modules/market-watch/pollers/hub-seed.js +54 -0
- package/modules/market-watch/pollers/weekly-run.js +100 -0
- package/modules/market-watch/research.js +350 -0
- package/modules/market-watch/run.js +245 -0
- package/modules/market-watch/seed/market-data.json +38 -0
- package/modules/market-watch/seed/partners-data.json +497 -0
- package/modules/market-watch/seed.js +252 -0
- package/modules/market-watch/store.js +230 -0
- package/modules/memory/routes/memory.js +14 -2
- package/modules/memory/routes/search.js +8 -2
- package/modules/platform-identity/account-logins.js +192 -0
- package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
- package/modules/platform-identity/platform-identity.js +5 -4
- package/modules/platform-identity/routes/my-projects.js +6 -0
- package/modules/platform-identity/routes/profile.js +18 -0
- package/modules/platform-identity/routes/public-profile.js +10 -1
- package/modules/platform-identity/routes/sso.js +26 -1
- package/modules/platform-identity/tests/platform-identity.mjs +2 -1
- package/modules/provisioning/credential-preflight.js +13 -3
- package/modules/provisioning/pollers/app-liveness.js +25 -5
- package/modules/provisioning/routes/provisioning.js +1 -1
- package/modules/provisioning/starter-bundles.js +6 -2
- package/modules/public-landing/public/account.html +10 -2
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
- package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
- package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
- package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
- package/modules/public-landing/public/community.html +1 -1
- package/modules/public-landing/public/connections.html +2 -2
- package/modules/public-landing/public/guild.html +1 -1
- package/modules/public-landing/public/invitations.html +2 -2
- package/modules/public-landing/public/projects.html +5 -5
- package/modules/raise/views/nda.html +2 -0
- package/modules/specialities/db.js +32 -1
- package/modules/specialities/routes/specialities.js +10 -6
- package/modules/status-ui/public/index.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +220 -0
- package/scripts/bongos/autobongos-tweak-lane.js +46 -1
- package/scripts/bongos/backfill-subagent-rewards.js +2 -2
- package/scripts/bongos/claim.js +22 -1
- package/scripts/bongos/db-backup-nightly.sh +29 -0
- package/scripts/bongos/deploy-divergence.js +7 -0
- package/scripts/bongos/doc-cli-guard.js +6 -0
- package/scripts/bongos/fitness.js +3 -2
- package/scripts/bongos/gen-diagrams.js +3 -3
- package/scripts/bongos/lib.sh +4 -5
- package/scripts/bongos/migration-namespace.js +13 -1
- package/scripts/bongos/plain-cards.js +4 -0
- package/scripts/bongos/provision-github-setup.js +3 -1
- package/scripts/bongos/provision-units.js +6 -1
- package/scripts/bongos/publish-manifest.js +3 -0
- package/scripts/bongos/release.js +2 -0
- package/scripts/bongos/run-unit-tests.js +268 -82
- package/scripts/bongos/seed-tasks.example.json +56 -0
- package/scripts/bongos/seed-tasks.js +222 -0
- package/scripts/bongos/seed-verify-criteria.js +1 -2
- package/scripts/bongos/ship-finish.js +2 -0
- package/scripts/bongos/ship-resume.js +2 -0
- package/scripts/bongos/strand-lease.js +250 -0
- package/scripts/bongos/strand-watch.js +26 -5
- package/scripts/bongos/version-literal-guard.js +2 -2
- package/scripts/discord/post.js +4 -3
- package/scripts/gds/seed-tasks.js +7 -0
- package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/auth-config.js +29 -1
- package/src/bongos/auth-discord.js +3 -3
- package/src/bongos/auth-github.js +3 -6
- package/src/bongos/auth-google.js +217 -0
- package/src/bongos/device-approval.js +140 -0
- package/src/bongos/llm-pricing.js +17 -1
- package/src/bongos/metrics.js +48 -2
- package/src/bongos/middleware/rate-limit.js +30 -11
- package/src/bongos/module-scope-map.js +12 -1
- package/src/bongos/optimizer.js +15 -1
- package/src/bongos/payout-approvals.js +342 -0
- package/src/bongos/payout-gates.js +364 -0
- package/src/bongos/prelaunch-gate.js +7 -9
- package/src/bongos/prelaunch-switch.js +20 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/bongos/routes/auth-google.js +246 -0
- package/src/bongos/routes/auth.js +38 -1
- package/src/bongos/routes/backup.js +33 -9
- package/src/bongos/routes/builders.js +57 -30
- package/src/bongos/routes/github.js +9 -0
- package/src/bongos/routes/me.js +18 -0
- package/src/bongos/routes/my-payouts.js +133 -0
- package/src/bongos/routes/payout-approvals.js +312 -0
- package/src/bongos/routes/payout-gates.js +179 -0
- package/src/bongos/routes.js +15 -1
- package/src/bongos/secret-box.js +18 -3
- package/src/bongos/serve-internal.js +47 -19
- package/src/bongos/sign-in-pages.js +138 -0
- package/src/bongos/task-needs.js +146 -0
- package/src/bongos-downloads.js +18 -1
- package/src/instance-config.js +28 -0
- package/src/module-api.js +14 -1
- package/tests/account_logins.mjs +326 -0
- package/tests/account_logins_db.mjs +161 -0
- package/tests/ai_studio_billing.mjs +253 -0
- package/tests/ai_studio_generate.mjs +282 -0
- package/tests/ai_studio_keys.mjs +159 -0
- package/tests/ai_studio_requests.mjs +319 -0
- package/tests/auth_backchannel_logout_route.mjs +201 -0
- package/tests/auto_satisfy_criteria.mjs +10 -1
- package/tests/autobongos_tweak_lane.mjs +144 -0
- package/tests/autobongos_tweak_pin_identity.mjs +137 -0
- package/tests/backfill_subagent_rewards.mjs +17 -1
- package/tests/backup_trigger_lock.mjs +119 -0
- package/tests/blockers_people.mjs +198 -0
- package/tests/bounded_rate_maps.mjs +184 -0
- package/tests/claimable_blocker_status.mjs +1 -1
- package/tests/claims_batch_routes.mjs +421 -0
- package/tests/config_read_cache.mjs +171 -0
- package/tests/copy_desk_page_editor.mjs +12 -0
- package/tests/cost_write_guard.mjs +200 -0
- package/tests/craft_achievements.mjs +33 -10
- package/tests/criterion_goal_attach.mjs +1 -1
- package/tests/db_backup_nightly.mjs +73 -1
- package/tests/deploy_divergence_line.mjs +7 -0
- package/tests/effective_visibility_predicate.mjs +12 -0
- package/tests/fitness.mjs +19 -0
- package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
- package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
- package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
- package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
- package/tests/github_one_trip.mjs +34 -0
- package/tests/github_repo_visibility.mjs +20 -0
- package/tests/goal_achievement.mjs +30 -1
- package/tests/goal_achievement_reward.mjs +205 -0
- package/tests/goal_sort_staleness.mjs +2 -1
- package/tests/goal_tier.mjs +11 -1
- package/tests/google_fonts_nonblocking.mjs +84 -0
- package/tests/google_sign_in.mjs +401 -0
- package/tests/government_board_expiry.mjs +94 -0
- package/tests/government_protected_surfaces.mjs +1 -1
- package/tests/government_routes.mjs +1 -1
- package/tests/hall_ai_studio.mjs +347 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_studio_home.mjs +61 -2
- package/tests/home_attention_cards.mjs +1 -1
- package/tests/human_signal.mjs +141 -0
- package/tests/install_script_reader.mjs +51 -0
- package/tests/integration_fixture_ids.mjs +82 -0
- package/tests/invite_expiry_db.mjs +13 -1
- package/tests/lib_sh_resolution.mjs +0 -4
- package/tests/market_watch_data.mjs +244 -0
- package/tests/market_watch_data_db.mjs +308 -0
- package/tests/market_watch_module.mjs +114 -0
- package/tests/market_watch_run.mjs +503 -0
- package/tests/metrics.mjs +124 -0
- package/tests/migration_namespace.mjs +14 -0
- package/tests/module_api.mjs +2 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/notes.mjs +182 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/page_tweak_marker_sql.mjs +76 -0
- package/tests/page_tweak_no_block.mjs +135 -0
- package/tests/page_tweak_notice.mjs +103 -0
- package/tests/payout_approvals.mjs +253 -0
- package/tests/payout_approvals_db.mjs +106 -0
- package/tests/payout_gates.mjs +322 -0
- package/tests/payout_gates_db.mjs +113 -0
- package/tests/planning_session_skill.mjs +13 -3
- package/tests/project_settings_page.mjs +29 -5
- package/tests/project_settings_spend_cap_route.mjs +235 -0
- package/tests/promotion_sweep.mjs +29 -1
- package/tests/provision.mjs +3 -2
- package/tests/provisioning_app_status.mjs +18 -0
- package/tests/provisioning_credential_preflight.mjs +53 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
- package/tests/publish_reconciler_overlap.mjs +60 -0
- package/tests/rank_change_broadcast_port.mjs +151 -0
- package/tests/seed_tasks.mjs +176 -0
- package/tests/seed_verify_criteria.mjs +2 -6
- package/tests/site_classified_once.mjs +142 -0
- package/tests/speciality_db_shape.mjs +23 -0
- package/tests/speciality_session_skills.mjs +48 -2
- package/tests/strand_lease.mjs +249 -0
- package/tests/strand_watch.mjs +10 -0
- package/tests/task_criteria_routes.mjs +275 -0
- package/tests/task_criteria_unlink_db.mjs +123 -0
- package/tests/task_needs.mjs +233 -0
- package/tests/task_needs_db.mjs +110 -0
- package/tests/unit_runner_serial.mjs +266 -43
- package/tests/version_literals.mjs +5 -7
- package/tests/welcome_boot.mjs +16 -5
- package/tests/welcome_boot_browser.mjs +36 -4
- package/docs/recipes/bongos-cli-release.md +0 -66
- package/scripts/bongos/backfill-disciplines.js +0 -144
- package/scripts/bongos/backfill-session-earnings.js +0 -123
- package/scripts/bongos/backfill-session-types.js +0 -87
- package/scripts/bongos/build-bongos.js +0 -94
- package/scripts/bongos/cleanup-orphans.js +0 -74
- package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
- package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
- package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
- package/scripts/bongos/smoke-batch-claim.sh +0 -238
- package/scripts/bongos/smoke-batch-release.sh +0 -316
- package/scripts/bongos/smoke-claim-consistency.sh +0 -222
- package/scripts/bongos/smoke-dependencies.sh +0 -143
- package/scripts/gds/backfill-disciplines.js +0 -7
- package/scripts/gds/backfill-session-earnings.js +0 -7
- package/scripts/gds/backfill-session-types.js +0 -7
- package/scripts/gds/cleanup-orphans.js +0 -7
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
- package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
- package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
- package/scripts/gds/smoke-batch-claim.sh +0 -8
- package/scripts/gds/smoke-batch-release.sh +0 -8
- package/scripts/gds/smoke-claim-consistency.sh +0 -8
- package/scripts/gds/smoke-dependencies.sh +0 -8
- package/tests/smoke_dependencies_witness.mjs +0 -46
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// tests/ai_studio_keys.mjs — a builder's own AI provider keys (task 1004919,
|
|
2
|
+
// goal 1000136, ADR 0392): saved only through the Settings route, stored
|
|
3
|
+
// encrypted, and NEVER sent back to the browser or written to a log.
|
|
4
|
+
//
|
|
5
|
+
// Drives the real router over a stubbed db and a stand-in secret box, so it
|
|
6
|
+
// needs no Postgres and no master key.
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const K = require('../modules/ai-studio/keys.js');
|
|
16
|
+
const db = require('../modules/ai-studio/db.js');
|
|
17
|
+
const api = require('../src/module-api.js');
|
|
18
|
+
|
|
19
|
+
const SECRET = 'sk-or-v1-THIS-IS-A-TEST-KEY-abcdef0123456789WXYZ';
|
|
20
|
+
|
|
21
|
+
// ---- pure ------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
test('cleanKey trims, refuses blanks, spaces and silly lengths, and never echoes the key', () => {
|
|
24
|
+
assert.deepEqual(K.cleanKey(` ${SECRET}\n`), { key: SECRET });
|
|
25
|
+
assert.equal(K.cleanKey('').error, 'key_required');
|
|
26
|
+
assert.equal(K.cleanKey(undefined).error, 'key_required');
|
|
27
|
+
assert.equal(K.cleanKey('short').error, 'key_wrong_length');
|
|
28
|
+
assert.equal(K.cleanKey('abcdefgh ijklmnopqrstu').error, 'key_has_spaces');
|
|
29
|
+
for (const bad of ['short', 'abcdefgh ijklmnopqrstu']) assert.ok(!JSON.stringify(K.cleanKey(bad)).includes(bad));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('publicKey carries no field that could hold the key or its ciphertext', () => {
|
|
33
|
+
const row = { provider: 'openrouter', key_enc: 'v1.iv.tag.CIPHERTEXT', last4: 'WXYZ', set_at: '2026-10-28T00:00:00Z', plaintext: SECRET };
|
|
34
|
+
const out = K.publicKey('openrouter', row);
|
|
35
|
+
assert.deepEqual(Object.keys(out).sort(), ['label', 'last4', 'learn_more_url', 'provider', 'set', 'set_at']);
|
|
36
|
+
assert.equal(out.set, true);
|
|
37
|
+
assert.equal(out.last4, 'WXYZ');
|
|
38
|
+
assert.ok(!JSON.stringify(out).includes('CIPHERTEXT'));
|
|
39
|
+
assert.ok(!JSON.stringify(out).includes(SECRET));
|
|
40
|
+
assert.deepEqual(K.listPublic([]).map((k) => [k.provider, k.set]), [['openrouter', false], ['gemini', false]]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('the migration stores only ciphertext and the last four, for known providers', () => {
|
|
44
|
+
const sql = fs.readFileSync(new URL('../modules/ai-studio/migrations/ai_studio_001_keys.sql', import.meta.url), 'utf8');
|
|
45
|
+
assert.match(sql, /key_enc\s+TEXT\s+NOT NULL/);
|
|
46
|
+
assert.match(sql, /CHECK \(key_enc LIKE 'v1\.%'\)/);
|
|
47
|
+
assert.match(sql, /CHECK \(provider IN \('openrouter', 'gemini'\)\)/);
|
|
48
|
+
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('ai_studio_001_keys'\)/);
|
|
49
|
+
assert.deepEqual(Object.keys(K.PROVIDERS).sort(), ['gemini', 'openrouter'], 'the CHECK and PROVIDERS must name the same providers');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---- the routes ------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const store = new Map();
|
|
55
|
+
db.listKeyMeta = async (builderId) => [...store.values()].filter((r) => r.builder_id === builderId).map((r) => ({ provider: r.provider, last4: r.last4, set_at: r.set_at, key_enc: true }));
|
|
56
|
+
db.setKey = async (builderId, provider, keyEnc, last4) => {
|
|
57
|
+
const row = { builder_id: builderId, provider, key_enc: keyEnc, last4, set_at: '2026-10-28T00:00:00Z' };
|
|
58
|
+
store.set(`${builderId}/${provider}`, row);
|
|
59
|
+
return { provider, last4, set_at: row.set_at, key_enc: true };
|
|
60
|
+
};
|
|
61
|
+
db.clearKey = async (builderId, provider) => store.delete(`${builderId}/${provider}`);
|
|
62
|
+
|
|
63
|
+
let configured = true;
|
|
64
|
+
const box = { isConfigured: () => configured, encrypt: (p) => `v1.iv.tag.${Buffer.from(p).toString('base64url')}`, decrypt: () => { throw new Error('a route must never decrypt'); } };
|
|
65
|
+
const logged = [];
|
|
66
|
+
const capture = (...args) => logged.push(JSON.stringify(args));
|
|
67
|
+
const fakeLog = { info: capture, warn: capture, error: capture, debug: capture, child: () => fakeLog };
|
|
68
|
+
let viewer = { id: 7, rank: 'xenos' };
|
|
69
|
+
for (const [name, value] of Object.entries({
|
|
70
|
+
secretBox: box,
|
|
71
|
+
logger: () => fakeLog,
|
|
72
|
+
requireBuilder: (req, res, next) => { if (!viewer) return res.status(401).json({ error: { code: 'unauthenticated' } }); req.builder = viewer; next(); },
|
|
73
|
+
requireRank: () => (req, res, next) => next(),
|
|
74
|
+
})) Object.defineProperty(api, name, { value, configurable: true, writable: true });
|
|
75
|
+
|
|
76
|
+
const express = require('express');
|
|
77
|
+
const app = express();
|
|
78
|
+
app.use((req, res, next) => {
|
|
79
|
+
res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code, message: opts && opts.message } });
|
|
80
|
+
next();
|
|
81
|
+
});
|
|
82
|
+
app.use(express.json({ limit: '64kb' }));
|
|
83
|
+
app.use('/api/bongos', require('../modules/ai-studio/routes/ai-studio.js')());
|
|
84
|
+
const server = app.listen(0);
|
|
85
|
+
await new Promise((r) => server.once('listening', r));
|
|
86
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
87
|
+
test.after(() => server.close());
|
|
88
|
+
|
|
89
|
+
function call(method, path, body) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const data = body === undefined ? null : JSON.stringify(body);
|
|
92
|
+
const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
res.on('data', (c) => chunks.push(c));
|
|
95
|
+
res.on('end', () => { const raw = Buffer.concat(chunks).toString(); resolve({ status: res.statusCode, raw, body: JSON.parse(raw) }); });
|
|
96
|
+
});
|
|
97
|
+
req.on('error', reject);
|
|
98
|
+
if (data) req.write(data);
|
|
99
|
+
req.end();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('THE WHOLE PATH: save a key, list it, replace it, remove it, and the key never comes back', async () => {
|
|
104
|
+
const saved = await call('PUT', '/ai-studio/keys/openrouter', { key: SECRET });
|
|
105
|
+
assert.equal(saved.status, 200);
|
|
106
|
+
assert.equal(saved.body.key.set, true);
|
|
107
|
+
assert.equal(saved.body.key.last4, 'WXYZ');
|
|
108
|
+
|
|
109
|
+
// It is stored encrypted, never as the plaintext.
|
|
110
|
+
const row = store.get('7/openrouter');
|
|
111
|
+
assert.ok(row.key_enc.startsWith('v1.'));
|
|
112
|
+
assert.notEqual(row.key_enc, SECRET);
|
|
113
|
+
|
|
114
|
+
const listed = await call('GET', '/ai-studio/keys');
|
|
115
|
+
assert.equal(listed.status, 200);
|
|
116
|
+
assert.equal(listed.body.storage_configured, true);
|
|
117
|
+
assert.deepEqual(listed.body.keys.map((k) => [k.provider, k.set, k.last4]), [['openrouter', true, 'WXYZ'], ['gemini', false, null]]);
|
|
118
|
+
|
|
119
|
+
const removed = await call('DELETE', '/ai-studio/keys/openrouter');
|
|
120
|
+
assert.equal(removed.body.removed, true);
|
|
121
|
+
assert.equal(removed.body.key.set, false);
|
|
122
|
+
|
|
123
|
+
// Nothing any route answered, and nothing logged, holds the key or its ciphertext.
|
|
124
|
+
for (const r of [saved, listed, removed]) {
|
|
125
|
+
assert.ok(!r.raw.includes(SECRET), 'a response carried the key');
|
|
126
|
+
assert.ok(!r.raw.includes(row.key_enc), 'a response carried the ciphertext');
|
|
127
|
+
}
|
|
128
|
+
assert.ok(logged.length > 0, 'the routes do log, so the next check is not vacuous');
|
|
129
|
+
for (const line of logged) {
|
|
130
|
+
assert.ok(!line.includes(SECRET), 'a log line carried the key');
|
|
131
|
+
assert.ok(!line.includes('WXYZ') || !line.includes(SECRET.slice(0, 12)), 'a log line carried part of the key');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a builder only ever sees their own keys', async () => {
|
|
136
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
137
|
+
await call('PUT', '/ai-studio/keys/gemini', { key: SECRET });
|
|
138
|
+
viewer = { id: 8, rank: 'xenos' };
|
|
139
|
+
const other = await call('GET', '/ai-studio/keys');
|
|
140
|
+
assert.deepEqual(other.body.keys.map((k) => k.set), [false, false]);
|
|
141
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
142
|
+
await call('DELETE', '/ai-studio/keys/gemini');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('refusals: unknown provider, bad key, no storage, signed out', async () => {
|
|
146
|
+
assert.equal((await call('PUT', '/ai-studio/keys/acme', { key: SECRET })).status, 404);
|
|
147
|
+
const bad = await call('PUT', '/ai-studio/keys/openrouter', { key: 'nope' });
|
|
148
|
+
assert.equal(bad.status, 400);
|
|
149
|
+
assert.ok(!bad.raw.includes('nope'));
|
|
150
|
+
configured = false;
|
|
151
|
+
const off = await call('PUT', '/ai-studio/keys/openrouter', { key: SECRET });
|
|
152
|
+
assert.equal(off.status, 409);
|
|
153
|
+
assert.equal(off.body.error.code, 'storage_not_configured');
|
|
154
|
+
assert.equal(store.size, 0, 'nothing is stored when storage is not set up');
|
|
155
|
+
configured = true;
|
|
156
|
+
viewer = null;
|
|
157
|
+
assert.equal((await call('GET', '/ai-studio/keys')).status, 401);
|
|
158
|
+
viewer = { id: 7, rank: 'xenos' };
|
|
159
|
+
});
|
|
@@ -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
|
+
});
|