feat(shared): read exposure intent off legacy expose blocks (PAP-17158)

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.
This commit is contained in:
Dotta authored and Dotta committed 2026-08-16 19:26:07 +00:00
1 parent 78b5ac1ce5
commit df4b46b137
3 files changed
+153

No files matched your search

+3
View File
@@ -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 {
@@ -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(() =>
@@ -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<string, unknown>;
// 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<string, unknown>;
const known = new Set<string>([...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<string, unknown> = { ...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);
}