← back to Kickbacks Ai Upstream
src/consent/client.ts
63 lines
import { timeoutFetch } from "../util/http";
type Fetch = typeof fetch;
export interface ConsentState {
telemetryOptIn: boolean;
tosAcceptedVersion: string | null;
currentTosVersion: string;
}
/** Backend-facing consent shim (wave-2I-F03). Mirrors the EarningsClient
* fail-safe pattern: any error => null. Never throws into activation. */
export class ConsentClient {
constructor(
private base: string,
private token: () => string | null,
// audit-2026-06-09 #38: was bare `fetch` — the only client whose DEFAULT
// bypassed the 2A-01 timeout wrapper. Consent is two tiny calls; 30s is
// a generous budget for the user-blocking Agree POST.
private f: Fetch = timeoutFetch(30000),
) {}
async read(): Promise<ConsentState | null> {
try {
const t = this.token();
if (!t) return null;
const r = await this.f(`${this.base}/v1/me/consent`,
{ headers: { authorization: `Bearer ${t}` } });
if (!r.ok) return null;
const j = await r.json() as {
telemetry_opt_in?: boolean;
tos_accepted_version?: string | null;
current_tos_version?: string;
};
if (typeof j.current_tos_version !== "string") return null;
return {
telemetryOptIn: !!j.telemetry_opt_in,
tosAcceptedVersion: typeof j.tos_accepted_version === "string"
? j.tos_accepted_version
: null,
currentTosVersion: j.current_tos_version,
};
} catch { return null; }
}
async accept(): Promise<{ tosVersion: string; acceptedAt: string } | null> {
try {
const t = this.token();
if (!t) return null;
const r = await this.f(`${this.base}/v1/me/consent`,
{ method: "POST", headers: { authorization: `Bearer ${t}` } });
if (!r.ok) return null;
const j = await r.json() as {
tos_version?: string;
accepted_at?: string;
};
if (typeof j.tos_version !== "string"
|| typeof j.accepted_at !== "string") return null;
return { tosVersion: j.tos_version, acceptedAt: j.accepted_at };
} catch { return null; }
}
}