Files
hyperframes/packages/cli/src/server/studioServer.test.ts
T
Vance IngallsandClaude Opus 5 f81ab0162e fix(cli,studio): close the four R3 blocking gaps
P1 — SPA route bypassed the DNS-rebinding guard. Guarding only
/api/telemetry-identity left the catch-all as an open side door: a
rebound origin could fetch `/` and read __HF_CLI_DISTINCT_ID and
__HF_CLI_BUCKET_SEED straight out of the returned HTML. The SPA response
now applies the same isLoopbackHost() check; an untrusted Host still gets
a working Studio, just with no identity, seed or decisions injected.
Route-level regression added.

P1 — a CLI cohort roll could override Studio's own opt-out.
decideStudioCanary() adopted the injected decision before checking
isOptedOut(), so CLI-telemetry-on plus Studio-opted-out still enrolled
Studio. A bare boolean could not express the difference between a
deliberate override and an ordinary cohort roll, so the injected map now
carries provenance ({ enabled, forced }). Forced wins outright — it is
the documented escalation channel and must behave the same on both
surfaces — while a percentage roll now loses to this profile's opt-out.
Full interaction matrix tested.

P1 — the legacy studio:* path sat outside both contracts.
utils/studioTelemetry.ts shipped its own opt-out key and its own send
loop, so the documented hyperframes-studio:telemetryDisabled did not
silence it and its events carried no cohort assignment. It now honours
both keys (the legacy one stays, so nobody already opted out is quietly
re-enabled) and mixes in canaryEventProperties(), making "every
telemetry event carries the assignment" actually true.

P2 — partial salvage could drop a tripped breaker.
salvageInstallState() discarded the whole record when markerAt and
bucketSeed were both unusable, taking deParallelRouterTrialFired with it
and re-enrolling a machine whose router already failed. All three fields
are now independently salvageable.

Docs: canary-rollouts.mdx said "disabling telemetry disables the
reporting, not the enrolment" — exactly backwards since the opt-out gate
landed. Corrected; checked for other copies, none.

Tests: 13 new (4 opt-out precedence, 4 legacy-path opt-out and canary
props, 3 route-level host guard, 2 breaker salvage). Fault injection:
each of the four fixes reverted independently fails its own tests
(2 CLI + 1 Studio + 2 Studio).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:35:22 -07:00

114 lines
4.2 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
import { loadRuntimeSource } from "./runtimeSource.js";
import { createStudioServer, type StudioServer } from "./studioServer.js";
describe("loadRuntimeSource", () => {
it("loads runtime source from the published core entrypoint", async () => {
await expect(loadRuntimeSource()).resolves.toBe(loadHyperframeRuntimeSource());
});
});
describe("createStudioServer autoProxy plumbing", () => {
const dirs: string[] = [];
let server: StudioServer | undefined;
function tmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-studio-server-test-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
server?.watcher.close();
server = undefined;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it("hyperframes.json media.autoProxy=false flows through to the adapter", () => {
const projectDir = tmpProject();
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ media: { autoProxy: false } }),
);
server = createStudioServer({ projectDir });
expect(server.adapter.autoProxy).toBe(false);
});
it("defaults the adapter to autoProxy=true when neither option nor config disables it", () => {
server = createStudioServer({ projectDir: tmpProject() });
expect(server.adapter.autoProxy).toBe(true);
});
it("an explicit option (the preview command's resolved --proxy flag) wins over config", () => {
const projectDir = tmpProject();
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({ media: { autoProxy: false } }),
);
server = createStudioServer({ projectDir, autoProxy: true });
expect(server.adapter.autoProxy).toBe(true);
});
});
describe("host guarding on identity-bearing responses", () => {
const dirs: string[] = [];
let server: StudioServer | undefined;
function tmpProject(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-studio-host-test-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
server?.watcher.close();
server = undefined;
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
// A rebound origin can point its own hostname at 127.0.0.1 and read
// responses as same-origin. Guarding only /api/telemetry-identity left the
// SPA route as an open side door: fetching `/` returned the same distinct
// id and bucket seed inline in the HTML.
it("omits identity injection from the SPA response for a hostile Host", async () => {
server = createStudioServer({ projectDir: tmpProject() });
const res = await server.app.request("/", { headers: { host: "evil.example.com" } });
const html = await res.text();
expect(html).not.toContain("__HF_CLI_DISTINCT_ID");
expect(html).not.toContain("__HF_CLI_BUCKET_SEED");
expect(html).not.toContain("__HF_CLI_CANARY_DECISIONS");
// Studio still loads — only the identity block is withheld. (The env
// script is empty here: it only emits with VITE_STUDIO_* vars set.)
expect(res.status).toBe(200);
expect(html).toContain("<head>");
});
it("refuses the identity endpoint for a hostile Host", async () => {
server = createStudioServer({ projectDir: tmpProject() });
const res = await server.app.request("/api/telemetry-identity", {
headers: { host: "evil.example.com" },
});
expect(res.status).toBe(403);
expect(await res.text()).not.toContain('distinctId":"');
});
it("serves the identity endpoint on a loopback Host", async () => {
server = createStudioServer({ projectDir: tmpProject() });
const res = await server.app.request("/api/telemetry-identity", {
headers: { host: "127.0.0.1:5173" },
});
expect(res.status).toBe(200);
// The seed is no longer served here at all — Studio gets decisions
// injected instead, so nothing needs it over HTTP.
expect(Object.keys((await res.json()) as object)).toEqual(["distinctId"]);
});
});