From df4b46b137093c0502235d0b906e2eed49bc5f76 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 12 Aug 2026 16:02:16 +0000 Subject: [PATCH] feat(shared): read exposure intent off legacy expose blocks (PAP-17158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automatic HTTPS for managed worktree runtimes cannot depend on every project template and UI caller supplying an `expose` block, so the server needs to tell three cases apart on a config it did not write: - `unset` — a pre-feature block that only declares the backend `urlTemplate`. Defaultable. - `enabled` — an explicit opt-in, honored verbatim. - `disabled` — a deliberate opt-out, which must survive the backfill. An explicit negative beats a positive in the same block so a stale `type` can never re-enable an opt-out. `resolveDeclaredRuntimeExposureConfig` completes a bare `{ type: "tailscale_https" }` from the fail-closed defaults while still rejecting smuggled fields, so the strict-config guarantee from the PAP-17050 verdict is unchanged. --- packages/shared/src/index.ts | 3 + .../src/validators/runtime-exposure.test.ts | 75 +++++++++++++++++++ .../shared/src/validators/runtime-exposure.ts | 75 +++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 45ff3d461d7..6e14a4b7751 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2447,8 +2447,11 @@ export { runtimeExposureListenerSchema, runtimeExposureStatusSchema, parseRuntimeExposureConfig, + readRuntimeExposureIntent, + resolveDeclaredRuntimeExposureConfig, DEFAULT_TAILSCALE_HTTPS_EXPOSURE, type RuntimeExposureConfigInput, + type RuntimeExposureIntent, type RuntimeExposureStatusInput, } from "./validators/runtime-exposure.js"; export { diff --git a/packages/shared/src/validators/runtime-exposure.test.ts b/packages/shared/src/validators/runtime-exposure.test.ts index 73575a1cfa3..31175388dba 100644 --- a/packages/shared/src/validators/runtime-exposure.test.ts +++ b/packages/shared/src/validators/runtime-exposure.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_TAILSCALE_HTTPS_EXPOSURE, parseRuntimeExposureConfig, + readRuntimeExposureIntent, + resolveDeclaredRuntimeExposureConfig, runtimeExposureConfigSchema, runtimeExposureListenerSchema, runtimeExposureStatusSchema, @@ -46,6 +48,79 @@ describe("runtimeExposureConfigSchema", () => { }); }); +describe("readRuntimeExposureIntent", () => { + it("treats a legacy expose block with no exposure fields as unset", () => { + // The pre-feature Paperclip App template shape: an `expose` block that only + // describes the backend URL. This must be defaultable, not opted out. + expect(readRuntimeExposureIntent({ urlTemplate: "http://paperclip-dev:{{port}}" })).toBe("unset"); + expect(readRuntimeExposureIntent(undefined)).toBe("unset"); + expect(readRuntimeExposureIntent(null)).toBe("unset"); + expect(readRuntimeExposureIntent({})).toBe("unset"); + expect(readRuntimeExposureIntent([])).toBe("unset"); + expect(readRuntimeExposureIntent("tailscale_https")).toBe("unset"); + }); + + it("reads explicit opt-in", () => { + expect(readRuntimeExposureIntent({ type: "tailscale_https" })).toBe("enabled"); + expect(readRuntimeExposureIntent(DEFAULT_TAILSCALE_HTTPS_EXPOSURE)).toBe("enabled"); + expect(readRuntimeExposureIntent({ tailscaleHttps: true })).toBe("enabled"); + }); + + it("reads deliberate opt-out", () => { + expect(readRuntimeExposureIntent({ tailscaleHttps: false })).toBe("disabled"); + expect(readRuntimeExposureIntent({ type: "none" })).toBe("disabled"); + }); + + it("lets an explicit negative win over a stale positive in the same block", () => { + expect(readRuntimeExposureIntent({ type: "tailscale_https", tailscaleHttps: false })).toBe("disabled"); + }); +}); + +describe("resolveDeclaredRuntimeExposureConfig", () => { + it("returns null unless the block explicitly opts in", () => { + expect(resolveDeclaredRuntimeExposureConfig(undefined)).toBeNull(); + expect(resolveDeclaredRuntimeExposureConfig({ urlTemplate: "http://paperclip-dev:{{port}}" })).toBeNull(); + expect(resolveDeclaredRuntimeExposureConfig({ tailscaleHttps: false })).toBeNull(); + }); + + it("completes a bare declaration from the fail-closed defaults", () => { + expect(resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https" })).toEqual( + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + ); + // Shorthand normalizes to the provider literal. + expect(resolveDeclaredRuntimeExposureConfig({ tailscaleHttps: true })).toEqual( + DEFAULT_TAILSCALE_HTTPS_EXPOSURE, + ); + }); + + it("keeps the backend urlTemplate alongside an opt-in without leaking it into the config", () => { + expect( + resolveDeclaredRuntimeExposureConfig({ + type: "tailscale_https", + urlTemplate: "http://127.0.0.1:{{port}}", + }), + ).toEqual(DEFAULT_TAILSCALE_HTTPS_EXPOSURE); + }); + + it("honors an explicit sub-field override", () => { + expect( + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", includePaperclipViteHmr: false }), + ).toEqual({ ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE, includePaperclipViteHmr: false }); + }); + + it("still rejects smuggled fields and invalid sub-field values", () => { + expect(() => + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", target: "http://127.0.0.1:5432" }), + ).toThrow(/Unsupported expose field/); + expect(() => + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", hostname: "evil.example" }), + ).toThrow(); + expect(() => + resolveDeclaredRuntimeExposureConfig({ type: "tailscale_https", failurePolicy: "fail_open" }), + ).toThrow(); + }); +}); + describe("runtimeExposureListenerSchema", () => { it("enforces the same-number invariant", () => { expect(() => diff --git a/packages/shared/src/validators/runtime-exposure.ts b/packages/shared/src/validators/runtime-exposure.ts index 39215da479b..362cfc47215 100644 --- a/packages/shared/src/validators/runtime-exposure.ts +++ b/packages/shared/src/validators/runtime-exposure.ts @@ -86,3 +86,78 @@ export function parseRuntimeExposureConfig( if (value === undefined || value === null) return null; return runtimeExposureConfigSchema.parse(value); } + +/** + * What an `expose` block says about HTTPS exposure, independent of whether the + * runtime is eligible for it. + * + * `unset` is the common case: legacy templates only declare + * `{ type: "url", urlTemplate }` for the backend readiness URL and say nothing + * about exposure. Those runtimes are defaulted to `tailscale_https` by the + * server start path when they are eligible (PAP-17158), so no project template + * or UI caller has to supply an exposure block. + */ +export type RuntimeExposureIntent = "enabled" | "disabled" | "unset"; + +/** + * Keys of the exposure contract itself. Anything a declared `tailscale_https` + * block sets outside this list (and {@link RUNTIME_EXPOSURE_TRANSPORT_KEYS}) is + * rejected, so a malformed or injected config still cannot smuggle an arbitrary + * target, path, or hostname suffix through to the broker (PAP-17050 verdict). + */ +const RUNTIME_EXPOSURE_CONFIG_KEYS = [ + "type", + "hostname", + "publicPort", + "includePaperclipViteHmr", + "failurePolicy", +] as const; + +/** + * Pre-existing `expose` keys that describe the *backend* URL rather than the + * exposure, and may legitimately sit next to a `tailscale_https` declaration. + */ +const RUNTIME_EXPOSURE_TRANSPORT_KEYS = ["urlTemplate", "tailscaleHttps"] as const; + +/** + * Read the declared intent off an `expose` block. + * + * An explicit negative always wins over an explicit positive so a deliberate + * opt-out can never be re-enabled by a stale `type` left in the same block. + */ +export function readRuntimeExposureIntent(value: unknown): RuntimeExposureIntent { + if (!value || typeof value !== "object" || Array.isArray(value)) return "unset"; + const expose = value as Record; + // Deliberate opt-outs, checked first. + if (expose.tailscaleHttps === false) return "disabled"; + if (expose.type === "none") return "disabled"; + if (expose.type === "tailscale_https" || expose.tailscaleHttps === true) return "enabled"; + return "unset"; +} + +/** + * Resolve the exposure config a service *declared* (as opposed to the one it + * inherits by default). Returns null unless the block explicitly opts in. + * + * Recognized sub-fields fall back to {@link DEFAULT_TAILSCALE_HTTPS_EXPOSURE}, + * so `{ type: "tailscale_https" }` is a complete declaration; unknown keys still + * throw rather than being silently dropped. + */ +export function resolveDeclaredRuntimeExposureConfig( + value: unknown, +): RuntimeExposureConfigInput | null { + if (readRuntimeExposureIntent(value) !== "enabled") return null; + const expose = value as Record; + const known = new Set([...RUNTIME_EXPOSURE_CONFIG_KEYS, ...RUNTIME_EXPOSURE_TRANSPORT_KEYS]); + const unknownKeys = Object.keys(expose).filter((key) => !known.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`Unsupported expose field(s): ${unknownKeys.sort().join(", ")}`); + } + const merged: Record = { ...DEFAULT_TAILSCALE_HTTPS_EXPOSURE }; + for (const key of RUNTIME_EXPOSURE_CONFIG_KEYS) { + if (expose[key] !== undefined) merged[key] = expose[key]; + } + // `tailscaleHttps: true` is shorthand for the provider; normalize it away. + merged.type = "tailscale_https"; + return runtimeExposureConfigSchema.parse(merged); +}