@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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
12
|
const ic = require('../instance-config');
|
|
13
|
+
const { readConfigFileCached } = require('../instance-config'); // destructured: knip credits only this form
|
|
13
14
|
const { branding, userAgent } = require('../branding');
|
|
14
15
|
|
|
15
16
|
const SCOPE = 'read:user';
|
|
@@ -62,6 +63,29 @@ function grantedScopeCoversRepos(granted) {
|
|
|
62
63
|
// (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
|
|
63
64
|
// moving this to Redis or a tiny `device_flows` table.
|
|
64
65
|
const deviceFlows = new Map();
|
|
66
|
+
// Task 1004964: expired flows used to be reaped only when a NEW flow started, so the
|
|
67
|
+
// map held its last burst forever on a quiet instance. Now it is also pruned on an
|
|
68
|
+
// unref'd five-minute timer (rare enough not to keep an idle server awake), and
|
|
69
|
+
// hard-capped: at the cap the oldest-inserted flow is evicted, the newest survive.
|
|
70
|
+
const DEVICE_FLOW_MAX = 50000;
|
|
71
|
+
const DEVICE_FLOW_PRUNE_MS = 5 * 60 * 1000;
|
|
72
|
+
|
|
73
|
+
function pruneDeviceFlows(now = Date.now()) {
|
|
74
|
+
for (const [k, v] of deviceFlows) {
|
|
75
|
+
if (v.expires_at < now) deviceFlows.delete(k);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// trackDeviceFlow(code, flow) — record a pending flow, evicting oldest-first at the cap.
|
|
80
|
+
// O(1): expiry is the timer's job, so a burst of starts never rescans the whole map.
|
|
81
|
+
function trackDeviceFlow(code, flow) {
|
|
82
|
+
deviceFlows.delete(code);
|
|
83
|
+
while (deviceFlows.size >= DEVICE_FLOW_MAX) deviceFlows.delete(deviceFlows.keys().next().value);
|
|
84
|
+
deviceFlows.set(code, flow);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const deviceFlowPruneTimer = setInterval(pruneDeviceFlows, DEVICE_FLOW_PRUNE_MS);
|
|
88
|
+
if (typeof deviceFlowPruneTimer.unref === 'function') deviceFlowPruneTimer.unref();
|
|
65
89
|
|
|
66
90
|
function loadGithubCreds() {
|
|
67
91
|
// Resolve the configured instance prefix first (CLOUDBONGOS_ on a fresh
|
|
@@ -77,7 +101,7 @@ function loadGithubCreds() {
|
|
|
77
101
|
// across the configured config dir and the legacy ~/.config/otb dir.
|
|
78
102
|
for (const name of ['gds-github.json', 'pms-github.json']) {
|
|
79
103
|
try {
|
|
80
|
-
const raw =
|
|
104
|
+
const raw = readConfigFileCached(name); // task 1004924: per-request path, cached (60 s TTL)
|
|
81
105
|
if (raw) {
|
|
82
106
|
const creds = JSON.parse(raw);
|
|
83
107
|
if (creds.client_id && creds.client_secret) return creds;
|
|
@@ -143,6 +167,10 @@ module.exports = {
|
|
|
143
167
|
SCOPE,
|
|
144
168
|
authConfigured,
|
|
145
169
|
deviceFlows,
|
|
170
|
+
trackDeviceFlow,
|
|
171
|
+
pruneDeviceFlows,
|
|
172
|
+
DEVICE_FLOW_MAX,
|
|
173
|
+
DEVICE_FLOW_PRUNE_MS,
|
|
146
174
|
grantedScopeCoversRepos,
|
|
147
175
|
idpConfigured,
|
|
148
176
|
idpEnabled,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const { readConfigFileCached } = require('../instance-config'); // destructured: knip credits only this form
|
|
12
12
|
|
|
13
13
|
const DISCORD_SCOPE = 'identify guilds.join';
|
|
14
14
|
|
|
@@ -17,7 +17,7 @@ function loadDiscordCreds() {
|
|
|
17
17
|
const clientSecret = process.env.DISCORD_CLIENT_SECRET;
|
|
18
18
|
if (clientId && clientSecret) return { client_id: clientId, client_secret: clientSecret };
|
|
19
19
|
try {
|
|
20
|
-
const raw =
|
|
20
|
+
const raw = readConfigFileCached('discord-bot.json'); // task 1004924: per-request path, cached (60 s TTL)
|
|
21
21
|
if (raw) {
|
|
22
22
|
const creds = JSON.parse(raw);
|
|
23
23
|
if (creds.client_id && creds.client_secret) {
|
|
@@ -43,7 +43,7 @@ function loadDiscordBotToken() {
|
|
|
43
43
|
const envToken = process.env.DISCORD_BOT_TOKEN;
|
|
44
44
|
if (envToken) return envToken;
|
|
45
45
|
try {
|
|
46
|
-
const raw =
|
|
46
|
+
const raw = readConfigFileCached('discord-bot.json'); // task 1004924: per-request path, cached (60 s TTL)
|
|
47
47
|
if (raw) {
|
|
48
48
|
const creds = JSON.parse(raw);
|
|
49
49
|
if (creds.bot_token) return creds.bot_token;
|
|
@@ -25,7 +25,7 @@ const { pool } = require('./pool');
|
|
|
25
25
|
const seams = require('../module-seams');
|
|
26
26
|
const { branding, userAgent } = require('../branding');
|
|
27
27
|
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, safeSyncRankRole } = require('./auth-admission.js');
|
|
28
|
-
const { SCOPE, deviceFlows, loadGithubCreds, scopeForWebFlow } = require('./auth-config.js');
|
|
28
|
+
const { SCOPE, deviceFlows, trackDeviceFlow, loadGithubCreds, scopeForWebFlow } = require('./auth-config.js');
|
|
29
29
|
const log = require('./logger').logger.child({ src: 'auth-github' });
|
|
30
30
|
|
|
31
31
|
function newSessionToken() {
|
|
@@ -121,15 +121,12 @@ async function startDeviceFlow() {
|
|
|
121
121
|
throw new Error('GitHub device/code returned no device_code (unexpected response shape).');
|
|
122
122
|
}
|
|
123
123
|
// data: { device_code, user_code, verification_uri, expires_in, interval }
|
|
124
|
-
|
|
124
|
+
// Evicts oldest-first at the size cap; an unref'd timer reaps expired flows (auth-config.js).
|
|
125
|
+
trackDeviceFlow(data.device_code, {
|
|
125
126
|
interval: data.interval || 5,
|
|
126
127
|
expires_at: Date.now() + data.expires_in * 1000,
|
|
127
128
|
last_polled_at: 0,
|
|
128
129
|
});
|
|
129
|
-
// Reap expired flows (cheap)
|
|
130
|
-
for (const [k, v] of deviceFlows) {
|
|
131
|
-
if (v.expires_at < Date.now()) deviceFlows.delete(k);
|
|
132
|
-
}
|
|
133
130
|
return {
|
|
134
131
|
device_code: data.device_code,
|
|
135
132
|
user_code: data.user_code,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/bongos/auth-google.js — signing in with Google (task 1004993, ADR 0396).
|
|
2
|
+
//
|
|
3
|
+
// WHY. A person with no GitHub account could not sign up at all. The owner chose
|
|
4
|
+
// Google as the one new way in (no email codes, no passwords, no other
|
|
5
|
+
// providers). This file holds the Google half of the browser sign-in: the
|
|
6
|
+
// authorize URL, the code exchange, checking the ID token Google returns, and
|
|
7
|
+
// turning that identity into a session on this instance.
|
|
8
|
+
//
|
|
9
|
+
// WHO THE PERSON IS. Google's own stable user id (`sub`) is the only thing that
|
|
10
|
+
// finds a person. The hub's account-logins table maps it to the person key
|
|
11
|
+
// (ADR 0393); an email is shown, never looked up, so owning an address that
|
|
12
|
+
// matches someone's account reaches nothing. The lookup lives on the hub, so this
|
|
13
|
+
// only works where the platform-identity module answers the `signInWithGoogle`
|
|
14
|
+
// port function — on every other instance Google sign-in is simply not offered.
|
|
15
|
+
//
|
|
16
|
+
// THE BUILDER ROW. Core's `builders.github_login` cannot be empty, and a person
|
|
17
|
+
// who signed up with Google has no GitHub login. They get a stand-in that can
|
|
18
|
+
// never be a GitHub login — `~` and the handle they choose on their account page
|
|
19
|
+
// (`standInLogin`) — so no admission rule, invite or GitHub call keyed on a login
|
|
20
|
+
// can ever reach a real GitHub user through them. Once GitHub is linked (task
|
|
21
|
+
// 1004995) the real login is used instead.
|
|
22
|
+
//
|
|
23
|
+
// THE ID TOKEN. It comes straight from Google's token endpoint over TLS in
|
|
24
|
+
// exchange for our client secret, so its signature is not re-checked (OpenID
|
|
25
|
+
// Connect Core's "ID Token Validation" allows TLS in its place); its issuer,
|
|
26
|
+
// audience, expiry and nonce are.
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
const crypto = require('node:crypto');
|
|
30
|
+
const ic = require('../instance-config');
|
|
31
|
+
const { createSession, getBuilderByGithubId, upsertBuilder } = require('./db-kernel');
|
|
32
|
+
const { pool } = require('./pool');
|
|
33
|
+
const seams = require('../module-seams');
|
|
34
|
+
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, safeSyncRankRole } = require('./auth-admission.js');
|
|
35
|
+
const log = require('./logger').logger.child({ src: 'auth-google' });
|
|
36
|
+
|
|
37
|
+
const GOOGLE_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
38
|
+
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
39
|
+
const GOOGLE_ISSUERS = Object.freeze(['https://accounts.google.com', 'accounts.google.com']);
|
|
40
|
+
// Identity only: who they are, the address Google vouches for, a name and a picture.
|
|
41
|
+
const GOOGLE_SCOPE = 'openid email profile';
|
|
42
|
+
// Clock skew allowed when checking the token's expiry.
|
|
43
|
+
const EXPIRY_SKEW_MS = 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// The Google sign-in app's credentials, from env (<PREFIX>_GOOGLE_CLIENT_ID /
|
|
46
|
+
// _GOOGLE_CLIENT_SECRET), or null. Env only — never a committed file.
|
|
47
|
+
function loadGoogleCreds() {
|
|
48
|
+
const clientId = ic.resolveEnv('GOOGLE_CLIENT_ID');
|
|
49
|
+
const clientSecret = ic.resolveEnv('GOOGLE_CLIENT_SECRET');
|
|
50
|
+
return clientId && clientSecret ? { client_id: clientId, client_secret: clientSecret } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The hub port that finds or creates a person by their Google id, or null.
|
|
54
|
+
function googleHub() {
|
|
55
|
+
try {
|
|
56
|
+
const hub = seams.resolveOptional('platform-identity.hub');
|
|
57
|
+
return hub && typeof hub.signInWithGoogle === 'function' ? hub : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Is "Continue with Google" offered here? Both halves must be present: the
|
|
64
|
+
// owner's Google app, and the hub that keeps who signs in with it.
|
|
65
|
+
function googleConfigured() {
|
|
66
|
+
return !!loadGoogleCreds() && !!googleHub();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The Google authorize URL for one sign-in. `nonce` is echoed back inside the ID
|
|
70
|
+
// token and checked, binding the token to this browser's sign-in.
|
|
71
|
+
function buildGoogleAuthorizeUrl({ state, nonce, redirectUri }) {
|
|
72
|
+
const creds = loadGoogleCreds();
|
|
73
|
+
if (!creds) throw new Error('Google sign-in is not configured');
|
|
74
|
+
const u = new URL(GOOGLE_AUTHORIZE_URL);
|
|
75
|
+
u.searchParams.set('client_id', creds.client_id);
|
|
76
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
77
|
+
u.searchParams.set('response_type', 'code');
|
|
78
|
+
u.searchParams.set('scope', GOOGLE_SCOPE);
|
|
79
|
+
u.searchParams.set('state', state);
|
|
80
|
+
u.searchParams.set('nonce', nonce);
|
|
81
|
+
// Let a person with several Google accounts pick one, instead of Google
|
|
82
|
+
// silently using whichever is signed in (the GitHub "New account" lesson).
|
|
83
|
+
u.searchParams.set('prompt', 'select_account');
|
|
84
|
+
return u.toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The claims inside an ID token (the middle, base64url part), or null.
|
|
88
|
+
function decodeIdToken(idToken) {
|
|
89
|
+
const parts = String(idToken || '').split('.');
|
|
90
|
+
if (parts.length !== 3) return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check an ID token's claims → the identity this sign-in carries
|
|
99
|
+
// { subject, email, emailVerified, displayName, avatarUrl }. Throws on anything
|
|
100
|
+
// that makes the token not ours, not Google's, stale, or not this sign-in's.
|
|
101
|
+
function verifyGoogleClaims(claims, { clientId, nonce, nowMs = Date.now() }) {
|
|
102
|
+
if (!claims || typeof claims !== 'object') throw new Error('Google returned no identity');
|
|
103
|
+
if (!GOOGLE_ISSUERS.includes(claims.iss)) throw new Error('the identity was not issued by Google');
|
|
104
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
105
|
+
if (!clientId || !aud.includes(clientId)) throw new Error('the identity was issued for another app');
|
|
106
|
+
if (aud.length > 1 && claims.azp !== clientId) throw new Error('the identity was issued for another app');
|
|
107
|
+
if (!Number.isFinite(claims.exp) || claims.exp * 1000 + EXPIRY_SKEW_MS < nowMs) throw new Error('the identity has expired');
|
|
108
|
+
if (!nonce || typeof claims.nonce !== 'string' || !timingSafeEqualStr(claims.nonce, nonce)) {
|
|
109
|
+
throw new Error('the identity does not belong to this sign-in');
|
|
110
|
+
}
|
|
111
|
+
const subject = typeof claims.sub === 'string' ? claims.sub : '';
|
|
112
|
+
if (!/^[\x21-\x7e]{1,255}$/.test(subject)) throw new Error('Google returned no account id');
|
|
113
|
+
return {
|
|
114
|
+
subject,
|
|
115
|
+
email: typeof claims.email === 'string' ? claims.email : null,
|
|
116
|
+
emailVerified: claims.email_verified === true || claims.email_verified === 'true',
|
|
117
|
+
displayName: typeof claims.name === 'string' ? claims.name.slice(0, 100) : null,
|
|
118
|
+
avatarUrl: typeof claims.picture === 'string' ? claims.picture : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function timingSafeEqualStr(a, b) {
|
|
123
|
+
const x = Buffer.from(String(a));
|
|
124
|
+
const y = Buffer.from(String(b));
|
|
125
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Trade the code Google redirected back with for the ID token, and check it →
|
|
129
|
+
// the identity. `fetchImpl` is injectable so a test needs no network.
|
|
130
|
+
async function exchangeGoogleCode({ code, redirectUri, nonce, fetchImpl = fetch, nowMs = Date.now() }) {
|
|
131
|
+
const creds = loadGoogleCreds();
|
|
132
|
+
if (!creds) throw new Error('Google sign-in is not configured');
|
|
133
|
+
const res = await fetchImpl(GOOGLE_TOKEN_URL, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
136
|
+
body: new URLSearchParams({
|
|
137
|
+
code,
|
|
138
|
+
client_id: creds.client_id,
|
|
139
|
+
client_secret: creds.client_secret,
|
|
140
|
+
redirect_uri: redirectUri,
|
|
141
|
+
grant_type: 'authorization_code',
|
|
142
|
+
}).toString(),
|
|
143
|
+
});
|
|
144
|
+
const data = await res.json().catch(() => ({}));
|
|
145
|
+
if (!res.ok || data.error || !data.id_token) {
|
|
146
|
+
throw new Error(`Google token exchange: ${data.error_description || data.error || `HTTP ${res.status}`}`);
|
|
147
|
+
}
|
|
148
|
+
return verifyGoogleClaims(decodeIdToken(data.id_token), { clientId: creds.client_id, nonce, nowMs });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The builder-row login for a person with no GitHub linked: `~` plus their
|
|
152
|
+
// handle. `~` is never in a GitHub login, so this can never name a real GitHub
|
|
153
|
+
// user (ADR 0396). The handle is the one they choose on their account page.
|
|
154
|
+
function standInLogin(handle, personKey) {
|
|
155
|
+
const h = String(handle || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 39);
|
|
156
|
+
return `~${h || `builder${String(personKey || '').replace(/\D/g, '')}`}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function signInError(code, extra = {}) {
|
|
160
|
+
const err = new Error(code.toLowerCase());
|
|
161
|
+
err.code = code;
|
|
162
|
+
Object.assign(err, extra);
|
|
163
|
+
return err;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Turn a checked Google identity into a web session here → { token, builder,
|
|
167
|
+
// expiresAt, created }. The same gates as the GitHub web sign-in
|
|
168
|
+
// (completeWebFlow): a first-timer is admitted only under open enrollment (they
|
|
169
|
+
// have no GitHub login an Archon could have invited), and a switched-off
|
|
170
|
+
// builder gets no session.
|
|
171
|
+
async function completeGoogleFlow({ identity, userAgent }) {
|
|
172
|
+
const hub = googleHub();
|
|
173
|
+
if (!hub) throw signInError('GOOGLE_NOT_AVAILABLE');
|
|
174
|
+
const person = await hub.signInWithGoogle(identity);
|
|
175
|
+
const existing = await getBuilderByGithubId(person.personKey);
|
|
176
|
+
if (!existing && firstTimeAdmissionBlocked({ existing, approved: false, login: null })) {
|
|
177
|
+
throw signInError('NOT_APPROVED');
|
|
178
|
+
}
|
|
179
|
+
const login = person.githubLogin || standInLogin(person.handle, person.personKey);
|
|
180
|
+
let builder = await upsertBuilder({
|
|
181
|
+
githubId: person.personKey,
|
|
182
|
+
githubLogin: login,
|
|
183
|
+
displayName: identity.displayName || person.handle || login,
|
|
184
|
+
avatarUrl: identity.avatarUrl,
|
|
185
|
+
});
|
|
186
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
187
|
+
if (builder.status === 'inactive') throw signInError('BUILDER_INACTIVE', { login });
|
|
188
|
+
await safeSyncRankRole(builder);
|
|
189
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
190
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
191
|
+
await createSession({ builderId: builder.id, token, source: 'web', userAgent: userAgent ?? null, expiresAt });
|
|
192
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
193
|
+
// The hub's own browser sign-in accepts the platform terms (ADR 0217 D3), as
|
|
194
|
+
// for GitHub. Best-effort: a failed stamp never costs the sign-in.
|
|
195
|
+
try {
|
|
196
|
+
if (typeof hub.recordHubSignin === 'function') {
|
|
197
|
+
await hub.recordHubSignin({
|
|
198
|
+
githubId: String(person.personKey),
|
|
199
|
+
githubLogin: person.githubLogin,
|
|
200
|
+
displayName: identity.displayName,
|
|
201
|
+
avatarUrl: identity.avatarUrl,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
log.error({ err: err && err.message }, 'hub sign-in terms acceptance not recorded (google)');
|
|
206
|
+
}
|
|
207
|
+
return { token, builder, expiresAt, created: !!person.created };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
googleConfigured,
|
|
212
|
+
buildGoogleAuthorizeUrl,
|
|
213
|
+
verifyGoogleClaims,
|
|
214
|
+
exchangeGoogleCode,
|
|
215
|
+
standInLogin,
|
|
216
|
+
completeGoogleFlow,
|
|
217
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// src/bongos/device-approval.js — command-line sign-in approved in the browser
|
|
2
|
+
// (task 1004993, ADR 0396).
|
|
3
|
+
//
|
|
4
|
+
// WHY. The CLI's sign-in has always been GitHub's own device flow: the terminal
|
|
5
|
+
// shows a code and the person types it at github.com. A person who signs in with
|
|
6
|
+
// Google has no GitHub account to type it into. So when this instance offers
|
|
7
|
+
// Google sign-in, the CLI's device flow is served HERE instead: the terminal gets
|
|
8
|
+
// a code and a link to this instance's own approval page, the person signs in
|
|
9
|
+
// there with GitHub or Google as they would in the browser, checks the code and
|
|
10
|
+
// presses Approve, and the terminal's next poll receives a CLI session.
|
|
11
|
+
//
|
|
12
|
+
// The CLI needs no change for this: /auth/device/start and /auth/device/poll keep
|
|
13
|
+
// their request and response shapes, and the CLI already opens whatever
|
|
14
|
+
// `verification_uri` it is handed.
|
|
15
|
+
//
|
|
16
|
+
// WHAT HOLDS IT SAFE. Approving is a cookie-session POST (the same rule as
|
|
17
|
+
// /auth/cli-token/issue: a bearer cannot mint a bearer), it binds the CLI session
|
|
18
|
+
// to whoever approved, and the page tells the person to check the code matches
|
|
19
|
+
// their terminal and to cancel a link someone sent them — the device-code
|
|
20
|
+
// phishing risk GitHub's own page carries, stated the same way. A code is
|
|
21
|
+
// single-use, lives FLOW_TTL_MS and is forgotten once polled to completion.
|
|
22
|
+
//
|
|
23
|
+
// In-memory, like GitHub's device flows in auth-config.js: a restart drops a
|
|
24
|
+
// pending sign-in and the person runs it again.
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const crypto = require('node:crypto');
|
|
28
|
+
|
|
29
|
+
const FLOW_TTL_MS = 10 * 60 * 1000;
|
|
30
|
+
const POLL_INTERVAL_S = 5;
|
|
31
|
+
const FLOWS_MAX = 10000;
|
|
32
|
+
// No vowels, so a code never spells a word; no look-alike letters.
|
|
33
|
+
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
34
|
+
|
|
35
|
+
const flows = new Map(); // device_code → flow
|
|
36
|
+
const byUserCode = new Map(); // user_code → device_code
|
|
37
|
+
|
|
38
|
+
function prune(now) {
|
|
39
|
+
for (const [deviceCode, f] of flows) {
|
|
40
|
+
if (f.expiresAt <= now) forget(deviceCode);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function forget(deviceCode) {
|
|
45
|
+
const f = flows.get(deviceCode);
|
|
46
|
+
if (f) byUserCode.delete(f.userCode);
|
|
47
|
+
flows.delete(deviceCode);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function newUserCode() {
|
|
51
|
+
let out = '';
|
|
52
|
+
for (let i = 0; i < 8; i++) out += USER_CODE_ALPHABET[crypto.randomInt(USER_CODE_ALPHABET.length)];
|
|
53
|
+
return `${out.slice(0, 4)}-${out.slice(4)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A code as a person might type it (lower case, spaces, no dash) → canonical
|
|
57
|
+
// XXXX-XXXX, or null when it cannot be one.
|
|
58
|
+
function normalizeUserCode(raw) {
|
|
59
|
+
const letters = String(raw || '').toUpperCase().replace(/[^A-Z]/g, '');
|
|
60
|
+
if (letters.length !== 8 || [...letters].some((c) => !USER_CODE_ALPHABET.includes(c))) return null;
|
|
61
|
+
return `${letters.slice(0, 4)}-${letters.slice(4)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Start a sign-in for the terminal → the same shape GitHub's device/code answers,
|
|
65
|
+
// so the CLI reads it unchanged. `pageUrl` is this instance's approval page.
|
|
66
|
+
function startBrowserDeviceFlow({ pageUrl, now = Date.now() }) {
|
|
67
|
+
prune(now);
|
|
68
|
+
while (flows.size >= FLOWS_MAX) forget(flows.keys().next().value);
|
|
69
|
+
const deviceCode = crypto.randomBytes(32).toString('hex');
|
|
70
|
+
let userCode = newUserCode();
|
|
71
|
+
while (byUserCode.has(userCode)) userCode = newUserCode();
|
|
72
|
+
flows.set(deviceCode, { userCode, expiresAt: now + FLOW_TTL_MS, lastPolledAt: 0, result: null });
|
|
73
|
+
byUserCode.set(userCode, deviceCode);
|
|
74
|
+
const complete = `${pageUrl}?code=${encodeURIComponent(userCode)}`;
|
|
75
|
+
return {
|
|
76
|
+
device_code: deviceCode,
|
|
77
|
+
user_code: userCode,
|
|
78
|
+
// The CLI opens verification_uri, so it carries the code: the page opens
|
|
79
|
+
// with it filled in. verification_uri_complete is the standard name for it.
|
|
80
|
+
verification_uri: complete,
|
|
81
|
+
verification_uri_complete: complete,
|
|
82
|
+
expires_in: Math.round(FLOW_TTL_MS / 1000),
|
|
83
|
+
interval: POLL_INTERVAL_S,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The pending flow a typed code names, or null (unknown, expired, already decided).
|
|
88
|
+
function pendingFlow(userCode, now = Date.now()) {
|
|
89
|
+
const code = normalizeUserCode(userCode);
|
|
90
|
+
const deviceCode = code && byUserCode.get(code);
|
|
91
|
+
const f = deviceCode && flows.get(deviceCode);
|
|
92
|
+
if (!f || f.expiresAt <= now || f.result) return null;
|
|
93
|
+
return { deviceCode, userCode: code };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The signed-in person approved (or cancelled) the code. On approve, `mint` is
|
|
97
|
+
// called once to create the CLI session for THIS person and its result is held
|
|
98
|
+
// for the terminal's next poll. → true when a pending code was decided.
|
|
99
|
+
async function decideDeviceFlow({ userCode, approve, mint, now = Date.now() }) {
|
|
100
|
+
const p = pendingFlow(userCode, now);
|
|
101
|
+
if (!p) return false;
|
|
102
|
+
const f = flows.get(p.deviceCode);
|
|
103
|
+
// Decide before the await, so a double-submit cannot mint twice.
|
|
104
|
+
f.result = { state: 'deciding' };
|
|
105
|
+
try {
|
|
106
|
+
f.result = approve ? { state: 'ok', ...(await mint()) } : { state: 'denied' };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
f.result = null; // undecided again: the person can retry
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Is this a device code this file issued (rather than GitHub's)?
|
|
115
|
+
function isBrowserDeviceCode(deviceCode) {
|
|
116
|
+
return typeof deviceCode === 'string' && flows.has(deviceCode);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The terminal's poll → { state: 'pending' | 'slow_down' | 'ok' | 'denied' | 'expired', … }.
|
|
120
|
+
// The same states pollDeviceFlow answers for GitHub's flow.
|
|
121
|
+
function pollBrowserDeviceFlow(deviceCode, now = Date.now()) {
|
|
122
|
+
const f = flows.get(deviceCode);
|
|
123
|
+
if (!f) return { state: 'expired' };
|
|
124
|
+
if (f.expiresAt <= now) { forget(deviceCode); return { state: 'expired' }; }
|
|
125
|
+
if (now - f.lastPolledAt < POLL_INTERVAL_S * 1000) return { state: 'slow_down', interval: POLL_INTERVAL_S };
|
|
126
|
+
f.lastPolledAt = now;
|
|
127
|
+
if (!f.result || f.result.state === 'deciding') return { state: 'pending' };
|
|
128
|
+
forget(deviceCode); // the token leaves exactly once
|
|
129
|
+
return f.result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
FLOW_TTL_MS,
|
|
134
|
+
normalizeUserCode,
|
|
135
|
+
startBrowserDeviceFlow,
|
|
136
|
+
pendingFlow,
|
|
137
|
+
decideDeviceFlow,
|
|
138
|
+
isBrowserDeviceCode,
|
|
139
|
+
pollBrowserDeviceFlow,
|
|
140
|
+
};
|
|
@@ -283,4 +283,20 @@ function componentsFromModelUsage(modelUsage) {
|
|
|
283
283
|
};
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
|
|
286
|
+
// SERVER-TOOL FEES (task 1004937, ADR 0394 §3). The Messages API's web search is
|
|
287
|
+
// billed per search on top of tokens: $10 per 1,000 searches, i.e. $0.01 each.
|
|
288
|
+
// Web fetch has no fee of its own; the fetched page is billed as input tokens,
|
|
289
|
+
// which priceUsage already prices. This is the one place the search price is
|
|
290
|
+
// spelled. Source: Anthropic's published web-search price as cached in the
|
|
291
|
+
// claude-api reference (2026-10-06), NOT re-verified against the live pricing
|
|
292
|
+
// page; the first real Market Watch run (task 1004942) is the measurement.
|
|
293
|
+
const WEB_SEARCH_USD_PER_REQUEST = 0.01;
|
|
294
|
+
|
|
295
|
+
// priceWebSearches(n) — USD for n web searches (usage.server_tool_use.web_search_requests).
|
|
296
|
+
// A missing or garbage count prices as zero searches, never NaN.
|
|
297
|
+
function priceWebSearches(n) {
|
|
298
|
+
const k = Number(n);
|
|
299
|
+
return Number.isFinite(k) && k > 0 ? k * WEB_SEARCH_USD_PER_REQUEST : 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { PRICES, FAMILY_CURRENT_MODEL, familyFor, priceUsage, canonicalizeModelId, priceModelUsage, componentsFromModelUsage, WEB_SEARCH_USD_PER_REQUEST, priceWebSearches };
|
package/src/bongos/metrics.js
CHANGED
|
@@ -51,9 +51,28 @@ function renderLabels(labels) {
|
|
|
51
51
|
return '{' + keys.map((k) => `${k}="${escapeLabelValue(labels[k])}"`).join(',') + '}';
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// --- series cap (task 1004924) ---------------------------------------------
|
|
55
|
+
// Every distinct label set is a series held in memory forever, so a label fed by
|
|
56
|
+
// request input can grow the heap without bound. Past this many series a metric
|
|
57
|
+
// takes no NEW label set: it folds into an overflow series instead. A metric with
|
|
58
|
+
// a route label folds only the route to 'other' (method and status are bounded
|
|
59
|
+
// sets already, so the overflow is a handful of series); any other metric folds
|
|
60
|
+
// every label to 'other', one series.
|
|
61
|
+
const MAX_SERIES_PER_METRIC = 2000;
|
|
62
|
+
const OVERFLOW = 'other';
|
|
63
|
+
|
|
64
|
+
function capLabels(series, labels) {
|
|
65
|
+
if (series.size < MAX_SERIES_PER_METRIC || series.has(labelKey(labels))) return labels;
|
|
66
|
+
if ('route' in labels) return { ...labels, route: OVERFLOW };
|
|
67
|
+
const all = {};
|
|
68
|
+
for (const k of Object.keys(labels)) all[k] = OVERFLOW;
|
|
69
|
+
return all;
|
|
70
|
+
}
|
|
71
|
+
|
|
54
72
|
function incCounter(name, help, labels = {}, by = 1) {
|
|
55
73
|
let c = counters.get(name);
|
|
56
74
|
if (!c) { c = { help, series: new Map() }; counters.set(name, c); }
|
|
75
|
+
labels = capLabels(c.series, labels);
|
|
57
76
|
const key = labelKey(labels);
|
|
58
77
|
const s = c.series.get(key);
|
|
59
78
|
if (s) s.value += by;
|
|
@@ -63,6 +82,7 @@ function incCounter(name, help, labels = {}, by = 1) {
|
|
|
63
82
|
function observeHistogram(name, help, labels, value) {
|
|
64
83
|
let h = histograms.get(name);
|
|
65
84
|
if (!h) { h = { help, series: new Map() }; histograms.set(name, h); }
|
|
85
|
+
labels = capLabels(h.series, labels);
|
|
66
86
|
const key = labelKey(labels);
|
|
67
87
|
let s = h.series.get(key);
|
|
68
88
|
if (!s) { s = { labels, buckets: new Array(HIST_BUCKETS.length).fill(0), sum: 0, count: 0 }; h.series.set(key, s); }
|
|
@@ -74,8 +94,14 @@ function observeHistogram(name, help, labels, value) {
|
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
// --- event-loop lag sampler (unref'd so it never holds the process open) ---
|
|
97
|
+
// Sampled every 10 s, not every second (task 1004961): a 1 s timer woke an idle
|
|
98
|
+
// hosted instance once a second forever, which burns CPU credits on a small
|
|
99
|
+
// droplet. perf_hooks.monitorEventLoopDelay() was considered and rejected — its
|
|
100
|
+
// native sampler is itself a repeating libuv timer firing every `resolution` ms
|
|
101
|
+
// (default 10 ms), so it would wake the loop far MORE often, not less. The gauge
|
|
102
|
+
// keeps its name and meaning: the most recent sampled lag, in seconds.
|
|
77
103
|
let _lagSeconds = 0;
|
|
78
|
-
const _LAG_INTERVAL_MS =
|
|
104
|
+
const _LAG_INTERVAL_MS = 10_000;
|
|
79
105
|
const _lagTimer = setInterval(() => {
|
|
80
106
|
const start = process.hrtime.bigint();
|
|
81
107
|
setImmediate(() => {
|
|
@@ -100,6 +126,26 @@ function normalizeRoute(p) {
|
|
|
100
126
|
return '/' + parts.join('/');
|
|
101
127
|
}
|
|
102
128
|
|
|
129
|
+
// routeLabel — the route label for one finished request (task 1004924). The
|
|
130
|
+
// literal path is caller-chosen (every scanner hit on /wp-admin/x.php or /.env
|
|
131
|
+
// is a new path), so it is never the label on its own:
|
|
132
|
+
// - a matched Express route labels by its PATTERN (mount path + route path),
|
|
133
|
+
// a fixed set only the code can grow;
|
|
134
|
+
// - a 404 with no matched route is the single label 'unmatched';
|
|
135
|
+
// - anything else unrouted (static files) keeps the normalized path, and the
|
|
136
|
+
// per-metric series cap above bounds even that.
|
|
137
|
+
const UNMATCHED = 'unmatched';
|
|
138
|
+
function routeLabel(req, status) {
|
|
139
|
+
const r = req.route;
|
|
140
|
+
if (r && r.path != null) {
|
|
141
|
+
const base = req.baseUrl && req.baseUrl !== '/' ? normalizeRoute(req.baseUrl) : '';
|
|
142
|
+
const p = typeof r.path === 'string' ? r.path : String(r.path);
|
|
143
|
+
return (base + p) || '/';
|
|
144
|
+
}
|
|
145
|
+
if (status === 404) return UNMATCHED;
|
|
146
|
+
return normalizeRoute(req.path);
|
|
147
|
+
}
|
|
148
|
+
|
|
103
149
|
// --- HTTP middleware ------------------------------------------------------
|
|
104
150
|
function httpMiddleware() {
|
|
105
151
|
return (req, res, next) => {
|
|
@@ -107,9 +153,9 @@ function httpMiddleware() {
|
|
|
107
153
|
if (req.path === '/metrics' || req.path === '/healthz') return next();
|
|
108
154
|
const start = process.hrtime.bigint();
|
|
109
155
|
res.on('finish', () => {
|
|
110
|
-
const route = normalizeRoute(req.path);
|
|
111
156
|
const method = req.method;
|
|
112
157
|
const status = res.statusCode;
|
|
158
|
+
const route = routeLabel(req, status);
|
|
113
159
|
incCounter('gds_http_requests_total', 'Total HTTP requests by method, normalized route, and status code.',
|
|
114
160
|
{ method, route, status: String(status) });
|
|
115
161
|
const dur = Number(process.hrtime.bigint() - start) / 1e9;
|