mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
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:
1 parent
78b5ac1ce5
commit
df4b46b137
3 files changed
+153
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user