// The person's platform analytics and observability data (ADR-049): start its erasure or export, // follow the job, take the export away. Pure TypeScript over the ACCOUNT_PRIVACY port. // // The platform's GET routes answer every request made by this project's deployment tokens, so a // request id from the browser would let one person read another's job or export. The browser // therefore only ever holds a ticket, signed here: // // `v1...` // payload = `v1||:|` // // K is not the deployment token itself: it is HKDF-SHA256 over `ticketKey` (the token) with the info // label TICKET_KEY_INFO, so the bearer credential never doubles as a MAC key and the ticket MAC is // useless for anything else. The version and the expiry are inside the signed payload, so neither // can be edited. A ticket is accepted only for the user it was signed for and only until it // expires (TICKET_LIFETIME_MS); a ticket of somebody else, an expired one or a forged one is the // same NotFound. // // Rotation: rotating the deployment token changes K, so every outstanding ticket stops verifying // (NotFound). That is deliberate and harmless: nothing is stored, a ticket only lives in the open // page, and asking again answers the open job (the platform's own idempotency) with a fresh ticket, // or starts a new export once the last one finished. A reload forgets the ticket the same way. import { AppError, conflict, notFound } from "@conusai-kit/core" import type { AnalyticsView } from "../contract/procedures.js" import type { AccountPrivacy, AccountPrivacyRequest } from "./ports.js" const encoder = new TextEncoder() /** The ticket format's version; part of both the ticket and its signed payload. */ export const TICKET_VERSION = "v1" /** The HKDF info label that makes the ticket MAC key purpose-specific. */ export const TICKET_KEY_INFO = "conusai-template/account-privacy-ticket/v1" /** How long a ticket names its request: a day, longer than any page stays open on a job. */ export const TICKET_LIFETIME_MS = 24 * 60 * 60 * 1000 const unavailable = (detail: string) => new AppError({ type: "/problems/unavailable", title: "Service unavailable", status: 503, code: "unavailable", detail, }) function hex(bytes: ArrayBuffer): string { return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("") } /** HKDF-SHA256(secret, salt = empty, info = TICKET_KEY_INFO) as a non-extractable HMAC key. */ async function ticketMacKey(secret: string): Promise { const ikm = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveKey"]) return crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(TICKET_KEY_INFO) }, ikm, { name: "HMAC", hash: "SHA-256", length: 256 }, false, ["sign"], ) } async function signature(secret: string, userId: string, requestId: string, expiresAt: number): Promise { // A length prefix, so ("ab", "c") and ("a", "bc") never sign alike; the request id is last and the // other fields are fixed-form, so the payload parses one way only. const payload = `${TICKET_VERSION}|${expiresAt}|${userId.length}:${userId}|${requestId}` return hex(await crypto.subtle.sign("HMAC", await ticketMacKey(secret), encoder.encode(payload))) } /** Constant-time comparison of two hex strings. */ function sameHex(a: string, b: string): boolean { if (a.length !== b.length) return false let diff = 0 for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) return diff === 0 } /** A ticket naming `requestId` for `userId`, valid for TICKET_LIFETIME_MS from `now`. */ export async function ticketFor(secret: string, userId: string, requestId: string, now: number): Promise { const expiresAt = now + TICKET_LIFETIME_MS return `${TICKET_VERSION}.${requestId}.${expiresAt}.${await signature(secret, userId, requestId, expiresAt)}` } /** * The request id a ticket names, when it was signed for this user under this secret and has not * expired at `now`; NotFound otherwise (one answer for forged, foreign and expired tickets). */ export async function requestIdOf(secret: string, userId: string, ticket: string, now: number): Promise { const missing = () => notFound("no such request for this account") const parts = ticket.split(".") // The request id may in principle hold dots: version first, expiry and MAC last. if (parts.length < 4 || parts[0] !== TICKET_VERSION) throw missing() const given = parts.at(-1) const expiresText = parts.at(-2) const requestId = parts.slice(1, -2).join(".") if (!given || !expiresText || !requestId || !/^[0-9]{1,16}$/.test(expiresText)) throw missing() const expiresAt = Number(expiresText) const expected = await signature(secret, userId, requestId, expiresAt) // The MAC first and always, then the clock: an expired ticket answers exactly like a forged one. const authentic = sameHex(given, expected) if (!authentic || !(now < expiresAt)) throw missing() return requestId } /** The platform's refusals as the app's problems; the deployment token never shows in one. */ function platformFailure(error: unknown): AppError { const status = typeof (error as { status?: unknown })?.status === "number" ? (error as { status: number }).status : 0 if (status === 404) return notFound("no such request for this account") if (status === 409) return conflict("the export is not ready, has expired, or failed") if (status === 403) return unavailable("this app's deployment token does not hold privacy:subjects") return unavailable("the platform's privacy service did not answer") } const view = async ( privacy: AccountPrivacy, userId: string, request: AccountPrivacyRequest, created: boolean, now: number, ): Promise => ({ ticket: await ticketFor(privacy.ticketKey, userId, request.id, now), kind: request.kind, status: request.status, created, stores: Object.fromEntries( Object.entries(request.stores).map(([store, outcome]) => [ store, { status: outcome.status === "not_keyed" || outcome.status === "failed" ? outcome.status : "completed", deleted: outcome.deleted ?? 0, anonymized: outcome.anonymized ?? 0, exported: outcome.exported ?? 0, }, ]), ) as AnalyticsView["stores"], requestedAt: request.created_at, finishedAt: request.finished_at ?? null, expiresAt: request.export_expires_at ?? null, }) const required = (privacy: AccountPrivacy | undefined): AccountPrivacy => { if (!privacy) throw unavailable("this deployment has no platform privacy service") return privacy } export async function requestAnalytics( privacy: AccountPrivacy | undefined, userId: string, kind: AnalyticsView["kind"], now: () => number, ): Promise { const port = required(privacy) try { const { request, created } = kind === "erase" ? await port.eraseSubject({ userId }) : await port.exportSubject({ userId }) return await view(port, userId, request, created, now()) } catch (error) { throw error instanceof AppError ? error : platformFailure(error) } } export async function analyticsStatus( privacy: AccountPrivacy | undefined, userId: string, ticket: string, now: () => number, ): Promise { const port = required(privacy) const requestId = await requestIdOf(port.ticketKey, userId, ticket, now()) try { return await view(port, userId, await port.getRequest(requestId), false, now()) } catch (error) { throw error instanceof AppError ? error : platformFailure(error) } } export async function analyticsExport( privacy: AccountPrivacy | undefined, userId: string, ticket: string, now: () => number, ): Promise<{ exportedAt: string; document: Record }> { const port = required(privacy) const requestId = await requestIdOf(port.ticketKey, userId, ticket, now()) try { return { exportedAt: new Date(now()).toISOString(), document: await port.downloadExport(requestId) } } catch (error) { throw error instanceof AppError ? error : platformFailure(error) } }