@bongos/core 1.17.0 → 1.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/.bongos-core.json +363 -312
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/CLAUDE.md +11 -238
  15. package/GOVERNANCE.md +1 -1
  16. package/README.md +1 -1
  17. package/bin/bongos.js +1 -0
  18. package/clients/bongos-client/README.md +4 -4
  19. package/clients/bongos-client/bongos-client.global.js +14 -1
  20. package/clients/bongos-client/index.cjs +14 -1
  21. package/clients/bongos-client/index.d.ts +19 -2
  22. package/clients/bongos-client/index.mjs +11 -1
  23. package/clients/bongos-client/package.json +1 -1
  24. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  25. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  26. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  27. package/docs/adr/0020-security-model-public-stub.md +1 -1
  28. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  29. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  30. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  31. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  32. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  33. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  34. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  35. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  36. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  37. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  38. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  39. package/docs/adr/0057-container-cost-ledger.md +3 -3
  40. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  41. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  42. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  43. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  44. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  45. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  46. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  47. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  48. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  49. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  50. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  51. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -0
  52. package/docs/adr/0134-private-first-npm-distribution.md +20 -6
  53. package/docs/adr/0135-module-upstream-submission-interim-queue.md +34 -0
  54. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  55. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  56. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  57. package/docs/adr/README.md +14 -10
  58. package/docs/api/openapi.json +315 -6
  59. package/docs/api-reference.md +10 -5
  60. package/docs/architecture.md +3 -3
  61. package/docs/branding-contract.md +5 -4
  62. package/docs/canonical-permissions.md +1 -1
  63. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  64. package/docs/file-map.md +20 -19
  65. package/docs/handoff-template.md +2 -2
  66. package/docs/modules-contract.md +8 -1
  67. package/docs/onboarding/diagrams/README.md +7 -7
  68. package/docs/onboarding/drachmae-and-karma.md +18 -18
  69. package/docs/onboarding/primer.md +9 -9
  70. package/docs/recipes/audit-replay.md +2 -2
  71. package/docs/recipes/builder-box-lifecycle.md +2 -2
  72. package/docs/recipes/claude-design-loop.md +1 -1
  73. package/docs/recipes/instance-deploy.md +4 -4
  74. package/docs/recipes/instance-first-run.md +51 -0
  75. package/docs/recipes/ops-gotchas.md +1 -1
  76. package/docs/recipes/packaging-the-core.md +5 -5
  77. package/docs/recipes/private-npm-distribution.md +39 -12
  78. package/docs/recipes/self-host.md +1 -1
  79. package/docs/recipes/staging-environment.md +1 -1
  80. package/docs/recipes/upgrading-the-core.md +63 -8
  81. package/docs/research/cursor-competitive-analysis.md +1 -1
  82. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  83. package/migrations/core_191_module_submissions.sql +45 -0
  84. package/modules/autonomy/CLAUDE.md +11 -45
  85. package/modules/builder-settings/CLAUDE.md +11 -54
  86. package/modules/dev-box/CLAUDE.md +11 -34
  87. package/modules/discord/CLAUDE.md +11 -46
  88. package/modules/discord/module.json +2 -1
  89. package/modules/economy/CLAUDE.md +11 -44
  90. package/modules/economy/achievements.js +9 -1
  91. package/modules/economy/credits.js +10 -1
  92. package/modules/grading/CLAUDE.md +11 -38
  93. package/modules/hall-ui/CLAUDE.md +11 -57
  94. package/modules/hall-ui/public/builders.js +71 -4
  95. package/modules/hall-ui/public/goal-inbox.js +191 -0
  96. package/modules/hall-ui/public/goals-page.js +227 -22
  97. package/modules/hall-ui/public/goals.html +16 -0
  98. package/modules/hall-ui/public/hall-widgets.js +14 -1
  99. package/modules/hall-ui/public/index.html +3 -0
  100. package/modules/hall-ui/public/new-project-wizard.js +6 -4
  101. package/modules/hall-ui/public/style.css +43 -0
  102. package/modules/ideas/CLAUDE.md +11 -33
  103. package/modules/lifecycle/CLAUDE.md +11 -59
  104. package/modules/lifecycle/routes/goals.js +80 -0
  105. package/modules/memory/CLAUDE.md +11 -36
  106. package/modules/onboarding/CLAUDE.md +11 -51
  107. package/modules/provisioning/CLAUDE.md +15 -0
  108. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  109. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  110. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  111. package/modules/provisioning/module.json +17 -0
  112. package/modules/provisioning/onboard-plan.js +37 -0
  113. package/modules/provisioning/provisioning.js +539 -0
  114. package/modules/provisioning/rate-limit.js +35 -0
  115. package/modules/provisioning/routes/provisioning.js +607 -0
  116. package/modules/provisioning/tests/provisioning.mjs +232 -0
  117. package/modules/provisioning/ui/.gitkeep +0 -0
  118. package/modules/security/CLAUDE.md +11 -40
  119. package/modules/security/secret-scrub.js +10 -0
  120. package/modules/sessions/CLAUDE.md +11 -35
  121. package/modules/status-ui/CLAUDE.md +11 -43
  122. package/package-lock.json +2 -2
  123. package/package.json +7 -2
  124. package/public-landing/create.html +58 -12
  125. package/public-landing/index.html +40 -21
  126. package/scripts/discord/README.md +1 -1
  127. package/scripts/gds/CLAUDE.md +11 -140
  128. package/scripts/gds/claim.js +98 -48
  129. package/scripts/gds/claude-materialize.js +174 -20
  130. package/scripts/gds/claude-md-template.js +84 -0
  131. package/scripts/gds/cli-lib.js +112 -2
  132. package/scripts/gds/cutover-codemod.js +241 -0
  133. package/scripts/gds/fitness.js +54 -0
  134. package/scripts/gds/gen-api-client.js +10 -10
  135. package/scripts/gds/init.js +112 -16
  136. package/scripts/gds/lint-infra-exec.js +70 -0
  137. package/scripts/gds/lint-workflow-exec.js +63 -0
  138. package/scripts/gds/migration-namespace.js +1 -1
  139. package/scripts/gds/mirror-lineage-check.js +14 -5
  140. package/scripts/gds/mirror-redact.js +8 -0
  141. package/scripts/gds/module.js +118 -4
  142. package/scripts/gds/npmrc.js +93 -0
  143. package/scripts/gds/onboard.js +17 -24
  144. package/scripts/gds/package-core.js +74 -17
  145. package/scripts/gds/provision.js +102 -17
  146. package/scripts/gds/publish-manifest.js +60 -2
  147. package/scripts/gds/run-routine.js +51 -13
  148. package/scripts/gds/run-unit-tests.js +4 -0
  149. package/scripts/gds/seed-adopt-tasks.js +1 -1
  150. package/scripts/gds/status.js +6 -1
  151. package/scripts/gds/task.js +166 -0
  152. package/scripts/gds/update-channel.js +186 -0
  153. package/scripts/gds/upgrade.js +172 -25
  154. package/src/bongos/CLAUDE.md +11 -60
  155. package/src/bongos/auth.js +24 -0
  156. package/src/bongos/module-submissions.js +56 -0
  157. package/src/bongos/route-rank-check.js +6 -0
  158. package/src/bongos/routes/CLAUDE.md +11 -39
  159. package/src/bongos/routes/auth.js +6 -1
  160. package/src/bongos/routes/modules.js +123 -21
  161. package/src/bongos/serve-internal.js +10 -1
  162. package/src/branding.js +1 -0
  163. package/src/instance-config.js +1 -1
  164. package/src/module-api.js +24 -1
  165. package/src/module-loader/loader.js +36 -4
  166. package/src/module-loader/manifest-schema.js +16 -1
  167. package/tests/branding.mjs +11 -0
  168. package/tests/claim_error_surface.mjs +107 -0
  169. package/tests/claude_materialize.mjs +125 -0
  170. package/tests/consumer_layout_boot.mjs +302 -0
  171. package/tests/cutover_codemod.mjs +184 -0
  172. package/tests/device_flow_start.mjs +96 -0
  173. package/tests/first_run_engine_check.mjs +79 -0
  174. package/tests/goal_membership_requests.mjs +33 -3
  175. package/tests/hook_core_resolve.mjs +125 -0
  176. package/tests/hook_user_agents.mjs +62 -0
  177. package/tests/infra_exec_paths.mjs +104 -0
  178. package/tests/init.mjs +106 -6
  179. package/tests/instance_mismatch_guard.mjs +101 -0
  180. package/tests/mirror_lineage_proof.mjs +14 -0
  181. package/tests/module_cli.mjs +130 -2
  182. package/tests/module_loader.mjs +42 -0
  183. package/tests/module_manifest.mjs +19 -0
  184. package/tests/module_submissions.mjs +135 -0
  185. package/tests/npmrc.mjs +105 -0
  186. package/tests/onboard.mjs +62 -0
  187. package/tests/package_core.mjs +76 -22
  188. package/tests/platform_boot.mjs +5 -2
  189. package/tests/provision.mjs +107 -8
  190. package/tests/provisioning_github_app_routes.mjs +44 -2
  191. package/tests/publish_manifest.mjs +52 -1
  192. package/tests/run_routine.mjs +26 -1
  193. package/tests/run_routine_two_root.mjs +77 -0
  194. package/tests/scrubber_corpus.mjs +17 -0
  195. package/tests/served_paths_shipped.mjs +2 -2
  196. package/tests/task_cli.mjs +99 -0
  197. package/tests/update_channel.mjs +186 -0
  198. package/tests/upgrade.mjs +220 -25
  199. package/tests/workflow_exec_paths.mjs +98 -0
  200. package/docs/design/container-exterior-tiles.md +0 -49
  201. package/docs/design/container-exterior.md +0 -81
  202. package/docs/design/container-interior.md +0 -77
  203. package/docs/design/world-map-scroll.md +0 -41
  204. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  205. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  206. package/docs/recipes/cloudbongos-standup.md +0 -111
  207. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  208. package/docs/recipes/parallel-load-test.md +0 -32
  209. package/docs/recipes/pixel-art-pipeline.md +0 -411
  210. package/docs/recipes/staging-data.md +0 -50
  211. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  212. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  213. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  214. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  215. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  216. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  217. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  218. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  219. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  220. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  221. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  222. package/scripts/gds/art-stage.js +0 -152
  223. package/server.js +0 -212
  224. package/tests/art_key.mjs +0 -121
  225. package/tests/day_cycle.mjs +0 -197
  226. package/tests/snapshot_picker.mjs +0 -233
  227. package/tests/sprite_facing.mjs +0 -35
  228. package/tests/terrain_picker.mjs +0 -298
  229. package/tests/turn_in_place.mjs +0 -280
  230. package/tests/walkability.mjs +0 -188
@@ -0,0 +1,539 @@
1
+ // modules/provisioning/provisioning.js — the provisioning module's domain slice
2
+ // (ADR 0111, task #1942). Pure helpers + data access for instance hosting.
3
+ //
4
+ // DESIGN — the trust boundary (ADR 0111 §2 / ADR 0016). This file NEVER calls a
5
+ // cloud provider (no DigitalOcean, no Cloudflare, no registrar) and holds NO
6
+ // tokens. It only reads/writes the module's own provisioning_* tables and ENQUEUES
7
+ // intents. The control-plane runner (scripts/gds/provision.js, task P3) is the sole
8
+ // token-holder that drains provisioning_intents and does the real infra work.
9
+ //
10
+ // `db` is dependency-INJECTED (passed in as the first arg), exactly like
11
+ // modules/dev-box/boxes.js — so this file has NO core require and is trivially
12
+ // unit-testable with a fake `{ query }`. The route factory passes the doorway pool.
13
+ 'use strict';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
17
+ // ---------------------------------------------------------------------------
18
+ const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down', 'torn_down', 'error'];
19
+ // co-tenant — a process + DB on THIS control-plane checkout (shared box, $0)
20
+ // dedicated — its own DigitalOcean droplet (priced by tier)
21
+ // standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
22
+ // the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
23
+ const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
24
+ // 'oauth-manifest' (task 2080) is queueable too — the GitHub-App-Manifest onboarding
25
+ // conversion. Today the runner drains it by scanning provisioning_oauth_manifests
26
+ // (drainOAuthManifests), not the intent queue, but it stays a valid action so the
27
+ // widened CHECK (migration provisioning_003) and this list never drift.
28
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest'];
29
+ // The states from which a teardown is meaningful (there is something to reclaim).
30
+ // Mirrors dev-box canTransition('deprovision', …): a requested/torn_down/error
31
+ // instance has nothing running to tear down via the happy path.
32
+ const TEARDOWN_FROM = new Set(['provisioning', 'active', 'tearing_down']);
33
+
34
+ // Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
35
+ // (ADR 0111 §6), never a bill. A small local table so this module needs no
36
+ // sibling-module import; P2 (shared control-plane client extraction) is where a
37
+ // unified SIZE_CATALOG can later back this. Co-tenant is always $0 (it shares an
38
+ // existing box). Unknown tier → 0 (unpriced rather than wrong).
39
+ const TIER_MONTHLY_USD = {
40
+ 's-1vcpu-1gb': 6,
41
+ 's-1vcpu-2gb': 12,
42
+ 's-2vcpu-2gb': 18,
43
+ 's-2vcpu-4gb': 24,
44
+ 's-4vcpu-8gb': 48,
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Pure helpers (no I/O — exported for unit tests)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ // A DNS/db-safe instance slug: lowercase, starts alnum, 2–40 of [a-z0-9-], no
52
+ // trailing/leading/double dash. This becomes a subdomain label + a DB-name base,
53
+ // so it must be conservative. Returns true/false.
54
+ function isValidSlug(slug) {
55
+ const s = String(slug == null ? '' : slug);
56
+ if (s.length < 2 || s.length > 40) return false;
57
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(s)) return false;
58
+ if (s.includes('--')) return false;
59
+ return true;
60
+ }
61
+
62
+ // Monthly USD estimate for a shape+tier. Co-tenant + standalone are free (they share
63
+ // the box / run on the owner's own infra); only a dedicated droplet costs its tier's
64
+ // rate (0 if the tier is unknown/unset). PURE.
65
+ function costEstimateUsd({ hostingShape, tier } = {}) {
66
+ if (hostingShape !== 'dedicated') return 0;
67
+ return TIER_MONTHLY_USD[tier] || 0;
68
+ }
69
+
70
+ // Normalize an SNI/hostname for an EXACT allow-list match: lowercase, trim, drop a
71
+ // trailing dot and any :port, and reject anything that can't be a real DNS name.
72
+ // Returns '' when the input isn't a plausible hostname (→ the caller denies). Backs
73
+ // the Caddy on-demand-TLS allow-list, so it errs strict: a bare TLD or malformed
74
+ // name is rejected. PURE (no I/O).
75
+ function normalizeHostname(h) {
76
+ let s = String(h == null ? '' : h).trim().toLowerCase();
77
+ if (!s) return '';
78
+ s = s.replace(/:\d+$/, ''); // drop a :port suffix FIRST …
79
+ s = s.replace(/\.$/, ''); // … then an FQDN trailing dot (handles host.:port order)
80
+ if (s.length < 1 || s.length > 253) return '';
81
+ // ≥2 labels (no bare TLD); each label [a-z0-9-], no leading/trailing dash.
82
+ if (!/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(s)) return '';
83
+ return s;
84
+ }
85
+
86
+ // The instance lifecycle states for which an on-demand TLS cert may be minted: a
87
+ // host mid-standup ('provisioning' — Caddy's first handshake can land here, while
88
+ // the snippet is written + reloaded) or live ('active'). NOT 'requested' (nothing
89
+ // stood up yet), 'tearing_down', 'torn_down', or 'error'.
90
+ const CERT_ELIGIBLE_STATES = new Set(['provisioning', 'active']);
91
+
92
+ // Is `hostname` a domain this module ACTUALLY provisioned + is still serving? Backs
93
+ // the Caddy on-demand-TLS `ask` allow-list (ADR 0111 §4): true → Caddy may fetch a
94
+ // Let's Encrypt cert for it; false → refuse (no cert-minting for arbitrary SNIs, the
95
+ // cert-flood guard). Exact match on the normalized host against a cert-eligible
96
+ // instance. NEVER interpolates the host into SQL (parameterized). db-injected.
97
+ async function isProvisionedHostname(db, hostname) {
98
+ const host = normalizeHostname(hostname);
99
+ if (!host) return false;
100
+ const { rows } = await db.query(
101
+ `SELECT status FROM provisioning_instances WHERE lower(domain) = $1 LIMIT 1`,
102
+ [host]
103
+ );
104
+ return !!(rows[0] && CERT_ELIGIBLE_STATES.has(rows[0].status));
105
+ }
106
+
107
+ // What we surface for an instance — an explicit allow-list projection (the
108
+ // publicBox pattern). There is nothing secret in a row today, but projecting
109
+ // explicitly means a future sensitive column is never leaked by accident.
110
+ function publicInstance(row) {
111
+ if (!row) return { state: 'none' };
112
+ return {
113
+ id: row.id,
114
+ slug: row.slug,
115
+ status: row.status,
116
+ hosting_shape: row.hosting_shape,
117
+ domain: row.domain,
118
+ db_name: row.db_name,
119
+ port: row.port,
120
+ tier: row.tier,
121
+ target_ref: row.target_ref,
122
+ cost_estimate_usd: Number(row.cost_estimate_usd || 0),
123
+ error_note: row.error_note || null,
124
+ created_at: row.created_at,
125
+ updated_at: row.updated_at,
126
+ provisioned_at: row.provisioned_at,
127
+ torn_down_at: row.torn_down_at,
128
+ };
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // GitHub App Manifest flow — PURE builders (task 2080, ADR 0133).
133
+ // GitHub has no create-an-OAuth-App API, but the App *Manifest* flow lets the owner
134
+ // click ONE pre-filled link, approve on GitHub, and have GitHub hand us the client
135
+ // creds automatically. These build the manifest + the POST target; the web route
136
+ // mints the state + persists the row, the control-plane runner does the exchange.
137
+ // ---------------------------------------------------------------------------
138
+
139
+ // The auto GitHub App name. A GitHub App name is GLOBALLY unique across ALL of GitHub and
140
+ // capped at 34 chars, and it is shown prominently on the sign-in consent screen as
141
+ // "<name> by <owner> … will redirect to https://<domain>".
142
+ //
143
+ // PREFER THE INSTANCE DOMAIN (e.g. "demo.cloudbongos.com"): a domain is inherently unique
144
+ // (so it never collides — the whole problem with a bare "demo"), and it reads perfectly on
145
+ // that consent screen because the name IS the destination. GitHub keeps the dots in the
146
+ // DISPLAY name (only the internal slug is transformed), so a domain is a valid name.
147
+ //
148
+ // FALLBACK (no domain, or a domain over 34 chars): brand-qualify the project —
149
+ // "<brand> - <project>" (e.g. "Cloud Bongos - demo") — still far less collision-prone than
150
+ // a bare slug. Either way it is only a DEFAULT the owner can rename on GitHub's screen.
151
+ // PURE.
152
+ function githubAppName({ domain, brand, project } = {}) {
153
+ const MAX = 34;
154
+ // Domain-first: normalizeHostname lowercases + strips port/trailing-dot + validates a
155
+ // real multi-label host (returns '' otherwise). Dots are fine in a GitHub App display name.
156
+ const host = normalizeHostname(domain);
157
+ if (host && host.length <= MAX) return host;
158
+ // Fallback: brand-qualified project. GitHub App display names accept more, but keeping
159
+ // this to alphanumerics/spaces/hyphens stays clean and predictable.
160
+ const clean = (s) => String(s == null ? '' : s)
161
+ .trim()
162
+ .replace(/[^A-Za-z0-9 -]+/g, ' ')
163
+ .replace(/\s+/g, ' ')
164
+ .replace(/^[\s-]+|[\s-]+$/g, '')
165
+ .trim();
166
+ const b = clean(brand);
167
+ const p = clean(project);
168
+ if (b && p) {
169
+ const sep = ' - ';
170
+ const room = MAX - b.length - sep.length;
171
+ // Keep the WHOLE brand (the collision-dodging qualifier) + as much of the project as
172
+ // fits; if the brand alone already fills the budget, the brand is the useful name.
173
+ return room >= 1 ? clean(`${b}${sep}${p.slice(0, room)}`) : b.slice(0, MAX);
174
+ }
175
+ return (b || p || 'Cloud Bongos').slice(0, MAX);
176
+ }
177
+
178
+ // The GitHub App Manifest JSON (docs: apps/sharing-github-apps/registering-a-github-app-
179
+ // from-a-manifest). Two origins matter and MUST differ:
180
+ // • redirect_url — where GitHub sends the browser (with ?code=) AFTER the owner
181
+ // approves. This is the MOTHERSHIP (control plane) callback — the instance itself
182
+ // can't do the token exchange (no tokens, and its own auth may not be live yet).
183
+ // • callback_urls — where "Login with GitHub" on the INSTANCE redirects during real
184
+ // sign-in. Both /api/gds/... (what auth.js actually builds) and the /api/bongos/...
185
+ // alias are registered so sign-in works regardless of which prefix a build emits.
186
+ // Minimal for sign-in: no repo permissions, no webhook (active:false), not public. PURE.
187
+ function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothershipOrigin, brand }) {
188
+ const instOrigin = `https://${instanceDomain}`;
189
+ const redirectUrl = `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/callback`;
190
+ return {
191
+ name: githubAppName({ domain: instanceDomain, brand, project: instanceName || slug }),
192
+ url: instOrigin,
193
+ // Sign-in only ever calls GET /user with the user-to-server token — no repo/account
194
+ // permissions are needed, so request none (least privilege).
195
+ default_permissions: {},
196
+ default_events: [],
197
+ // No webhook: active:false. A url is still supplied (GitHub validates the shape) but
198
+ // is never called while inactive; point it at the mothership so it's a real host.
199
+ hook_attributes: { url: `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/webhook`, active: false },
200
+ redirect_url: redirectUrl,
201
+ callback_urls: [
202
+ `${instOrigin}/api/gds/auth/web/callback`,
203
+ `${instOrigin}/api/bongos/auth/web/callback`,
204
+ ],
205
+ public: false,
206
+ };
207
+ }
208
+
209
+ // The GitHub form-POST target for creating an App from a manifest. Personal account
210
+ // vs org differ only in the URL; the App works for sign-in either way (ADR 0133). The
211
+ // one-time `state` rides the query string (GitHub echoes it back on redirect). PURE.
212
+ function manifestPostUrl({ account = 'personal', orgLogin, state } = {}) {
213
+ const q = state ? `?state=${encodeURIComponent(state)}` : '';
214
+ if (account === 'org' && orgLogin) {
215
+ return `https://github.com/organizations/${encodeURIComponent(orgLogin)}/settings/apps/new${q}`;
216
+ }
217
+ return `https://github.com/settings/apps/new${q}`;
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Data access (db injected; owns only the provisioning_* tables)
222
+ // ---------------------------------------------------------------------------
223
+
224
+ async function getInstanceById(db, id) {
225
+ const { rows } = await db.query(
226
+ `SELECT * FROM provisioning_instances WHERE id = $1`, [id]
227
+ );
228
+ return rows[0] || null;
229
+ }
230
+
231
+ async function getInstanceBySlug(db, slug) {
232
+ const { rows } = await db.query(
233
+ `SELECT * FROM provisioning_instances WHERE slug = $1`, [slug]
234
+ );
235
+ return rows[0] || null;
236
+ }
237
+
238
+ // The caller's own instances, newest first.
239
+ async function listInstancesForOwner(db, ownerBuilderId) {
240
+ const { rows } = await db.query(
241
+ `SELECT * FROM provisioning_instances
242
+ WHERE owner_builder_id = $1
243
+ ORDER BY updated_at DESC
244
+ LIMIT 500`,
245
+ [ownerBuilderId]
246
+ );
247
+ return rows;
248
+ }
249
+
250
+ // The full fleet across all owners (Archon), joined to the owner identity.
251
+ async function listAllInstances(db, { status } = {}) {
252
+ const params = [];
253
+ let where = '';
254
+ if (status) { params.push(status); where = `WHERE pi.status = $1`; }
255
+ const { rows } = await db.query(
256
+ `SELECT pi.*, b.github_login, b.display_name, b.rank
257
+ FROM provisioning_instances pi
258
+ JOIN builders b ON b.id = pi.owner_builder_id
259
+ ${where}
260
+ ORDER BY pi.updated_at DESC
261
+ LIMIT 1000`,
262
+ params
263
+ );
264
+ return rows;
265
+ }
266
+
267
+ // Create an instance row (status 'requested'). Idempotent on slug: a slug clash
268
+ // re-fetches the existing row and returns { created:false } so the ROUTE can
269
+ // decide (same owner → idempotent re-request; different owner → 409). Never
270
+ // silently reassigns ownership.
271
+ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier }) {
272
+ const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
273
+ const cost = costEstimateUsd({ hostingShape: shape, tier });
274
+ try {
275
+ const { rows } = await db.query(
276
+ `INSERT INTO provisioning_instances
277
+ (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd)
278
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
279
+ RETURNING *`,
280
+ [ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
281
+ dbName ?? null, port ?? null, tier ?? null, cost]
282
+ );
283
+ return { instance: rows[0], created: true };
284
+ } catch (e) {
285
+ // 23505 = unique_violation on the slug UNIQUE constraint.
286
+ if (e && e.code === '23505') {
287
+ const existing = await getInstanceBySlug(db, slug);
288
+ if (existing) return { instance: existing, created: false };
289
+ }
290
+ throw e;
291
+ }
292
+ }
293
+
294
+ // Advance an instance's lifecycle status (+ optional column patch). Whitelisted
295
+ // patch columns only, so a caller can never write an arbitrary column.
296
+ async function setInstanceStatus(db, id, status, patch = {}) {
297
+ const sets = [`status = $2`, `updated_at = now()`];
298
+ const params = [id, status];
299
+ const COLS = {
300
+ domain: 'domain', db_name: 'db_name', port: 'port', tier: 'tier',
301
+ target_ref: 'target_ref', error_note: 'error_note',
302
+ cost_estimate_usd: 'cost_estimate_usd',
303
+ provisioned_at: 'provisioned_at', torn_down_at: 'torn_down_at',
304
+ };
305
+ for (const [k, col] of Object.entries(COLS)) {
306
+ if (k in patch) { params.push(patch[k]); sets.push(`${col} = $${params.length}`); }
307
+ }
308
+ const { rows } = await db.query(
309
+ `UPDATE provisioning_instances SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
310
+ params
311
+ );
312
+ return rows[0] || null;
313
+ }
314
+
315
+ // Enqueue a provision/teardown intent. Idempotent: the partial unique index
316
+ // ux_provisioning_intents_open makes a second open enqueue collide (23505); we
317
+ // then return the already-open intent with created:false (mirrors boxes.enqueueIntent).
318
+ async function enqueueIntent(db, instanceId, action, requestedBy) {
319
+ try {
320
+ const { rows } = await db.query(
321
+ `INSERT INTO provisioning_intents (instance_id, action, requested_by, state)
322
+ VALUES ($1, $2, $3, 'pending') RETURNING *`,
323
+ [instanceId, action, requestedBy ?? null]
324
+ );
325
+ return { intent: rows[0], created: true };
326
+ } catch (e) {
327
+ if (e && e.code === '23505') {
328
+ const existing = await getOpenIntent(db, instanceId);
329
+ if (existing) return { intent: existing, created: false };
330
+ }
331
+ throw e;
332
+ }
333
+ }
334
+
335
+ // The instance's currently-open (pending|running) intent, if any.
336
+ async function getOpenIntent(db, instanceId) {
337
+ const { rows } = await db.query(
338
+ `SELECT * FROM provisioning_intents
339
+ WHERE instance_id = $1 AND state IN ('pending', 'running')
340
+ ORDER BY created_at DESC LIMIT 1`,
341
+ [instanceId]
342
+ );
343
+ return rows[0] || null;
344
+ }
345
+
346
+ // Atomically claim the oldest pending intent for the runner (task P3): flip it to
347
+ // 'running', bump attempts. FOR UPDATE SKIP LOCKED so concurrent runner ticks never
348
+ // grab the same row. Returns the claimed row or null. (Provided now so the P3 runner
349
+ // reuses this module's data slice rather than re-implementing the queue drain.)
350
+ async function claimNextIntent(db) {
351
+ const { rows } = await db.query(
352
+ `UPDATE provisioning_intents
353
+ SET state = 'running', attempts = attempts + 1, updated_at = now()
354
+ WHERE id = (
355
+ SELECT id FROM provisioning_intents
356
+ WHERE state = 'pending'
357
+ ORDER BY created_at
358
+ FOR UPDATE SKIP LOCKED
359
+ LIMIT 1
360
+ )
361
+ RETURNING *`
362
+ );
363
+ return rows[0] || null;
364
+ }
365
+
366
+ // Resolve a claimed intent to its terminal state ('done' | 'error').
367
+ async function resolveIntent(db, id, state, lastError) {
368
+ await db.query(
369
+ `UPDATE provisioning_intents
370
+ SET state = $2, last_error = $3, updated_at = now(), resolved_at = now()
371
+ WHERE id = $1`,
372
+ [id, state, lastError ?? null]
373
+ );
374
+ }
375
+
376
+ // ── GitHub App Manifest attempts (task 2080, ADR 0133) ─────────────────────
377
+
378
+ // Record a fresh manifest onboarding attempt (status 'pending') with its one-time
379
+ // `state` token + TTL. The web route mints the state; the runner never writes here.
380
+ async function createOAuthManifest(db, { instanceId, state, requestedBy, expiresAt }) {
381
+ const { rows } = await db.query(
382
+ `INSERT INTO provisioning_oauth_manifests (instance_id, state, requested_by, expires_at)
383
+ VALUES ($1, $2, $3, $4) RETURNING *`,
384
+ [instanceId, state, requestedBy ?? null, expiresAt]
385
+ );
386
+ return rows[0];
387
+ }
388
+
389
+ // Look up an attempt by its `state` token (the public callback's only key).
390
+ async function getOAuthManifestByState(db, state) {
391
+ if (!state) return null;
392
+ const { rows } = await db.query(
393
+ `SELECT * FROM provisioning_oauth_manifests WHERE state = $1`, [String(state)]
394
+ );
395
+ return rows[0] || null;
396
+ }
397
+
398
+ // Attach GitHub's returned conversion `code` and flip 'pending' → 'code_received'.
399
+ // SINGLE-USE + TTL-guarded IN SQL: only a still-pending, un-expired row advances, so a
400
+ // replayed or stale callback is a no-op (returns null). Returns the updated row or null.
401
+ async function attachManifestCode(db, id, code) {
402
+ const { rows } = await db.query(
403
+ `UPDATE provisioning_oauth_manifests
404
+ SET github_code = $2, status = 'code_received', updated_at = now()
405
+ WHERE id = $1 AND status = 'pending' AND expires_at > now()
406
+ RETURNING *`,
407
+ [id, code]
408
+ );
409
+ return rows[0] || null;
410
+ }
411
+
412
+ // The most recent manifest attempt for an instance (any status) — for the web read
413
+ // surface + audit. `statuses` optionally filters (e.g. ['code_received','converting']).
414
+ async function getLatestOAuthManifestForInstance(db, instanceId, { statuses } = {}) {
415
+ const params = [instanceId];
416
+ let filter = '';
417
+ if (Array.isArray(statuses) && statuses.length) {
418
+ params.push(statuses);
419
+ filter = `AND status = ANY($2)`;
420
+ }
421
+ const { rows } = await db.query(
422
+ `SELECT * FROM provisioning_oauth_manifests
423
+ WHERE instance_id = $1 ${filter}
424
+ ORDER BY created_at DESC LIMIT 1`,
425
+ params
426
+ );
427
+ return rows[0] || null;
428
+ }
429
+
430
+ // Atomically claim the oldest attempt awaiting conversion for the runner: flip
431
+ // 'code_received' → 'converting', bump attempts. FOR UPDATE SKIP LOCKED so overlapping
432
+ // runner ticks never grab the same row (the claimNextIntent pattern). Null when none.
433
+ //
434
+ // Only claims a row whose INSTANCE is already 'active' — cred placement rewrites
435
+ // web.env + restarts the unit, which only exists once provisioning finished. A code
436
+ // that arrives mid-provision therefore WAITS (no attempt burned, no error) until the
437
+ // instance goes active, instead of failing against a not-yet-created unit. `OF m` locks
438
+ // only the manifest row, never the joined instance row.
439
+ async function claimNextOAuthManifest(db) {
440
+ const { rows } = await db.query(
441
+ `UPDATE provisioning_oauth_manifests
442
+ SET status = 'converting', attempts = attempts + 1, updated_at = now()
443
+ WHERE id = (
444
+ SELECT m.id FROM provisioning_oauth_manifests m
445
+ JOIN provisioning_instances i ON i.id = m.instance_id
446
+ WHERE m.status = 'code_received' AND i.status = 'active'
447
+ ORDER BY m.created_at
448
+ FOR UPDATE OF m SKIP LOCKED
449
+ LIMIT 1
450
+ )
451
+ RETURNING *`
452
+ );
453
+ return rows[0] || null;
454
+ }
455
+
456
+ // Resolve a claimed attempt to a terminal-ish status ('done' | 'error' | 'code_received'
457
+ // on a retry). Whitelisted patch columns only (last_error, app_id, app_slug).
458
+ async function resolveOAuthManifest(db, id, status, patch = {}) {
459
+ const sets = [`status = $2`, `updated_at = now()`];
460
+ const params = [id, status];
461
+ const terminal = status === 'done' || status === 'error' || status === 'expired';
462
+ if (terminal) sets.push(`resolved_at = now()`);
463
+ const COLS = { last_error: 'last_error', app_id: 'app_id', app_slug: 'app_slug', github_code: 'github_code' };
464
+ for (const [k, col] of Object.entries(COLS)) {
465
+ if (k in patch) { params.push(patch[k]); sets.push(`${col} = $${params.length}`); }
466
+ }
467
+ const { rows } = await db.query(
468
+ `UPDATE provisioning_oauth_manifests SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
469
+ params
470
+ );
471
+ return rows[0] || null;
472
+ }
473
+
474
+ // Append an audit event to provisioning_events.
475
+ async function recordEvent(db, { instanceId, ownerBuilderId, event, detail, costUsd, actor }) {
476
+ await db.query(
477
+ `INSERT INTO provisioning_events (instance_id, owner_builder_id, event, detail, cost_usd, actor)
478
+ VALUES ($1, $2, $3, $4, $5, $6)`,
479
+ [instanceId, ownerBuilderId ?? null, event, detail ?? null, costUsd ?? null, actor ?? null]
480
+ );
481
+ }
482
+
483
+ // The `provisioning.instanceState` port implementation: given an instance slug,
484
+ // return a small state summary (or null if unknown). Lets OTHER code read an
485
+ // instance's provisioning status via seams.resolveOptional without importing this
486
+ // module (the box.hasEverConnected precedent).
487
+ async function instanceStateForSlug(db, slug) {
488
+ const row = await getInstanceBySlug(db, slug);
489
+ if (!row) return null;
490
+ return {
491
+ slug: row.slug,
492
+ status: row.status,
493
+ hosting_shape: row.hosting_shape,
494
+ domain: row.domain || null,
495
+ };
496
+ }
497
+
498
+ // Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
499
+ // instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
500
+ async function costLedger(db, { limit = 500 } = {}) {
501
+ const { rows } = await db.query(
502
+ `SELECT pi.id, pi.slug, pi.hosting_shape, pi.tier, pi.status,
503
+ pi.cost_estimate_usd, b.github_login
504
+ FROM provisioning_instances pi
505
+ JOIN builders b ON b.id = pi.owner_builder_id
506
+ WHERE pi.status <> 'torn_down'
507
+ ORDER BY pi.cost_estimate_usd DESC, pi.updated_at DESC
508
+ LIMIT $1`,
509
+ [Math.min(Math.max(Number(limit) || 500, 1), 2000)]
510
+ );
511
+ const totalMonthlyUsd = rows.reduce((a, r) => a + Number(r.cost_estimate_usd || 0), 0);
512
+ return {
513
+ instances: rows.map((r) => ({
514
+ id: r.id, slug: r.slug, owner: r.github_login,
515
+ hosting_shape: r.hosting_shape, tier: r.tier, status: r.status,
516
+ cost_estimate_usd: Number(r.cost_estimate_usd || 0),
517
+ })),
518
+ total_monthly_usd: Number(totalMonthlyUsd.toFixed(4)),
519
+ };
520
+ }
521
+
522
+ module.exports = {
523
+ // constants
524
+ INSTANCE_STATES, HOSTING_SHAPES, INTENT_ACTIONS, TEARDOWN_FROM, TIER_MONTHLY_USD,
525
+ CERT_ELIGIBLE_STATES,
526
+ // pure
527
+ isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
528
+ // pure — GitHub App Manifest flow (task 2080)
529
+ githubAppName, buildGithubAppManifest, manifestPostUrl,
530
+ // data access
531
+ isProvisionedHostname,
532
+ getInstanceById, getInstanceBySlug, listInstancesForOwner, listAllInstances,
533
+ createInstance, setInstanceStatus,
534
+ enqueueIntent, getOpenIntent, claimNextIntent, resolveIntent,
535
+ recordEvent, instanceStateForSlug, costLedger,
536
+ // data access — GitHub App Manifest attempts (task 2080)
537
+ createOAuthManifest, getOAuthManifestByState, attachManifestCode,
538
+ getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
539
+ };
@@ -0,0 +1,35 @@
1
+ // modules/provisioning/rate-limit.js — a small per-IP sliding-window rate limiter
2
+ // for the module's PUBLIC tls-check `ask` endpoint (SEC review of task 1945).
3
+ //
4
+ // WHY THIS LIVES HERE (not core reuse). Core has publicReadRateLimitMiddleware
5
+ // (src/bongos/middleware/rate-limit.js), but a module may not import it: the one-way
6
+ // rule forbids a deep core require (ADR 0083), and the kernel doorway may not
7
+ // re-export it because rate-limit.js is a non-kernel core file (ADR 0091 §1). So we
8
+ // carry a dedicated limiter, mirroring core's shape. It is deliberately TIGHT: Caddy
9
+ // calls the on-demand-TLS `ask` endpoint at most once per new SNI, so legitimate
10
+ // volume is tiny — a low per-IP cap sheds an enumeration/issuance flood without ever
11
+ // throttling a real TLS handshake.
12
+ //
13
+ // In-memory + per-process (the core limiter's model too). `now` is injectable so the
14
+ // window logic is unit-testable without a real clock.
15
+ 'use strict';
16
+
17
+ function makeAskRateLimiter({ windowMs = 60_000, max = 60, now = Date.now } = {}) {
18
+ const buckets = new Map(); // ip -> array of recent hit timestamps within the window
19
+ return function askRateLimit(req, res, next) {
20
+ const t = now();
21
+ const ip = req.ip || (req.socket && req.socket.remoteAddress) || 'unknown';
22
+ // Prune this IP's bucket to the window, then decide.
23
+ const hits = (buckets.get(ip) || []).filter((ts) => ts > t - windowMs);
24
+ if (hits.length >= max) {
25
+ const retryAfter = Math.max(1, Math.ceil((hits[0] + windowMs - t) / 1000));
26
+ res.set('Retry-After', String(retryAfter));
27
+ return res.status(429).json({ ok: false, error: 'rate_limited', retry_after_seconds: retryAfter });
28
+ }
29
+ hits.push(t);
30
+ buckets.set(ip, hits);
31
+ return next();
32
+ };
33
+ }
34
+
35
+ module.exports = { makeAskRateLimiter };