mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
co-authored by
Claude Opus 5
parent
3f69a2c635
commit
6f0df2640b
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user