@bongos/core 1.19.1072 → 1.19.1074

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 (297) hide show
  1. package/.bongos-core.json +320 -465
  2. package/.claude/skills/feedback/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -60
  5. package/clients/bongos-client/index.cjs +4 -60
  6. package/clients/bongos-client/index.d.ts +5 -90
  7. package/clients/bongos-client/index.mjs +4 -60
  8. package/config/branding.neutral.json +1 -5
  9. package/config/modules.neutral.json +2 -3
  10. package/config/scheduled-routines.json +0 -2
  11. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
  12. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
  13. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  14. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
  15. package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +6 -2
  16. package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
  17. package/docs/adr/README.md +2 -1
  18. package/docs/api/openapi.json +171 -1661
  19. package/docs/api-reference.md +9 -41
  20. package/docs/architecture.md +16 -1
  21. package/docs/branding-contract.md +0 -3
  22. package/docs/copy-inventory.md +454 -479
  23. package/docs/copy-registry.json +746 -978
  24. package/docs/file-map.md +5 -34
  25. package/docs/module-api-changelog.md +4 -0
  26. package/docs/modules-contract.md +13 -1
  27. package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
  28. package/docs/onboarding/diagrams/README.md +3 -3
  29. package/docs/onboarding/diagrams/assertions.json +2 -22
  30. package/docs/onboarding/primer.md +9 -28
  31. package/docs/page-inventory.json +4 -1
  32. package/docs/page-readings.json +1032 -1077
  33. package/docs/recipes/render-pilot.md +29 -10
  34. package/migrations/core_257_module_store_registry.sql +87 -0
  35. package/migrations/core_258_drop_dev_box_tables.sql +55 -0
  36. package/modules/agents/module.json +1 -0
  37. package/modules/autonomy/module.json +1 -0
  38. package/modules/builder-settings/module.json +1 -0
  39. package/modules/builder-settings/render-prefs.js +4 -5
  40. package/modules/copy-desk/docx.js +78 -5
  41. package/modules/copy-desk/module.json +1 -0
  42. package/modules/copy-desk/routes/copy-desk.js +9 -9
  43. package/modules/copy-desk/tests/copy_docx.mjs +107 -1
  44. package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
  45. package/modules/discord/module.json +1 -0
  46. package/modules/economy/module.json +1 -0
  47. package/modules/economy/routes/credits.js +1 -2
  48. package/modules/government/catalog.js +13 -17
  49. package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
  50. package/modules/government/module.json +1 -0
  51. package/modules/government/protected-surfaces.json +3 -11
  52. package/modules/government/resolver.js +3 -46
  53. package/modules/government/routes/government.js +0 -2
  54. package/modules/government/session-scopes.js +43 -83
  55. package/modules/government/session-scopes.json +8 -18
  56. package/modules/grading/module.json +1 -0
  57. package/modules/hall-ui/module.json +1 -0
  58. package/modules/hall-ui/public/board-lib.js +2 -2
  59. package/modules/hall-ui/public/builders.js +29 -40
  60. package/modules/hall-ui/public/collab.html +1 -1
  61. package/modules/hall-ui/public/collab.js +1 -1
  62. package/modules/hall-ui/public/diagrams.html +3 -3
  63. package/modules/hall-ui/public/dom-utils.js +1 -1
  64. package/modules/hall-ui/public/gate.js +2 -2
  65. package/modules/hall-ui/public/gate.states.json +1 -1
  66. package/modules/hall-ui/public/hall-render.js +43 -79
  67. package/modules/hall-ui/public/index.html +5 -1
  68. package/modules/hall-ui/public/modules.js +1 -0
  69. package/modules/hall-ui/public/oversight.css +5 -5
  70. package/modules/hall-ui/public/palette.js +0 -11
  71. package/modules/hall-ui/public/primer.js +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings-sessions.js +1 -1
  74. package/modules/hall-ui/public/settings.css +0 -1
  75. package/modules/hall-ui/public/settings.html +4 -33
  76. package/modules/hall-ui/public/settings.js +3 -33
  77. package/modules/hall-ui/public/settings.states.json +2 -2
  78. package/modules/hall-ui/public/studio.html +1 -1
  79. package/modules/hall-ui/public/style.css +10 -13
  80. package/modules/hall-ui/public/task.html +4 -1
  81. package/modules/hall-ui/public/task.js +36 -1
  82. package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
  83. package/modules/hall-ui/public/tweak-editor.css +16 -0
  84. package/modules/hall-ui/public/tweak-editor.html +26 -3
  85. package/modules/hall-ui/public/tweak-editor.js +32 -1
  86. package/modules/hall-ui/public/work.js +1 -1
  87. package/modules/hall-ui/records/tweak-editor.md +1 -0
  88. package/modules/ideas/module.json +1 -0
  89. package/modules/lifecycle/db-claim-reads.js +1 -58
  90. package/modules/lifecycle/db-ship.js +0 -5
  91. package/modules/lifecycle/db.js +0 -4
  92. package/modules/lifecycle/kickoff-checklist.js +0 -4
  93. package/modules/lifecycle/lifecycle.js +0 -8
  94. package/modules/lifecycle/module.json +1 -0
  95. package/modules/lifecycle/routes/tasks.js +5 -7
  96. package/modules/memory/module.json +1 -0
  97. package/modules/npm-release/module.json +1 -0
  98. package/modules/onboarding/module.json +1 -0
  99. package/modules/onboarding/onboarding-state.js +17 -36
  100. package/modules/onboarding/routes/access-requests.js +3 -3
  101. package/modules/platform-identity/module.json +1 -0
  102. package/modules/platform-identity/platform-identity.js +13 -5
  103. package/modules/platform-identity/routes/sso.js +2 -2
  104. package/modules/platform-identity/tests/platform-identity.mjs +17 -3
  105. package/modules/provisioning/module.json +1 -0
  106. package/modules/provisioning/provisioning.js +2 -2
  107. package/modules/provisioning/starter-bundles.js +5 -25
  108. package/modules/public-landing/module.json +1 -0
  109. package/modules/public-landing/public/assets/cosmos.css +1 -1
  110. package/modules/public-landing/public/contact.html +1 -1
  111. package/modules/public-landing/public/privacy.html +1 -1
  112. package/modules/public-landing/public/projects.html +101 -32
  113. package/modules/public-landing/public/projects.probes.json +2 -2
  114. package/modules/public-landing/public/projects.states.json +4 -3
  115. package/modules/public-landing/public/terms.html +1 -1
  116. package/modules/security/module.json +1 -0
  117. package/modules/sessions/module.json +1 -0
  118. package/modules/specialities/module.json +1 -0
  119. package/modules/status-ui/module.json +1 -0
  120. package/modules/ui-design/module.json +1 -0
  121. package/package-lock.json +2 -2
  122. package/package.json +1 -1
  123. package/release-notes.json +84 -0
  124. package/scripts/gds/artifact-format.js +203 -0
  125. package/scripts/gds/audit-authorship.sh +1 -13
  126. package/scripts/gds/claim.js +0 -15
  127. package/scripts/gds/cli-lib.js +8 -8
  128. package/scripts/gds/diagram-facts.js +12 -23
  129. package/scripts/gds/do-api.js +13 -80
  130. package/scripts/gds/doc-cli-guard.js +2 -0
  131. package/scripts/gds/doctor.js +1 -1
  132. package/scripts/gds/feedback-latest.js +139 -9
  133. package/scripts/gds/fitness-checks-identity.js +2 -7
  134. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  135. package/scripts/gds/fitness-ratchets.js +4 -4
  136. package/scripts/gds/fitness.js +10 -10
  137. package/scripts/gds/gen-diagrams.js +49 -71
  138. package/scripts/gds/http-api-client.js +12 -19
  139. package/scripts/gds/init.js +1 -1
  140. package/scripts/gds/lint-infra-exec.js +1 -1
  141. package/scripts/gds/local-preview-lib.js +8 -39
  142. package/scripts/gds/local-preview.js +2 -15
  143. package/scripts/gds/migration-namespace.js +3 -3
  144. package/scripts/gds/module-artifact.js +212 -0
  145. package/scripts/gds/module.js +95 -6
  146. package/scripts/gds/package-core.js +5 -116
  147. package/scripts/gds/provision-config.js +2 -2
  148. package/scripts/gds/provision-net.js +2 -3
  149. package/scripts/gds/provision.js +9 -9
  150. package/scripts/gds/publish-manifest.js +1 -2
  151. package/scripts/gds/regen-instance-docs.js +1 -1
  152. package/scripts/gds/run-unit-tests.js +2 -16
  153. package/scripts/gds/sandbox-stage.js +39 -341
  154. package/scripts/gds/ship-flow.js +6 -6
  155. package/scripts/gds/ship-land.js +3 -4
  156. package/scripts/gds/ship-regen.js +10 -41
  157. package/scripts/gds/ship.js +4 -24
  158. package/scripts/gds/start.js +0 -15
  159. package/scripts/hall-preview/README.md +5 -5
  160. package/scripts/hall-preview/server.js +6 -6
  161. package/scripts/render-diagrams.sh +1 -1
  162. package/src/bongos/api-errors.js +1 -1
  163. package/src/bongos/auth-github.js +2 -3
  164. package/src/bongos/auth.js +51 -126
  165. package/src/bongos/db-kernel.js +2 -3
  166. package/src/bongos/db.js +5 -28
  167. package/src/bongos/module-scope-map.js +14 -29
  168. package/src/bongos/module-store.js +141 -0
  169. package/src/bongos/route-rank-check.js +3 -16
  170. package/src/bongos/routes/auth.js +0 -116
  171. package/src/bongos/routes/builders.js +5 -15
  172. package/src/bongos/routes/instance.js +8 -14
  173. package/src/bongos/routes/me.js +6 -22
  174. package/src/bongos/routes/modules.js +83 -0
  175. package/src/bongos/routes.js +5 -0
  176. package/src/module-api.js +13 -6
  177. package/src/module-loader/catalog.js +3 -0
  178. package/src/module-loader/loader.js +4 -4
  179. package/src/module-loader/manifest-schema.js +15 -7
  180. package/src/module-seams.js +2 -3
  181. package/src/modules.js +42 -14
  182. package/tests/agents_spend_guard.mjs +1 -1
  183. package/tests/api_client.mjs +0 -15
  184. package/tests/auth_page_gate.mjs +10 -10
  185. package/tests/canonical_profile_url.mjs +1 -3
  186. package/tests/claim_action.mjs +4 -1
  187. package/tests/claim_from_task_and_home.mjs +127 -0
  188. package/tests/consumer_layout_boot.mjs +2 -0
  189. package/tests/copy_desk_page_docx.mjs +33 -2
  190. package/tests/core_upgrade_runner.mjs +6 -6
  191. package/tests/deploy_divergence_line.mjs +11 -11
  192. package/tests/diagram_facts_offset.mjs +1 -1
  193. package/tests/do_api.mjs +106 -0
  194. package/tests/feedback_latest.mjs +122 -0
  195. package/tests/fitness.mjs +44 -42
  196. package/tests/gate_approvals.mjs +0 -1
  197. package/tests/go_live.mjs +3 -3
  198. package/tests/government_abuse_matrix.mjs +20 -19
  199. package/tests/government_ownership_scope.mjs +33 -35
  200. package/tests/government_parity.mjs +2 -2
  201. package/tests/government_protected_surfaces.mjs +56 -37
  202. package/tests/government_require_permission.mjs +3 -3
  203. package/tests/government_seed.mjs +37 -4
  204. package/tests/government_session_scope.mjs +92 -366
  205. package/tests/hall_error_envelope.mjs +4 -0
  206. package/tests/hall_landing_boot.mjs +4 -1
  207. package/tests/hall_palette.mjs +1 -16
  208. package/tests/hall_record_world.mjs +6 -0
  209. package/tests/hall_tweak_editor.mjs +52 -1
  210. package/tests/host_topology_skips.mjs +1 -2
  211. package/tests/html_comment_nesting.mjs +137 -0
  212. package/tests/infra_exec_paths.mjs +9 -10
  213. package/tests/init.mjs +4 -5
  214. package/tests/instance_manifest.mjs +18 -25
  215. package/tests/kickoff_checklist.mjs +1 -1
  216. package/tests/lib_sh_resolution.mjs +0 -221
  217. package/tests/local_preview.mjs +0 -12
  218. package/tests/module-scope-map.mjs +9 -9
  219. package/tests/module_catalog.mjs +1 -1
  220. package/tests/module_cli.mjs +15 -15
  221. package/tests/module_contributions.mjs +6 -6
  222. package/tests/module_loader.mjs +6 -6
  223. package/tests/module_manifest.mjs +13 -4
  224. package/tests/module_store_publish.mjs +307 -0
  225. package/tests/module_store_publish_route.mjs +121 -0
  226. package/tests/module_store_registry_migration.mjs +70 -0
  227. package/tests/modules.mjs +56 -26
  228. package/tests/onboarding_route_signals.mjs +21 -31
  229. package/tests/onboarding_state.mjs +35 -49
  230. package/tests/permission_path.mjs +0 -3
  231. package/tests/platform_boot.mjs +1 -2
  232. package/tests/profile_route.mjs +1 -6
  233. package/tests/projects_hub.mjs +5 -5
  234. package/tests/projects_hub_app_step.mjs +138 -0
  235. package/tests/projects_hub_module_picker.mjs +2 -2
  236. package/tests/projects_hub_pre_uat.mjs +4 -5
  237. package/tests/provision.mjs +7 -7
  238. package/tests/provisioning_starter_bundles.mjs +0 -22
  239. package/tests/publish_branch_route.mjs +10 -18
  240. package/tests/publish_manifest.mjs +1 -2
  241. package/tests/rank_tier_single_source.mjs +1 -13
  242. package/tests/repo_map.mjs +3 -4
  243. package/tests/runner_drift.mjs +3 -21
  244. package/tests/sandbox_stage.mjs +57 -390
  245. package/tests/session_rename_fallback.mjs +0 -21
  246. package/tests/ship_error_shape.mjs +1 -1
  247. package/tests/task_visual_slots.mjs +31 -0
  248. package/tests/terms_acceptance.mjs +1 -1
  249. package/tests/upgrade.mjs +4 -4
  250. package/tests/watch_sealed_floor.mjs +0 -1
  251. package/tests/wizard_draft_resume.mjs +2 -2
  252. package/tests/wizard_intent_resume.mjs +21 -10
  253. package/tests/wizard_preselect_why.mjs +18 -18
  254. package/docs/onboarding/browser-terminal-guide.md +0 -73
  255. package/docs/recipes/managed-settings-remote-control.md +0 -416
  256. package/modules/dev-box/CLAUDE.md +0 -15
  257. package/modules/dev-box/box-access.js +0 -623
  258. package/modules/dev-box/box-credential.js +0 -94
  259. package/modules/dev-box/box-onboard.js +0 -486
  260. package/modules/dev-box/boxes.js +0 -1038
  261. package/modules/dev-box/db.js +0 -39
  262. package/modules/dev-box/module.json +0 -19
  263. package/modules/dev-box/routes/box.js +0 -1223
  264. package/scripts/gds/box-auth-check.js +0 -160
  265. package/scripts/gds/box-infra.js +0 -321
  266. package/scripts/gds/box-sync.js +0 -216
  267. package/scripts/gds/box.js +0 -1332
  268. package/scripts/gds/cf-tunnel.js +0 -558
  269. package/scripts/gds/smoke-box.sh +0 -150
  270. package/scripts/gds/tree-preflight.js +0 -241
  271. package/src/bongos/app-pair.js +0 -172
  272. package/tests/app_pair.mjs +0 -168
  273. package/tests/box_access.mjs +0 -578
  274. package/tests/box_auth_check.mjs +0 -114
  275. package/tests/box_code_staleness.mjs +0 -129
  276. package/tests/box_connect_e2e.mjs +0 -196
  277. package/tests/box_cost.mjs +0 -70
  278. package/tests/box_credential.mjs +0 -125
  279. package/tests/box_dns_repoint.mjs +0 -153
  280. package/tests/box_env.mjs +0 -64
  281. package/tests/box_host_keys.mjs +0 -126
  282. package/tests/box_infra_resolve.mjs +0 -246
  283. package/tests/box_onboard.mjs +0 -317
  284. package/tests/box_scope_session.mjs +0 -119
  285. package/tests/box_sweep_docs.mjs +0 -119
  286. package/tests/box_sync.mjs +0 -55
  287. package/tests/box_sync_scope_report.mjs +0 -126
  288. package/tests/box_task_scope.mjs +0 -169
  289. package/tests/box_task_scope_pause.mjs +0 -66
  290. package/tests/box_terminal_ssrf.mjs +0 -96
  291. package/tests/boxes.mjs +0 -1585
  292. package/tests/cf_ruleset_preflight.mjs +0 -142
  293. package/tests/cf_tunnel.mjs +0 -496
  294. package/tests/docs_scrubber_damage.mjs +0 -111
  295. package/tests/module_scope_active_claims.mjs +0 -94
  296. package/tests/ship_api_client_pathspec.mjs +0 -100
  297. package/tests/tree_preflight.mjs +0 -243
@@ -1,558 +0,0 @@
1
- // scripts/gds/cf-tunnel.js — a thin Cloudflare API client for the box terminal's
2
- // NAMED tunnel (#771, ADR 0038 Part B). A control-plane (operator-CLI) network
3
- // client; moved here from modules/dev-box/ alongside do-api.js so the control
4
- // plane owns the network clients and the shared http-api-client (task #1943).
5
- //
6
- // The control-plane half of the "named tunnel" production upgrade. #760 shipped
7
- // the BOX-side cloudflared-run.sh that already runs a named tunnel when
8
- // BOX_TUNNEL_TOKEN + BOX_TERMINAL_HOSTNAME are present in /etc/otb/box.env. This
9
- // module is how scripts/gds/box.js MAKES those exist: it creates (or reuses) a
10
- // remotely-managed Cloudflare Tunnel per builder, points its ingress at the box's
11
- // localhost ttyd (127.0.0.1:7681), routes a stable DNS CNAME
12
- // (term-<login>.example.com → <tunnel-id>.cfargotunnel.com), and hands
13
- // back the connector token to bake into the box. Teardown deletes the tunnel +
14
- // the DNS record.
15
- //
16
- // Design mirrors src/bongos/do-api.js exactly so the box subsystem has ONE shape for
17
- // every external API:
18
- // - Injectable transport (`fetchImpl`) so tests assert request shape with a
19
- // fake fetch and never touch the network.
20
- // - `dryRun` mode (the CLI DEFAULT): NO request is sent; each method logs what
21
- // it WOULD do and returns a synthetic `{ dryRun: true, ... }` so box.js's
22
- // control flow runs end-to-end against a throwaway plan.
23
- // - Live mode requires a token + accountId; the constructor throws clearly if
24
- // asked to run live without them — it never silently no-ops a real call.
25
- //
26
- // SECURITY: the Cloudflare API token (Account:Cloudflare Tunnel:Edit +
27
- // Zone:DNS:Edit) lives ONLY on the control plane (the prod droplet's box.env),
28
- // exactly where DO_API_TOKEN lives — never on the internet-facing web tier. A
29
- // web-tier compromise can never create or delete a tunnel. The per-builder
30
- // connector token this returns is baked into the box's cloud-init (same exposure
31
- // as the SSH keys already baked there) and only authorizes running THAT tunnel,
32
- // whose only target is the basic-auth-protected localhost ttyd.
33
-
34
- const { JsonHttpApi, makeApiError } = require('./http-api-client');
35
-
36
- const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
37
- const CFARGO_SUFFIX = 'cfargotunnel.com';
38
- const TTYD_SERVICE = 'http://127.0.0.1:7681';
39
- // The box's own `node server.js` (ADR 0044). A SECOND ingress route on the same
40
- // per-builder named tunnel exposes the live game preview at game-<login>.<apex>.
41
- const GAME_SERVICE = 'http://127.0.0.1:3000';
42
-
43
- // SECURITY (#919, security_reports SR#11): the apex gds_session cookie
44
- // (Domain=.example.com) is sent by the browser to every subdomain, including
45
- // the box-fronting term-<login> (ttyd) and sandbox-<login> (game preview) hosts —
46
- // so the builder's FULL hall bearer rides the tunnel onto the box, which is OUTSIDE
47
- // the ADR 0016 trust boundary. We must NOT narrow the apex cookie (the #726 SSO
48
- // across builders.example.com needs it), so instead a zone Transform Rule
49
- // removes the Cookie REQUEST header at the Cloudflare edge — before the request
50
- // enters the tunnel — for exactly the box subdomains. ttyd uses basic-auth and the
51
- // sandbox serves the game, so neither needs the cookie. Cloudflare permits REMOVE
52
- // (not set/modify) of the Cookie request header via Transform Rules.
53
- const COOKIE_STRIP_PHASE = 'http_request_late_transform';
54
- const COOKIE_STRIP_RULE_DESC = 'otb-919 strip Cookie on box subdomains (term-*/sandbox-*) — SR#11';
55
- // Zone-scoped ruleset ⇒ http.host is always within example.com, so the prefix
56
- // match precisely targets the per-builder box hosts and nothing else.
57
- const COOKIE_STRIP_EXPRESSION = 'starts_with(http.host, "term-") or starts_with(http.host, "sandbox-")';
58
-
59
- // #1065 — Cloudflare's DEFAULT 4h Browser Cache TTL rewrites the box origin's
60
- // no-store on the sandbox hosts, serving multi-hour-stale game code. A zone Cache
61
- // Rule scoped to sandbox-* bypasses the edge cache + respects the origin headers
62
- // there, WITHOUT touching the zone-wide TTL prod (the apex) relies on. Like the
63
- // cookie-strip rule, this needs the CF token to carry zone "Cache Rules" (Account
64
- // Rulesets) edit scope; callers run it best-effort so a missing scope warns.
65
- const CACHE_BYPASS_PHASE = 'http_request_cache_settings';
66
- const CACHE_BYPASS_RULE_DESC = 'otb-1065 bypass cache on sandbox preview hosts (sandbox-*) — keep prod cached';
67
- // Sandbox preview only. NOT term-* (ttyd, not cached) and NEVER the apex.
68
- const CACHE_BYPASS_EXPRESSION = 'starts_with(http.host, "sandbox-")';
69
-
70
- // FORCE IPv4 egress. The Cloudflare API token is IP-locked to the control-plane
71
- // droplet's IPv4 (least privilege, mirroring box-dns-cloudflare.sh's `curl -4`).
72
- // The droplet ALSO has IPv6 egress, and Node's fetch will happily dial Cloudflare
73
- // over IPv6 — whose source address is NOT on the token's allow-list, so Cloudflare
74
- // rejects it as an "Authentication error" (verified live under #771). undici's
75
- // Agent with connect.family=4 pins IPv4. Memoized; degrades to undefined (default
76
- // dispatcher) if undici can't be required, so a non-IP-locked token still works.
77
- let _ipv4Dispatcher;
78
- let _ipv4Tried = false;
79
- function ipv4Dispatcher() {
80
- if (_ipv4Tried) return _ipv4Dispatcher;
81
- _ipv4Tried = true;
82
- try {
83
- // eslint-disable-next-line global-require
84
- const { Agent } = require('undici');
85
- _ipv4Dispatcher = new Agent({ connect: { family: 4 } });
86
- } catch { _ipv4Dispatcher = undefined; }
87
- return _ipv4Dispatcher;
88
- }
89
-
90
- const CfApiError = makeApiError('CfApiError', 'CF_API_ERROR');
91
-
92
- // The Cloudflare token permission each ruleset phase is gated behind, named exactly
93
- // as it reads in the dashboard's token editor so the guidance is click-followable.
94
- const RULESET_PHASE_PERMISSION = {
95
- [COOKIE_STRIP_PHASE]: 'Transform Rules',
96
- [CACHE_BYPASS_PHASE]: 'Cache Rules',
97
- };
98
-
99
- // The one fix for every not-readable verdict: widen the token. Both edit scopes are
100
- // named because provisioning applies BOTH ruleset legs (cookie-strip + cache-bypass)
101
- // and an operator in the token editor should add them in one pass.
102
- function rulesetScopeGuidance(zone) {
103
- return `Widen CLOUDFLARE_API_TOKEN on the control plane: Cloudflare dashboard → My Profile → API Tokens → `
104
- + `edit the token → Permissions → add "Zone → Transform Rules → Edit" (the ADR 0053 cookie-strip) and `
105
- + `"Zone → Cache Rules → Edit" (the sandbox cache bypass) → Zone Resources → Include → Specific zone → ${zone} `
106
- + `→ Continue → Save. Only the account owner can do this. Until then, apply the rule by hand in the dashboard `
107
- + `(see docs/recipes/builder-box-lifecycle.md).`;
108
- }
109
-
110
- // PURE. Given the outcome of the READ-ONLY phase-entrypoint probe (HTTP status +
111
- // parsed Cloudflare JSON), say what we actually know about the token's scope.
112
- //
113
- // HONESTY — THIS IS A NEGATIVE DETECTOR, NOT A POSITIVE ONE. The probe is a GET, so
114
- // a 200 proves the token can READ the phase entrypoint; it does NOT prove the token
115
- // can EDIT it. Cloudflare exposes no read-only way to prove edit scope — only the
116
- // real PUT does. So:
117
- // - 401/403 → DEFINITIVELY no scope. This is the actionable case the preflight
118
- // exists to catch, and the only verdict we assert about the token.
119
- // - 200/404 → readable. Edit scope stays UNPROVEN until the rule PUT runs.
120
- // - anything else → inconclusive, reported as not-readable so the operator is
121
- // warned rather than falsely reassured.
122
- // The field is named `readable` and never `editable` for exactly this reason, and no
123
- // verdict here may render as "scope confirmed".
124
- function classifyRulesetScope({ status, body, phase, zone }) {
125
- const z = String(zone || '');
126
- const perm = RULESET_PHASE_PERMISSION[phase] || 'Transform Rules';
127
- if (status === 401 || status === 403) {
128
- return {
129
- readable: false,
130
- reason: `Cloudflare refused to read the "${phase}" ruleset on ${z} (HTTP ${status}) — the token definitively lacks zone "${perm}" scope there`,
131
- guidance: rulesetScopeGuidance(z),
132
- };
133
- }
134
- // A zone with no rules in this phase yet has no entrypoint ruleset. That is a
135
- // legitimate empty state (ensureCookieStripRule's PUT creates it), not a denial.
136
- if (status === 404) {
137
- return {
138
- readable: true,
139
- reason: `the "${phase}" phase has no entrypoint ruleset on ${z} yet (HTTP 404) — readable; edit scope is NOT proven until the rule is applied`,
140
- guidance: '',
141
- };
142
- }
143
- if (status !== 200 || !body || body.success !== true) {
144
- const errs = body && Array.isArray(body.errors) ? body.errors.map((e) => e && e.message).filter(Boolean).join('; ') : '';
145
- return {
146
- readable: false,
147
- reason: `the "${phase}" ruleset probe on ${z} failed and is inconclusive (HTTP ${status}${errs ? `: ${errs}` : ''})`,
148
- guidance: `Re-run once network/Cloudflare recovers. If it keeps failing, ${rulesetScopeGuidance(z)}`,
149
- };
150
- }
151
- return {
152
- readable: true,
153
- reason: `the token can READ the "${phase}" ruleset on ${z} — edit scope is NOT proven until the rule is applied`,
154
- guidance: '',
155
- };
156
- }
157
-
158
- // Read-only preflight for a zone ruleset phase. Does its OWN raw fetch rather than
159
- // routing through CfTunnelApi._req, because _req THROWS on failure and is dry-run
160
- // gated — a preflight must return a verdict (never throw) and must run on the
161
- // dry-run path too. Mirrors provision.js's checkDnsTokenScope.
162
- //
163
- // Deliberately does NOT use `GET /user/tokens/verify`: an IP-locked token answers
164
- // "Invalid API Token" there even when it is valid (docs/recipes/builder-box-
165
- // lifecycle.md). Probe the real operation instead.
166
- //
167
- // Returns { zone, phase, readable, reason, guidance[, zoneId] }. Never throws.
168
- async function checkRulesetScope({
169
- zone, phase = COOKIE_STRIP_PHASE, token = process.env.CLOUDFLARE_API_TOKEN,
170
- fetchImpl, zoneId = null, dispatcher,
171
- } = {}) {
172
- const base = { zone, phase };
173
- if (!zone) return { ...base, readable: false, reason: 'no Cloudflare zone given', guidance: 'set CLOUDFLARE_ZONE on the control plane' };
174
- if (!token) {
175
- return { ...base, readable: false, reason: `no CLOUDFLARE_API_TOKEN configured`,
176
- guidance: `Set CLOUDFLARE_API_TOKEN in the control-plane box.env. ${rulesetScopeGuidance(zone)}` };
177
- }
178
- const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
179
- if (!doFetch) return { ...base, readable: false, reason: 'no fetch implementation available', guidance: 'run on a Node with global fetch, or inject fetchImpl' };
180
- const init = { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } };
181
- if (dispatcher !== undefined) init.dispatcher = dispatcher;
182
- else { const d = ipv4Dispatcher(); if (d) init.dispatcher = d; }
183
-
184
- let id = zoneId;
185
- if (!id) {
186
- let res;
187
- try {
188
- res = await doFetch(`${CF_API_BASE}/zones?name=${encodeURIComponent(zone)}`, init);
189
- } catch (e) {
190
- return { ...base, readable: false, reason: `Cloudflare zone lookup failed: ${e.message}`, guidance: 'check control-plane network + token, then retry' };
191
- }
192
- const body = await res.json().catch(() => null);
193
- const match = (body && Array.isArray(body.result) ? body.result : []).find((r) => r && r.id);
194
- if (!match) {
195
- const errs = body && Array.isArray(body.errors) ? body.errors.map((e) => e && e.message).filter(Boolean).join('; ') : '';
196
- return { ...base, readable: false,
197
- reason: `could not resolve zone "${zone}" (HTTP ${res.status}${errs ? `: ${errs}` : ''})`,
198
- guidance: `Check that CLOUDFLARE_API_TOKEN is valid and its Zone Resources include ${zone}. ${rulesetScopeGuidance(zone)}` };
199
- }
200
- id = match.id;
201
- }
202
-
203
- let status = 0; let bodyJson = null;
204
- try {
205
- const res = await doFetch(`${CF_API_BASE}/zones/${id}/rulesets/phases/${phase}/entrypoint`, init);
206
- status = res.status;
207
- bodyJson = await res.json().catch(() => null);
208
- } catch (e) {
209
- return { ...base, zoneId: id, readable: false, reason: `Cloudflare ruleset probe failed: ${e.message}`, guidance: 'check control-plane network + token, then retry' };
210
- }
211
- return { ...base, zoneId: id, ...classifyRulesetScope({ status, body: bodyJson, phase, zone }) };
212
- }
213
-
214
- // Deterministic per-builder names so teardown can find what provision created
215
- // WITHOUT persisting an id in the DB (no migration): the tunnel name and the DNS
216
- // hostname are both pure functions of the login.
217
- function tunnelName(login) { return `otb-term-${login}`; }
218
- function terminalHostname(login, zone) { return `term-${login}.${zone}`; }
219
- // The live game preview host (ADR 0044) — the builder's "sandbox". One label
220
- // under the apex (same zone as the terminal host) so free Universal SSL covers
221
- // it — see the #771 lesson that forced an apex one-label host over `dev.*`.
222
- function previewHostname(login, zone) { return `sandbox-${login}.${zone}`; }
223
-
224
- class CfTunnelApi extends JsonHttpApi {
225
- // opts: { token, accountId, dryRun=true, fetchImpl=globalThis.fetch, base, log }
226
- constructor(opts = {}) {
227
- super(opts, { base: CF_API_BASE, logTag: 'CF', ErrorClass: CfApiError });
228
- this.accountId = opts.accountId || null;
229
- // Pin IPv4 for the live token (see ipv4Dispatcher). Overridable/null-able for
230
- // tests; a fake fetchImpl simply ignores the extra `dispatcher` option.
231
- this.dispatcher = opts.dispatcher !== undefined ? opts.dispatcher : ipv4Dispatcher();
232
- if (!this.dryRun) {
233
- if (!this.token) {
234
- throw new CfApiError(
235
- 'live Cloudflare call requested but no API token is set — ' +
236
- 'set CLOUDFLARE_API_TOKEN (Tunnel + DNS scope) or run without --apply (dry-run)'
237
- );
238
- }
239
- if (!this.accountId) {
240
- throw new CfApiError('live Cloudflare tunnel call requested but CLOUDFLARE_ACCOUNT_ID is not set');
241
- }
242
- if (typeof this.fetchImpl !== 'function') {
243
- throw new CfApiError('no fetch implementation available for live mode');
244
- }
245
- }
246
- }
247
-
248
- // CfTunnelApi pins the IPv4 dispatcher on every live request (its token is
249
- // IP-locked, #771) and treats the Cloudflare {success:false} envelope as a
250
- // failure, not just a non-2xx status. The dry-run/live skeleton itself (and the
251
- // envelope's {result} passthrough) is inherited from JsonHttpApi.
252
- requestInit() { return { dispatcher: this.dispatcher }; }
253
-
254
- detectError(res, json) {
255
- if (!res.ok || (json && json.success === false)) {
256
- const errs = json && Array.isArray(json.errors) ? json.errors.map((e) => e.message || e.code).join('; ') : '';
257
- return errs || `HTTP ${res.status}`;
258
- }
259
- return null;
260
- }
261
-
262
- // ---- tunnels (account-scoped) ---------------------------------------------
263
-
264
- // Find a non-deleted tunnel by exact name. Returns the tunnel object or null.
265
- async findTunnelByName(name) {
266
- const r = await this._req('GET', `/accounts/${this.accountId}/cfd_tunnel?name=${encodeURIComponent(name)}&is_deleted=false`, null, {
267
- dryRunResult: { result: [] },
268
- });
269
- const list = (r && r.result) || [];
270
- return list.find((tn) => tn.name === name) || null;
271
- }
272
-
273
- // Create a remotely-managed tunnel (config_src: 'cloudflare' so the ingress is
274
- // set via the API, not a config file on the box). Returns the tunnel object.
275
- async createTunnel(name) {
276
- const r = await this._req('POST', `/accounts/${this.accountId}/cfd_tunnel`, { name, config_src: 'cloudflare' }, {
277
- dryRunResult: { result: { id: `dryrun-tunnel-${name}`, name } },
278
- });
279
- return (r && r.result) || null;
280
- }
281
-
282
- // Find-or-create. Idempotent: re-provisioning a builder reuses their tunnel.
283
- async ensureTunnel(name) {
284
- const existing = await this.findTunnelByName(name);
285
- if (existing) { this.log(`reusing tunnel ${existing.id} (${name})`); return { tunnel: existing, created: false }; }
286
- const tunnel = await this.createTunnel(name);
287
- this.log(`created tunnel ${tunnel && tunnel.id} (${name})`);
288
- return { tunnel, created: true };
289
- }
290
-
291
- // The connector token (the BOX_TUNNEL_TOKEN the box runs cloudflared with).
292
- async getTunnelToken(tunnelId) {
293
- const r = await this._req('GET', `/accounts/${this.accountId}/cfd_tunnel/${tunnelId}/token`, null, {
294
- dryRunResult: { result: `dryrun-token-${tunnelId}` },
295
- });
296
- return (r && r.result) || null;
297
- }
298
-
299
- // Point the tunnel's ingress at the box's localhost ttyd, with a 404 catch-all.
300
- // `extraRoutes` ([{ hostname, service }, ...]) adds further hostname→service
301
- // rules on the SAME connector — e.g. the ADR 0044 game preview at
302
- // sandbox-<login>.<apex> → 127.0.0.1:3000 — inserted before the catch-all. A
303
- // Cloudflare named tunnel routes by hostname, so each route needs its own
304
- // proxied DNS CNAME (see ensureNamedTunnel).
305
- async putConfiguration(tunnelId, { hostname, service = TTYD_SERVICE, extraRoutes = [] } = {}) {
306
- const ingress = [{ hostname, service }, ...extraRoutes, { service: 'http_status:404' }];
307
- const config = { ingress };
308
- return this._req('PUT', `/accounts/${this.accountId}/cfd_tunnel/${tunnelId}/configurations`, { config }, {
309
- dryRunResult: { result: { config } },
310
- });
311
- }
312
-
313
- // Best-effort: drop any live connector connections so the tunnel can be deleted
314
- // (CF refuses to delete a tunnel with active connections). The droplet is
315
- // already gone by teardown time, so this is usually a no-op.
316
- async cleanupConnections(tunnelId) {
317
- return this._req('DELETE', `/accounts/${this.accountId}/cfd_tunnel/${tunnelId}/connections`, null, {
318
- dryRunResult: { result: {} },
319
- });
320
- }
321
-
322
- async deleteTunnel(tunnelId) {
323
- return this._req('DELETE', `/accounts/${this.accountId}/cfd_tunnel/${tunnelId}`, null, {
324
- dryRunResult: { result: { id: tunnelId } },
325
- });
326
- }
327
-
328
- // ---- DNS (zone-scoped) -----------------------------------------------------
329
-
330
- async getZoneId(zone) {
331
- const r = await this._req('GET', `/zones?name=${encodeURIComponent(zone)}`, null, {
332
- dryRunResult: { result: [{ id: `dryrun-zone-${zone}` }] },
333
- });
334
- const z = ((r && r.result) || [])[0];
335
- if (!z || !z.id) throw new CfApiError(`Cloudflare zone '${zone}' not found, or the token lacks access to it`);
336
- return z.id;
337
- }
338
-
339
- async findDnsRecord(zoneId, name) {
340
- const r = await this._req('GET', `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(name)}`, null, {
341
- dryRunResult: { result: [] },
342
- });
343
- const lower = name.toLowerCase();
344
- return (((r && r.result) || []).find((rec) => rec.name.toLowerCase() === lower)) || null;
345
- }
346
-
347
- // Upsert the proxied CNAME that fronts the tunnel. proxied:true is required for
348
- // a *.cfargotunnel.com target (the orange-cloud is what routes to the tunnel).
349
- async upsertDnsRoute({ zoneId, name, tunnelId }) {
350
- const body = { type: 'CNAME', name, content: `${tunnelId}.${CFARGO_SUFFIX}`, proxied: true, ttl: 1 };
351
- const existing = await this.findDnsRecord(zoneId, name);
352
- if (existing) {
353
- return this._req('PUT', `/zones/${zoneId}/dns_records/${existing.id}`, body, {
354
- dryRunResult: { result: { id: existing.id, ...body } },
355
- });
356
- }
357
- return this._req('POST', `/zones/${zoneId}/dns_records`, body, {
358
- dryRunResult: { result: { id: `dryrun-dns-${name}`, ...body } },
359
- });
360
- }
361
-
362
- async deleteDnsRoute({ zoneId, name }) {
363
- const existing = await this.findDnsRecord(zoneId, name);
364
- if (!existing) return { result: null, missing: true };
365
- return this._req('DELETE', `/zones/${zoneId}/dns_records/${existing.id}`, null, {
366
- dryRunResult: { result: { id: existing.id } },
367
- });
368
- }
369
-
370
- // ---- edge cookie strip (zone-scoped Transform Rule, #919 / SR#11) ----------
371
-
372
- // Ensure the zone's request-header Transform Rule that REMOVES the Cookie header
373
- // on the box subdomains (term-*/sandbox-*). Idempotent and NON-DESTRUCTIVE: it
374
- // reads the phase entrypoint ruleset, preserves every OTHER transform rule, drops
375
- // any prior copy of ours (matched by description), and PUTs the merged set. One
376
- // wildcard-prefix rule covers every builder's box, so this only needs to land
377
- // ONCE — but re-running on each provision self-heals a deleted rule. Requires the
378
- // CF token to carry zone "Transform Rules" (Account Rulesets) edit scope; callers
379
- // run it best-effort so a missing scope warns but never blocks box provisioning.
380
- async ensureCookieStripRule({ zone }) {
381
- const zoneId = await this.getZoneId(zone);
382
- const phasePath = `/zones/${zoneId}/rulesets/phases/${COOKIE_STRIP_PHASE}/entrypoint`;
383
- // Read the current entrypoint ruleset. A zone with no transform rules yet has
384
- // no entrypoint (404) — fine, the PUT below creates it.
385
- let existing = [];
386
- try {
387
- const got = await this._req('GET', phasePath, null, { dryRunResult: { result: { rules: [] } } });
388
- const rules = got && got.result && got.result.rules;
389
- existing = Array.isArray(rules) ? rules : [];
390
- } catch (e) {
391
- if (e.status && e.status !== 404) throw e;
392
- }
393
- const ourRule = {
394
- action: 'rewrite',
395
- action_parameters: { headers: { Cookie: { operation: 'remove' } } },
396
- expression: COOKIE_STRIP_EXPRESSION,
397
- description: COOKIE_STRIP_RULE_DESC,
398
- enabled: true,
399
- };
400
- // Preserve other rules, dropping CF-managed read-only fields (id/version/
401
- // last_updated) that a PUT rejects; keep a caller-set `ref` if present.
402
- const preserved = existing
403
- .filter((r) => r && r.description !== COOKIE_STRIP_RULE_DESC)
404
- .map((r) => ({
405
- action: r.action,
406
- action_parameters: r.action_parameters,
407
- expression: r.expression,
408
- description: r.description,
409
- enabled: r.enabled !== false,
410
- ...(r.ref ? { ref: r.ref } : {}),
411
- }));
412
- const rules = [...preserved, ourRule];
413
- return this._req('PUT', phasePath, { rules }, { dryRunResult: { result: { rules } } });
414
- }
415
-
416
- // ---- edge cache bypass on the sandbox hosts (zone Cache Rule, #1065) --------
417
-
418
- // Ensure the zone Cache Rule that BYPASSES Cloudflare caching for the sandbox
419
- // preview hosts (sandbox-*), so the box origin's no-store (#1065 part 1) is
420
- // honored and the 4h Browser Cache TTL stops serving stale game code. Idempotent
421
- // + NON-DESTRUCTIVE (mirrors ensureCookieStripRule): read the cache-phase
422
- // entrypoint ruleset, preserve every OTHER cache rule, drop any prior copy of
423
- // ours by description, PUT the merged set. Scoped strictly to sandbox-* via the
424
- // host expression so prod (the apex) caching is UNTOUCHED. Requires the CF token
425
- // to carry zone "Cache Rules" (Account Rulesets) edit scope; callers run it
426
- // best-effort so a missing scope warns but never blocks provisioning.
427
- async ensureCacheBypassRule({ zone }) {
428
- const zoneId = await this.getZoneId(zone);
429
- const phasePath = `/zones/${zoneId}/rulesets/phases/${CACHE_BYPASS_PHASE}/entrypoint`;
430
- let existing = [];
431
- try {
432
- const got = await this._req('GET', phasePath, null, { dryRunResult: { result: { rules: [] } } });
433
- const rules = got && got.result && got.result.rules;
434
- existing = Array.isArray(rules) ? rules : [];
435
- } catch (e) {
436
- if (e.status && e.status !== 404) throw e;
437
- }
438
- const ourRule = {
439
- action: 'set_cache_settings',
440
- action_parameters: {
441
- cache: false, // do not cache on the edge
442
- // Even if a future tweak re-enables caching, honor the box origin's
443
- // no-store and never override its browser TTL.
444
- browser_ttl: { mode: 'respect_origin' },
445
- },
446
- expression: CACHE_BYPASS_EXPRESSION,
447
- description: CACHE_BYPASS_RULE_DESC,
448
- enabled: true,
449
- };
450
- const preserved = existing
451
- .filter((r) => r && r.description !== CACHE_BYPASS_RULE_DESC)
452
- .map((r) => ({
453
- action: r.action,
454
- action_parameters: r.action_parameters,
455
- expression: r.expression,
456
- description: r.description,
457
- enabled: r.enabled !== false,
458
- ...(r.ref ? { ref: r.ref } : {}),
459
- }));
460
- const rules = [...preserved, ourRule];
461
- return this._req('PUT', phasePath, { rules }, { dryRunResult: { result: { rules } } });
462
- }
463
-
464
- // ---- high-level compose ----------------------------------------------------
465
-
466
- // Create-or-reuse everything the box needs for a stable named tunnel and return
467
- // the values to bake into /etc/otb/box.env. Idempotent end-to-end.
468
- async ensureNamedTunnel({ login, zone, terminalZone }) {
469
- const name = tunnelName(login);
470
- const hostname = terminalHostname(login, terminalZone);
471
- // The ADR 0044 live game preview rides the SAME tunnel as a second ingress
472
- // route + its own proxied CNAME.
473
- const previewHost = previewHostname(login, terminalZone);
474
- const { tunnel, created } = await this.ensureTunnel(name);
475
- const tunnelId = tunnel && tunnel.id;
476
- await this.putConfiguration(tunnelId, {
477
- hostname,
478
- extraRoutes: [{ hostname: previewHost, service: GAME_SERVICE }],
479
- });
480
- const token = await this.getTunnelToken(tunnelId);
481
- const zoneId = await this.getZoneId(zone);
482
- await this.upsertDnsRoute({ zoneId, name: hostname, tunnelId });
483
- await this.upsertDnsRoute({ zoneId, name: previewHost, tunnelId });
484
- // Both edge-rule legs are BEST-EFFORT and report their outcome STRUCTURALLY so
485
- // the caller can surface a failure in its summary instead of leaving it buried
486
- // in a mid-run log line (task 1002511). `applied` is false in dry-run because
487
- // nothing WAS applied — these are security/caching controls, and a plan must
488
- // never read as though the rule is in place.
489
- const edgeRules = {
490
- cookieStrip: { applied: false, error: null },
491
- cacheBypass: { applied: false, error: null },
492
- };
493
- // SECURITY (#919 / SR#11): strip the apex cookie at the edge for the box
494
- // subdomains. A CF token without Transform Rules scope must not block
495
- // provisioning (the box still works; the rule is defense-in-depth). Warn
496
- // loudly so the operator adds the scope or applies the rule once by hand.
497
- try {
498
- await this.ensureCookieStripRule({ zone });
499
- edgeRules.cookieStrip.applied = !this.dryRun;
500
- } catch (e) {
501
- edgeRules.cookieStrip.error = e.message;
502
- this.log(`⚠ #919 cookie-strip Transform Rule NOT applied (${e.message}) — add zone "Transform Rules" scope to CLOUDFLARE_API_TOKEN or apply it once in the CF dashboard (ADR 0053)`);
503
- }
504
- // #1065: stop Cloudflare's 4h Browser Cache TTL from serving stale sandbox
505
- // game code. A CF token without Cache-Rules scope must not block provisioning.
506
- // Warn loudly so the operator adds the scope or applies the rule once by hand
507
- // (operator blocker, like the cookie-strip rule).
508
- try {
509
- await this.ensureCacheBypassRule({ zone });
510
- edgeRules.cacheBypass.applied = !this.dryRun;
511
- } catch (e) {
512
- edgeRules.cacheBypass.error = e.message;
513
- this.log(`⚠ #1065 sandbox cache-bypass Cache Rule NOT applied (${e.message}) — add zone "Cache Rules" (Account Rulesets) scope to CLOUDFLARE_API_TOKEN or add it once in the CF dashboard`);
514
- }
515
- return { tunnelId, token, hostname, previewHostname: previewHost, created, edgeRules };
516
- }
517
-
518
- // Tear down the per-builder tunnel + its DNS route. Best-effort: each leg is
519
- // independent so a missing tunnel still cleans the DNS and vice versa. Returns a
520
- // summary the caller can log. Never throws (so deprovision always completes).
521
- async teardownNamedTunnel({ login, zone, terminalZone }) {
522
- const out = { tunnelDeleted: false, dnsDeleted: false, previewDnsDeleted: false, errors: [] };
523
- const name = tunnelName(login);
524
- const hostname = terminalHostname(login, terminalZone);
525
- const previewHost = previewHostname(login, terminalZone);
526
- try {
527
- const zoneId = await this.getZoneId(zone);
528
- const res = await this.deleteDnsRoute({ zoneId, name: hostname });
529
- out.dnsDeleted = !(res && res.missing);
530
- // The ADR 0044 preview CNAME shares the tunnel; drop it too (best-effort,
531
- // independent leg so a missing record never blocks teardown).
532
- try {
533
- const pres = await this.deleteDnsRoute({ zoneId, name: previewHost });
534
- out.previewDnsDeleted = !(pres && pres.missing);
535
- } catch (e) { out.errors.push(`preview-dns: ${e.message}`); }
536
- } catch (e) { out.errors.push(`dns: ${e.message}`); }
537
- try {
538
- const tunnel = await this.findTunnelByName(name);
539
- if (tunnel && tunnel.id) {
540
- await this.cleanupConnections(tunnel.id).catch(() => {});
541
- await this.deleteTunnel(tunnel.id);
542
- out.tunnelDeleted = true;
543
- }
544
- } catch (e) { out.errors.push(`tunnel: ${e.message}`); }
545
- return out;
546
- }
547
- }
548
-
549
- module.exports = {
550
- CfTunnelApi, CfApiError, CF_API_BASE, CFARGO_SUFFIX, TTYD_SERVICE, GAME_SERVICE,
551
- tunnelName, terminalHostname, previewHostname,
552
- // #1065: the sandbox edge cache-bypass rule constants, exported for the test.
553
- CACHE_BYPASS_PHASE, CACHE_BYPASS_RULE_DESC, CACHE_BYPASS_EXPRESSION,
554
- // #919 / SR#11: the cookie-strip rule constants + the read-only scope preflight
555
- // box.js runs before any droplet spend (task 1002511).
556
- COOKIE_STRIP_PHASE, COOKIE_STRIP_RULE_DESC, COOKIE_STRIP_EXPRESSION,
557
- classifyRulesetScope, checkRulesetScope,
558
- };