/** * TASK-0132 — SCIM ETag support. * * RFC 7232 weak ETag (`W/"…"`) computed from the resource representation + * `updatedAt` — the same representation always yields the same tag (the * unit test asserts stability), and any mutation (which bumps `updatedAt` * or changes a field) yields a new one. Not stored (the task: "computed, * not stored — updatedAt + content hash"). */ import { createHash } from "node:crypto"; /** Canonical JSON: keys sorted recursively, so object key order in the * payload never changes the hash (stable across serialization paths). */ function canonical(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; const obj = value as Record; const keys = Object.keys(obj).sort(); return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(",")}}`; } /** `W/"scim-<16 hex>"` for a representation. */ export function computeEtag(representation: unknown, updatedAt: Date): string { const h = createHash("sha256") .update(canonical(representation)) .update(`|${updatedAt.toISOString()}`) .digest("hex") .slice(0, 16); return `W/"scim-${h}"`; } /** True if the `If-Match` header matches the current ETag. Per RFC 7232 * §3.1, `If-Match: *` always matches; a list matches if ANY listed tag * matches (we compare exact weak tags; the `W/` prefix is stripped). */ export function ifMatchMatches(ifMatch: string | undefined, currentEtag: string): boolean { if (!ifMatch) return true; // no precondition supplied const list = ifMatch.split(",").map((t) => t.trim()); if (list.includes("*")) return true; const target = currentEtag.replace(/^W\//, ""); return list.some((t) => t.replace(/^W\//, "") === target); }