fix(cli,studio,core): close five R5 telemetry and canary findings

- A long-lived preview cached its telemetry posture in two places
  (readConfig and shouldTrack). Running `telemetry disable` in another
  terminal left it resolving canaries and injecting the CLI id for hours.
  Both caches are now dropped together at a request boundary.
- Studio minted and shipped a telemetry id for every render regardless of
  the browser profile's opt-out, and the server emitted the outcome under
  CLI policy, which cannot see localStorage or DNT. The browser now sends
  an explicit telemetryOptOut, distinct from an old client's omission.
- Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard,
  so even a loopback bind accepted a hostile Host. The guard now holds for
  loopback binds and, on a LAN bind, admits only names this machine
  answers on.
- sunsetAfter had no reader of the current date. A scheduled workflow runs
  scripts/check-canary-sunset.ts weekly, so a failure lands on the
  rollout's owner rather than on an unrelated PR author.
- The install-state seed memo outlived `rm -rf ~/.hyperframes`,
  resurrecting a cleared cohort. Removed; it only saved a read on a
  readConfig cache miss.

Docs updated for the Host rule and the 100% exclusion carve-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-01 17:21:41 -07:00
co-authored by Claude Opus 5
parent 3f69a2c635
commit 6f0df2640b
16 changed files with 504 additions and 47 deletions
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { CanvasResolution } from "@hyperframes/parsers";
import { trackStudioRenderStart } from "../../telemetry/events";
import { getAnonymousId } from "../../telemetry/config";
import { browserTelemetryAllowed } from "../../telemetry/policy";
import { generateId } from "../../utils/generateId";
export interface RenderJob {
@@ -157,16 +158,28 @@ export function useRenderQueue(projectId: string | null) {
resolution?: string;
composition?: string;
variables?: Record<string, unknown>;
telemetryDistinctId: string;
telemetryDistinctId?: string;
telemetryOptOut?: boolean;
} = {
fps,
quality,
format,
};
// The id is MINTED by getAnonymousId(), so calling it unconditionally
// created a telemetry identity for a profile that had opted out — and
// then shipped it to the server. The server's own policy cannot see this
// browser's localStorage or DoNotTrack, so it has to be told: an
// explicit `telemetryOptOut` suppresses the render outcome, which
// omitting the id alone does NOT (an old client omits it too, and that
// falls back to the install id).
if (browserTelemetryAllowed()) {
// So the server-emitted render_complete/render_error is attributed to
// this browser user (same id studio_* events use), making the render
// funnel joinable. Matches studio_render_start fired just above.
telemetryDistinctId: getAnonymousId(),
};
body.telemetryDistinctId = getAnonymousId();
} else {
body.telemetryOptOut = true;
}
if (resolution && resolution !== "auto") body.resolution = resolution;
if (composition) body.composition = composition;
if (opts.variables && Object.keys(opts.variables).length > 0) {
@@ -0,0 +1,104 @@
// @vitest-environment happy-dom
// The render request body is the only place the browser can tell the CLI that
// this profile opted out. The CLI's own policy cannot see localStorage or
// DoNotTrack in someone else's browser, so if the body says nothing, the
// server emits render_complete / render_error anyway — attributed to the
// install id rather than the user's, which is worse than attributing it
// correctly.
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const policyState = { allowed: true };
const mintCalls = vi.fn(() => "browser-user-123");
vi.mock("../../telemetry/policy", () => ({
browserTelemetryAllowed: () => policyState.allowed,
}));
vi.mock("../../telemetry/config", () => ({
getAnonymousId: () => mintCalls(),
}));
vi.mock("../../telemetry/events", () => ({
trackStudioRenderStart: vi.fn(),
}));
const { useRenderQueue } = await import("./useRenderQueue");
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | null = null;
/** Body of the POST the hook makes when a render is started. */
async function startRenderBody(): Promise<Record<string, unknown>> {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
Promise.resolve(
new Response(JSON.stringify({ jobId: "j1", status: "rendering" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal(
"EventSource",
class {
close(): void {}
addEventListener(): void {}
},
);
let api: ReturnType<typeof useRenderQueue> | null = null;
function Harness(): null {
api = useRenderQueue("demo");
return null;
}
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(<Harness />);
});
await act(async () => {
await api?.startRender({ fps: 30, quality: "standard", format: "mp4" });
});
const post = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
const body = post?.[1]?.body;
if (body === undefined || body === null) throw new Error("hook made no POST with a body");
return JSON.parse(String(body)) as Record<string, unknown>;
}
beforeEach(() => {
policyState.allowed = true;
mintCalls.mockClear();
});
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
describe("render request telemetry fields", () => {
it("sends the browser id when this profile allows telemetry", async () => {
const body = await startRenderBody();
expect(body["telemetryDistinctId"]).toBe("browser-user-123");
expect(body["telemetryOptOut"]).toBeUndefined();
});
it("mints no id and says so explicitly when the profile opted out", async () => {
policyState.allowed = false;
const body = await startRenderBody();
// Minting is itself the leak: getAnonymousId() CREATES and persists an
// identity, so calling it for an opted-out profile is wrong even if the
// value were never sent.
expect(mintCalls).not.toHaveBeenCalled();
expect(body["telemetryDistinctId"]).toBeUndefined();
// Omission alone would be read as an old client and fall back to the
// install id — the flag is what actually suppresses the server event.
expect(body["telemetryOptOut"]).toBe(true);
});
});