@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.
- package/.bongos-core.json +320 -465
- package/.claude/skills/feedback/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -60
- package/clients/bongos-client/index.cjs +4 -60
- package/clients/bongos-client/index.d.ts +5 -90
- package/clients/bongos-client/index.mjs +4 -60
- package/config/branding.neutral.json +1 -5
- package/config/modules.neutral.json +2 -3
- package/config/scheduled-routines.json +0 -2
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
- package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
- package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +6 -2
- package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
- package/docs/adr/README.md +2 -1
- package/docs/api/openapi.json +171 -1661
- package/docs/api-reference.md +9 -41
- package/docs/architecture.md +16 -1
- package/docs/branding-contract.md +0 -3
- package/docs/copy-inventory.md +454 -479
- package/docs/copy-registry.json +746 -978
- package/docs/file-map.md +5 -34
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +13 -1
- package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
- package/docs/onboarding/diagrams/README.md +3 -3
- package/docs/onboarding/diagrams/assertions.json +2 -22
- package/docs/onboarding/primer.md +9 -28
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +1032 -1077
- package/docs/recipes/render-pilot.md +29 -10
- package/migrations/core_257_module_store_registry.sql +87 -0
- package/migrations/core_258_drop_dev_box_tables.sql +55 -0
- package/modules/agents/module.json +1 -0
- package/modules/autonomy/module.json +1 -0
- package/modules/builder-settings/module.json +1 -0
- package/modules/builder-settings/render-prefs.js +4 -5
- package/modules/copy-desk/docx.js +78 -5
- package/modules/copy-desk/module.json +1 -0
- package/modules/copy-desk/routes/copy-desk.js +9 -9
- package/modules/copy-desk/tests/copy_docx.mjs +107 -1
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
- package/modules/discord/module.json +1 -0
- package/modules/economy/module.json +1 -0
- package/modules/economy/routes/credits.js +1 -2
- package/modules/government/catalog.js +13 -17
- package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
- package/modules/government/module.json +1 -0
- package/modules/government/protected-surfaces.json +3 -11
- package/modules/government/resolver.js +3 -46
- package/modules/government/routes/government.js +0 -2
- package/modules/government/session-scopes.js +43 -83
- package/modules/government/session-scopes.json +8 -18
- package/modules/grading/module.json +1 -0
- package/modules/hall-ui/module.json +1 -0
- package/modules/hall-ui/public/board-lib.js +2 -2
- package/modules/hall-ui/public/builders.js +29 -40
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/dom-utils.js +1 -1
- package/modules/hall-ui/public/gate.js +2 -2
- package/modules/hall-ui/public/gate.states.json +1 -1
- package/modules/hall-ui/public/hall-render.js +43 -79
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.js +1 -0
- package/modules/hall-ui/public/oversight.css +5 -5
- package/modules/hall-ui/public/palette.js +0 -11
- package/modules/hall-ui/public/primer.js +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-sessions.js +1 -1
- package/modules/hall-ui/public/settings.css +0 -1
- package/modules/hall-ui/public/settings.html +4 -33
- package/modules/hall-ui/public/settings.js +3 -33
- package/modules/hall-ui/public/settings.states.json +2 -2
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +10 -13
- package/modules/hall-ui/public/task.html +4 -1
- package/modules/hall-ui/public/task.js +36 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
- package/modules/hall-ui/public/tweak-editor.css +16 -0
- package/modules/hall-ui/public/tweak-editor.html +26 -3
- package/modules/hall-ui/public/tweak-editor.js +32 -1
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/hall-ui/records/tweak-editor.md +1 -0
- package/modules/ideas/module.json +1 -0
- package/modules/lifecycle/db-claim-reads.js +1 -58
- package/modules/lifecycle/db-ship.js +0 -5
- package/modules/lifecycle/db.js +0 -4
- package/modules/lifecycle/kickoff-checklist.js +0 -4
- package/modules/lifecycle/lifecycle.js +0 -8
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/tasks.js +5 -7
- package/modules/memory/module.json +1 -0
- package/modules/npm-release/module.json +1 -0
- package/modules/onboarding/module.json +1 -0
- package/modules/onboarding/onboarding-state.js +17 -36
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/module.json +1 -0
- package/modules/platform-identity/platform-identity.js +13 -5
- package/modules/platform-identity/routes/sso.js +2 -2
- package/modules/platform-identity/tests/platform-identity.mjs +17 -3
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/starter-bundles.js +5 -25
- package/modules/public-landing/module.json +1 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +101 -32
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +4 -3
- package/modules/public-landing/public/terms.html +1 -1
- package/modules/security/module.json +1 -0
- package/modules/sessions/module.json +1 -0
- package/modules/specialities/module.json +1 -0
- package/modules/status-ui/module.json +1 -0
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +84 -0
- package/scripts/gds/artifact-format.js +203 -0
- package/scripts/gds/audit-authorship.sh +1 -13
- package/scripts/gds/claim.js +0 -15
- package/scripts/gds/cli-lib.js +8 -8
- package/scripts/gds/diagram-facts.js +12 -23
- package/scripts/gds/do-api.js +13 -80
- package/scripts/gds/doc-cli-guard.js +2 -0
- package/scripts/gds/doctor.js +1 -1
- package/scripts/gds/feedback-latest.js +139 -9
- package/scripts/gds/fitness-checks-identity.js +2 -7
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/fitness-ratchets.js +4 -4
- package/scripts/gds/fitness.js +10 -10
- package/scripts/gds/gen-diagrams.js +49 -71
- package/scripts/gds/http-api-client.js +12 -19
- package/scripts/gds/init.js +1 -1
- package/scripts/gds/lint-infra-exec.js +1 -1
- package/scripts/gds/local-preview-lib.js +8 -39
- package/scripts/gds/local-preview.js +2 -15
- package/scripts/gds/migration-namespace.js +3 -3
- package/scripts/gds/module-artifact.js +212 -0
- package/scripts/gds/module.js +95 -6
- package/scripts/gds/package-core.js +5 -116
- package/scripts/gds/provision-config.js +2 -2
- package/scripts/gds/provision-net.js +2 -3
- package/scripts/gds/provision.js +9 -9
- package/scripts/gds/publish-manifest.js +1 -2
- package/scripts/gds/regen-instance-docs.js +1 -1
- package/scripts/gds/run-unit-tests.js +2 -16
- package/scripts/gds/sandbox-stage.js +39 -341
- package/scripts/gds/ship-flow.js +6 -6
- package/scripts/gds/ship-land.js +3 -4
- package/scripts/gds/ship-regen.js +10 -41
- package/scripts/gds/ship.js +4 -24
- package/scripts/gds/start.js +0 -15
- package/scripts/hall-preview/README.md +5 -5
- package/scripts/hall-preview/server.js +6 -6
- package/scripts/render-diagrams.sh +1 -1
- package/src/bongos/api-errors.js +1 -1
- package/src/bongos/auth-github.js +2 -3
- package/src/bongos/auth.js +51 -126
- package/src/bongos/db-kernel.js +2 -3
- package/src/bongos/db.js +5 -28
- package/src/bongos/module-scope-map.js +14 -29
- package/src/bongos/module-store.js +141 -0
- package/src/bongos/route-rank-check.js +3 -16
- package/src/bongos/routes/auth.js +0 -116
- package/src/bongos/routes/builders.js +5 -15
- package/src/bongos/routes/instance.js +8 -14
- package/src/bongos/routes/me.js +6 -22
- package/src/bongos/routes/modules.js +83 -0
- package/src/bongos/routes.js +5 -0
- package/src/module-api.js +13 -6
- package/src/module-loader/catalog.js +3 -0
- package/src/module-loader/loader.js +4 -4
- package/src/module-loader/manifest-schema.js +15 -7
- package/src/module-seams.js +2 -3
- package/src/modules.js +42 -14
- package/tests/agents_spend_guard.mjs +1 -1
- package/tests/api_client.mjs +0 -15
- package/tests/auth_page_gate.mjs +10 -10
- package/tests/canonical_profile_url.mjs +1 -3
- package/tests/claim_action.mjs +4 -1
- package/tests/claim_from_task_and_home.mjs +127 -0
- package/tests/consumer_layout_boot.mjs +2 -0
- package/tests/copy_desk_page_docx.mjs +33 -2
- package/tests/core_upgrade_runner.mjs +6 -6
- package/tests/deploy_divergence_line.mjs +11 -11
- package/tests/diagram_facts_offset.mjs +1 -1
- package/tests/do_api.mjs +106 -0
- package/tests/feedback_latest.mjs +122 -0
- package/tests/fitness.mjs +44 -42
- package/tests/gate_approvals.mjs +0 -1
- package/tests/go_live.mjs +3 -3
- package/tests/government_abuse_matrix.mjs +20 -19
- package/tests/government_ownership_scope.mjs +33 -35
- package/tests/government_parity.mjs +2 -2
- package/tests/government_protected_surfaces.mjs +56 -37
- package/tests/government_require_permission.mjs +3 -3
- package/tests/government_seed.mjs +37 -4
- package/tests/government_session_scope.mjs +92 -366
- package/tests/hall_error_envelope.mjs +4 -0
- package/tests/hall_landing_boot.mjs +4 -1
- package/tests/hall_palette.mjs +1 -16
- package/tests/hall_record_world.mjs +6 -0
- package/tests/hall_tweak_editor.mjs +52 -1
- package/tests/host_topology_skips.mjs +1 -2
- package/tests/html_comment_nesting.mjs +137 -0
- package/tests/infra_exec_paths.mjs +9 -10
- package/tests/init.mjs +4 -5
- package/tests/instance_manifest.mjs +18 -25
- package/tests/kickoff_checklist.mjs +1 -1
- package/tests/lib_sh_resolution.mjs +0 -221
- package/tests/local_preview.mjs +0 -12
- package/tests/module-scope-map.mjs +9 -9
- package/tests/module_catalog.mjs +1 -1
- package/tests/module_cli.mjs +15 -15
- package/tests/module_contributions.mjs +6 -6
- package/tests/module_loader.mjs +6 -6
- package/tests/module_manifest.mjs +13 -4
- package/tests/module_store_publish.mjs +307 -0
- package/tests/module_store_publish_route.mjs +121 -0
- package/tests/module_store_registry_migration.mjs +70 -0
- package/tests/modules.mjs +56 -26
- package/tests/onboarding_route_signals.mjs +21 -31
- package/tests/onboarding_state.mjs +35 -49
- package/tests/permission_path.mjs +0 -3
- package/tests/platform_boot.mjs +1 -2
- package/tests/profile_route.mjs +1 -6
- package/tests/projects_hub.mjs +5 -5
- package/tests/projects_hub_app_step.mjs +138 -0
- package/tests/projects_hub_module_picker.mjs +2 -2
- package/tests/projects_hub_pre_uat.mjs +4 -5
- package/tests/provision.mjs +7 -7
- package/tests/provisioning_starter_bundles.mjs +0 -22
- package/tests/publish_branch_route.mjs +10 -18
- package/tests/publish_manifest.mjs +1 -2
- package/tests/rank_tier_single_source.mjs +1 -13
- package/tests/repo_map.mjs +3 -4
- package/tests/runner_drift.mjs +3 -21
- package/tests/sandbox_stage.mjs +57 -390
- package/tests/session_rename_fallback.mjs +0 -21
- package/tests/ship_error_shape.mjs +1 -1
- package/tests/task_visual_slots.mjs +31 -0
- package/tests/terms_acceptance.mjs +1 -1
- package/tests/upgrade.mjs +4 -4
- package/tests/watch_sealed_floor.mjs +0 -1
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_intent_resume.mjs +21 -10
- package/tests/wizard_preselect_why.mjs +18 -18
- package/docs/onboarding/browser-terminal-guide.md +0 -73
- package/docs/recipes/managed-settings-remote-control.md +0 -416
- package/modules/dev-box/CLAUDE.md +0 -15
- package/modules/dev-box/box-access.js +0 -623
- package/modules/dev-box/box-credential.js +0 -94
- package/modules/dev-box/box-onboard.js +0 -486
- package/modules/dev-box/boxes.js +0 -1038
- package/modules/dev-box/db.js +0 -39
- package/modules/dev-box/module.json +0 -19
- package/modules/dev-box/routes/box.js +0 -1223
- package/scripts/gds/box-auth-check.js +0 -160
- package/scripts/gds/box-infra.js +0 -321
- package/scripts/gds/box-sync.js +0 -216
- package/scripts/gds/box.js +0 -1332
- package/scripts/gds/cf-tunnel.js +0 -558
- package/scripts/gds/smoke-box.sh +0 -150
- package/scripts/gds/tree-preflight.js +0 -241
- package/src/bongos/app-pair.js +0 -172
- package/tests/app_pair.mjs +0 -168
- package/tests/box_access.mjs +0 -578
- package/tests/box_auth_check.mjs +0 -114
- package/tests/box_code_staleness.mjs +0 -129
- package/tests/box_connect_e2e.mjs +0 -196
- package/tests/box_cost.mjs +0 -70
- package/tests/box_credential.mjs +0 -125
- package/tests/box_dns_repoint.mjs +0 -153
- package/tests/box_env.mjs +0 -64
- package/tests/box_host_keys.mjs +0 -126
- package/tests/box_infra_resolve.mjs +0 -246
- package/tests/box_onboard.mjs +0 -317
- package/tests/box_scope_session.mjs +0 -119
- package/tests/box_sweep_docs.mjs +0 -119
- package/tests/box_sync.mjs +0 -55
- package/tests/box_sync_scope_report.mjs +0 -126
- package/tests/box_task_scope.mjs +0 -169
- package/tests/box_task_scope_pause.mjs +0 -66
- package/tests/box_terminal_ssrf.mjs +0 -96
- package/tests/boxes.mjs +0 -1585
- package/tests/cf_ruleset_preflight.mjs +0 -142
- package/tests/cf_tunnel.mjs +0 -496
- package/tests/docs_scrubber_damage.mjs +0 -111
- package/tests/module_scope_active_claims.mjs +0 -94
- package/tests/ship_api_client_pathspec.mjs +0 -100
- package/tests/tree_preflight.mjs +0 -243
package/scripts/gds/cf-tunnel.js
DELETED
|
@@ -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
|
-
};
|