mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 02:36:10 +00:00
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>
114 lines
4.2 KiB
TypeScript
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"]);
|
|
});
|
|
});
|