返回 CodeWhale
cloud-facts.ts
根目录 / web / lib / cloud-facts.ts
1 /** Verified facts/v1 delivery. Supabase and KV are untrusted transports; only
2 * active, pinned public keys authenticate the exact payload bytes. */
3 import { DOMAIN, MAX_PAYLOAD_BYTES, TRUSTED_KEYS, type TrustedKey } from "./cloud-facts/keys";
4 import { readBoundedBody } from "./bounded-body";
5 import type { KVStreamNamespace } from "./kv";
6
7 export const CHANNEL_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
8 export const KV_PREFIX = "facts:cloud:";
9 export const SUPABASE_TIMEOUT_MS = 3000;
10 export const MAX_ENVELOPE_BYTES = 768 * 1024;
11 // The last-good copy only bridges a transport outage. It lives no longer than
12 // a client keeps applying its own cached envelope (payload ttl_secs, 6 h by
13 // default, docs/CLOUD_FACTS.md), so an outage cannot extend a withdrawn
14 // release beyond the staleness every client already accepts.
15 const KV_TTL_SECS = 60 * 60 * 6;
16 const KEY_ID_RE = /^cwf-[a-z0-9-]{1,32}$/;
17 const VERSION_REQ_RE = /^(\*|(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?(\s*,\s*(?:>=|<=|>|<|=|\^|~)?\s*\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?)*)$/;
18 const MAX_SIGNATURES = 7; // Plus the primary signature: eight candidates total.
19 const CLOCK_SKEW_MS = 5 * 60 * 1000;
20
21 export interface FactsCurrentRow {
22 channel: string;
23 release_id: string;
24 facts_version: number;
25 schema_version: number;
26 envelope_version: number;
27 applies_to: string;
28 key_id: string;
29 payload_b64: string;
30 sig_b64: string;
31 sigs: { key_id: string; sig_b64: string }[] | null;
32 payload_sha256: string;
33 published_at: string;
34 not_after: string | null;
35 }
36
37 export interface CloudFactsEnvelope {
38 envelope: number;
39 channel: string;
40 facts_version: number;
41 schema_version: number;
42 key_id: string;
43 alg: "ed25519";
44 applies_to: string;
45 published_at: string;
46 not_after?: string | null;
47 payload_b64: string;
48 sig_b64: string;
49 sigs: { key_id: string; sig_b64: string }[];
50 sha256: string;
51 }
52
53 export interface CloudFactsEnv {
54 SUPABASE_URL?: string;
55 SUPABASE_PUBLISHABLE_KEY?: string;
56 CURATED_KV?: KVStreamNamespace;
57 }
58
59 type Rejection = "no-active-keys" | "unknown-key" | "retired-key" | "bad-signature" |
60 "bad-envelope" | "bad-payload" | "sha-mismatch" | "wrong-channel" | "expired";
61 export type Verification =
62 | { ok: true; keyId: string; mode: "verified" }
63 | { ok: false; reason: Rejection };
64
65 export type CloudFactsResult =
66 | {
67 kind: "ok";
68 envelope: CloudFactsEnvelope;
69 body: string;
70 etag: string;
71 source: "supabase" | "kv-stale";
72 verified: "verified";
73 keyId: string;
74 }
75 | { kind: "none" }
76 | { kind: "sha-mismatch"; channel: string; factsVersion: number }
77 | { kind: "unverifiable"; reason: string; channel: string; factsVersion: number }
78 | { kind: "unavailable"; reason: string };
79
80 export interface ResolveOptions {
81 fetchImpl?: typeof fetch;
82 keys?: readonly TrustedKey[];
83 timeoutMs?: number;
84 now?: () => number;
85 }
86
87 export function isValidChannel(slug: string): boolean {
88 return CHANNEL_RE.test(slug);
89 }
90
91 function isObject(value: unknown): value is Record<string, unknown> {
92 return value !== null && typeof value === "object" && !Array.isArray(value);
93 }
94
95 /** Reject noncanonical/oversized encodings before either decoder allocates. */
96 function b64ToBytes(value: unknown, maxBytes: number): Uint8Array {
97 if (typeof value !== "string" || !value.length || value.length > 4 * Math.ceil(maxBytes / 3) ||
98 (value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))) {
99 throw new Error("invalid-base64");
100 }
101 const bin = atob(value);
102 if (bin.length > maxBytes || btoa(bin) !== value) throw new Error("invalid-base64");
103 return Uint8Array.from(bin, (char) => char.charCodeAt(0));
104 }
105
106 export async function sha256Hex(bytes: Uint8Array): Promise<string> {
107 const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
108 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
109 }
110
111 export function signingMessage(keyId: string, payload: Uint8Array): Uint8Array {
112 const prefix = new TextEncoder().encode(`${DOMAIN}${keyId}\0`);
113 const out = new Uint8Array(prefix.length + payload.length);
114 out.set(prefix);
115 out.set(payload, prefix.length);
116 return out;
117 }
118
119 function hasActiveKeys(keys: readonly TrustedKey[]): boolean {
120 const ids = new Set<string>();
121 try {
122 for (const key of keys) {
123 if (!KEY_ID_RE.test(key.keyId) || ids.has(key.keyId) ||
124 !["active", "retired"].includes(key.status) || b64ToBytes(key.publicKey, 32).length !== 32) return false;
125 ids.add(key.keyId);
126 }
127 return keys.some((key) => key.status === "active");
128 } catch { return false; }
129 }
130
131 function utcTime(value: unknown): number | null {
132 if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return null;
133 const time = Date.parse(value);
134 // Date.parse normalizes invalid civil dates such as February 30.
135 return Number.isFinite(time) && new Date(time).toISOString().slice(0, 19) === value.slice(0, 19) ? time : null;
136 }
137
138 function validSignature(value: unknown): boolean {
139 if (!isObject(value) || typeof value.key_id !== "string" || !KEY_ID_RE.test(value.key_id)) return false;
140 try { return b64ToBytes(value.sig_b64, 64).length === 64; } catch { return false; }
141 }
142
143 function isEnvelope(value: unknown): value is CloudFactsEnvelope {
144 if (!isObject(value)) return false;
145 return value.envelope === 1 && value.alg === "ed25519" && value.schema_version === 1 &&
146 typeof value.channel === "string" && isValidChannel(value.channel) &&
147 Number.isSafeInteger(value.facts_version) && Number(value.facts_version) > 0 &&
148 typeof value.applies_to === "string" && value.applies_to.length <= 200 && VERSION_REQ_RE.test(value.applies_to) &&
149 utcTime(value.published_at) !== null && (value.not_after == null || utcTime(value.not_after) !== null) &&
150 typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) &&
151 typeof value.payload_b64 === "string" && validSignature(value) &&
152 Array.isArray(value.sigs) && value.sigs.length <= MAX_SIGNATURES && value.sigs.every(validSignature);
153 }
154
155 function rowTimestamp(value: string): string {
156 // PostgREST emits timestamptz as +00:00; the signed contract uses UTC Z.
157 if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return value;
158 const time = Date.parse(value);
159 return Number.isFinite(time) ? new Date(time).toISOString().replace(/\.000Z$/, "Z") : value;
160 }
161
162 export function envelopeFromRow(row: FactsCurrentRow): CloudFactsEnvelope {
163 return {
164 envelope: row.envelope_version,
165 channel: row.channel,
166 facts_version: row.facts_version,
167 schema_version: row.schema_version,
168 key_id: row.key_id,
169 alg: "ed25519",
170 applies_to: row.applies_to,
171 published_at: rowTimestamp(row.published_at),
172 not_after: row.not_after == null ? null : rowTimestamp(row.not_after),
173 payload_b64: row.payload_b64,
174 sig_b64: row.sig_b64,
175 sigs: row.sigs === null ? [] : row.sigs,
176 sha256: row.payload_sha256,
177 };
178 }
179
180 /** A strong validator covers signatures and every other byte of the response. */
181 export async function etagFor(envelope: CloudFactsEnvelope): Promise<string> {
182 return `"${await sha256Hex(new TextEncoder().encode(JSON.stringify(envelope)))}"`;
183 }
184
185 /** Authenticate first, then validate signed metadata. A channel serves all
186 * client versions; each client evaluates the validated applicability range. */
187 export async function verifyEnvelope(
188 value: unknown,
189 keys: readonly TrustedKey[] = TRUSTED_KEYS,
190 opts: { channel?: string; now?: number } = {},
191 ): Promise<Verification> {
192 if (!hasActiveKeys(keys)) return { ok: false, reason: "no-active-keys" };
193 if (!isEnvelope(value)) return { ok: false, reason: "bad-envelope" };
194 const envelope = value;
195 let payload: Uint8Array;
196 try { payload = b64ToBytes(envelope.payload_b64, MAX_PAYLOAD_BYTES); }
197 catch { return { ok: false, reason: "bad-envelope" }; }
198 let keyId: string | undefined;
199 let sawKnown = false;
200 let sawRetired = false;
201 for (const candidate of [envelope, ...envelope.sigs]) {
202 const key = keys.find((key) => key.keyId === candidate.key_id);
203 if (!key) continue;
204 if (key.status !== "active") { sawRetired = true; continue; }
205 sawKnown = true;
206 try {
207 const publicKey = await crypto.subtle.importKey("raw", b64ToBytes(key.publicKey, 32) as BufferSource, { name: "Ed25519" }, false, ["verify"]);
208 if (await crypto.subtle.verify({ name: "Ed25519" }, publicKey, b64ToBytes(candidate.sig_b64, 64) as BufferSource, signingMessage(candidate.key_id, payload) as BufferSource)) {
209 keyId = candidate.key_id;
210 break;
211 }
212 } catch { /* An invalid candidate cannot authenticate the payload. */ }
213 }
214 if (!keyId) return { ok: false, reason: sawKnown ? "bad-signature" : sawRetired ? "retired-key" : "unknown-key" };
215 if (await sha256Hex(payload) !== envelope.sha256) return { ok: false, reason: "sha-mismatch" };
216 let facts: Record<string, unknown>;
217 try {
218 const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload));
219 if (!isObject(parsed)) return { ok: false, reason: "bad-payload" };
220 facts = parsed;
221 } catch { return { ok: false, reason: "bad-payload" }; }
222 for (const field of ["channel", "facts_version", "schema_version", "applies_to"] as const) {
223 if (facts[field] !== envelope[field]) return { ok: false, reason: "bad-payload" };
224 }
225 if (utcTime(facts.published_at) === null || utcTime(facts.published_at) !== utcTime(envelope.published_at) ||
226 (facts.not_after != null && utcTime(facts.not_after) === null) ||
227 (facts.not_after == null ? null : utcTime(facts.not_after)) !== (envelope.not_after == null ? null : utcTime(envelope.not_after)) ||
228 (facts.models !== undefined && !Array.isArray(facts.models)) ||
229 (facts.provider_defaults !== undefined && !isObject(facts.provider_defaults)) ||
230 (facts.announcements !== undefined && !Array.isArray(facts.announcements)) ||
231 (facts.release != null && !isObject(facts.release))) return { ok: false, reason: "bad-payload" };
232 if (opts.channel !== undefined && envelope.channel !== opts.channel) return { ok: false, reason: "wrong-channel" };
233 const now = opts.now ?? Date.now();
234 const published = utcTime(facts.published_at)!;
235 const expires = facts.not_after == null ? null : utcTime(facts.not_after);
236 if (!Number.isFinite(now) || published > now + CLOCK_SKEW_MS ||
237 (expires !== null && expires <= published)) return { ok: false, reason: "bad-payload" };
238 if (expires !== null && now >= expires) return { ok: false, reason: "expired" };
239 return { ok: true, keyId, mode: "verified" };
240 }
241
242 /** Only publishable keys (or legacy anon JWTs), never secret/service-role keys. */
243 function isPublishableKey(key: string): boolean {
244 if (/^sb_publishable_[A-Za-z0-9_-]+$/.test(key)) return true;
245 if (key.length > 8192) return false;
246 try {
247 const parts = key.split(".");
248 if (parts.length !== 3) return false;
249 const middle = parts[1].replace(/-/g, "+").replace(/_/g, "/");
250 const payload = JSON.parse(atob(middle.padEnd(Math.ceil(middle.length / 4) * 4, "=")));
251 return isObject(payload) && payload.role === "anon";
252 } catch { return false; }
253 }
254
255 export async function fetchCurrentRow(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<FactsCurrentRow | null> {
256 if (!isValidChannel(channel)) throw new Error("invalid-channel");
257 const key = env.SUPABASE_PUBLISHABLE_KEY;
258 let base: URL;
259 try {
260 base = new URL(env.SUPABASE_URL ?? "");
261 if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash || !key || !isPublishableKey(key)) throw new Error();
262 } catch { throw new Error("supabase-not-configured"); }
263 const controller = new AbortController();
264 const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? SUPABASE_TIMEOUT_MS);
265 try {
266 const url = new URL(`${base.href.replace(/\/+$/, "")}/rest/v1/facts_current`);
267 url.search = new URLSearchParams({ channel: `eq.${channel}`, scope: "eq.global", select: "channel,release_id,facts_version,schema_version,envelope_version,applies_to,key_id,payload_b64,sig_b64,sigs,payload_sha256,published_at,not_after", limit: "1" }).toString();
268 const res = await (opts.fetchImpl ?? fetch)(url, {
269 headers: { apikey: key!, Authorization: `Bearer ${key}`, Accept: "application/json" },
270 signal: controller.signal,
271 redirect: "error",
272 });
273 if (!res.ok) { await res.body?.cancel(); throw new Error(`supabase-http-${res.status}`); }
274 const bytes = await readBoundedBody(res, MAX_ENVELOPE_BYTES);
275 const rows: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
276 if (!Array.isArray(rows) || rows.length > 1) throw new Error("supabase-bad-row");
277 if (rows.length === 0) return null;
278 if (!isObject(rows[0]) || !isEnvelope(envelopeFromRow(rows[0] as unknown as FactsCurrentRow))) throw new Error("supabase-bad-row");
279 return rows[0] as unknown as FactsCurrentRow;
280 } finally { clearTimeout(timer); }
281 }
282
283 async function kvGet(env: CloudFactsEnv, channel: string): Promise<unknown> {
284 if (!env.CURATED_KV) return null;
285 try {
286 const body = await env.CURATED_KV.get(`${KV_PREFIX}${channel}`, "stream");
287 if (!body) return null;
288 const bytes = await readBoundedBody({ body, headers: new Headers() }, MAX_ENVELOPE_BYTES);
289 return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
290 } catch { return null; }
291 }
292
293 async function retireLastGood(env: CloudFactsEnv, channel: string): Promise<void> {
294 if (!env.CURATED_KV || !isEnvelope(await kvGet(env, channel))) return;
295 try {
296 await env.CURATED_KV.put(`${KV_PREFIX}${channel}`, JSON.stringify({ retired: "no-current-head" }), { expirationTtl: KV_TTL_SECS });
297 } catch { /* Best effort, like the write; the copy still expires on its TTL. */ }
298 }
299
300 export async function resolveCloudFacts(channel: string, env: CloudFactsEnv, opts: ResolveOptions = {}): Promise<CloudFactsResult> {
301 if (!isValidChannel(channel)) return { kind: "none" };
302 const keys = opts.keys ?? TRUSTED_KEYS;
303 // Do not consult either transport when this release has no usable trust root.
304 if (!hasActiveKeys(keys)) return { kind: "unavailable", reason: "no-active-keys" };
305 let envelope: unknown;
306 let source: "supabase" | "kv-stale" = "supabase";
307 try {
308 const row = await fetchCurrentRow(channel, env, opts);
309 if (!row) {
310 // The head was revoked, expired or future-dated. Retire the last-good
311 // copy so a later outage cannot resurrect it through the stale path.
312 await retireLastGood(env, channel);
313 return { kind: "none" };
314 }
315 envelope = envelopeFromRow(row);
316 } catch {
317 source = "kv-stale";
318 envelope = await kvGet(env, channel);
319 if (!envelope) return { kind: "unavailable", reason: "facts-transport-unavailable" };
320 }
321 const verification = await verifyEnvelope(envelope, keys, { channel, now: (opts.now ?? Date.now)() });
322 if (!verification.ok) {
323 if (source === "kv-stale" || !isEnvelope(envelope)) return { kind: "unavailable", reason: `facts-${verification.reason}` };
324 if (verification.reason === "sha-mismatch") return { kind: "sha-mismatch", channel, factsVersion: envelope.facts_version };
325 return { kind: "unverifiable", reason: verification.reason, channel, factsVersion: envelope.facts_version };
326 }
327 const verified = envelope as CloudFactsEnvelope;
328 const body = JSON.stringify(verified);
329 if (new TextEncoder().encode(body).length > MAX_ENVELOPE_BYTES) return { kind: "unavailable", reason: "facts-too-large" };
330 if (source === "supabase" && env.CURATED_KV) {
331 try { await env.CURATED_KV.put(`${KV_PREFIX}${channel}`, body, { expirationTtl: KV_TTL_SECS }); }
332 catch { /* The last-good cache is best effort. */ }
333 }
334 return { kind: "ok", envelope: verified, body, etag: await etagFor(verified), source, verified: "verified", keyId: verification.keyId };
335 }
336
337 export async function cloudFactsSummary(env: CloudFactsEnv, channel = "stable"): Promise<
338 { channel: string; factsVersion: number; publishedAt: string; keyId: string; source: string } | null
339 > {
340 try {
341 const result = await resolveCloudFacts(channel, env);
342 if (result.kind !== "ok") return null;
343 return { channel: result.envelope.channel, factsVersion: result.envelope.facts_version,
344 publishedAt: result.envelope.published_at, keyId: result.keyId, source: result.source };
345 } catch { return null; }
346 }
347
348 const CACHE_CONTROL = "public, max-age=300, s-maxage=300, stale-while-revalidate=3600, stale-if-error=604800";
349
350 function cacheControl(envelope: CloudFactsEnvelope): string {
351 if (envelope.not_after == null) return CACHE_CONTROL;
352 const seconds = Math.max(0, Math.min(300, Math.floor((utcTime(envelope.not_after)! - Date.now()) / 1000)));
353 // A CDN must not extend a signed deadline through stale serving directives.
354 return `public, max-age=${seconds}, s-maxage=${seconds}, must-revalidate`;
355 }
356
357 function baseHeaders(): Record<string, string> {
358 return {
359 "Content-Type": "application/json; charset=utf-8",
360 "Access-Control-Allow-Origin": "*",
361 "X-Content-Type-Options": "nosniff",
362 };
363 }
364
365 function errorResponse(status: number, body: Record<string, unknown>, method: "GET" | "HEAD", extra: Record<string, string> = {}): Response {
366 return new Response(method === "HEAD" ? null : JSON.stringify(body), {
367 status,
368 headers: { ...baseHeaders(), "Cache-Control": "no-store", ...extra },
369 });
370 }
371
372 function etagMatches(ifNoneMatch: string | null, etag: string): boolean {
373 if (!ifNoneMatch) return false;
374 return ifNoneMatch
375 .split(",")
376 .map((v) => v.trim().replace(/^W\//, ""))
377 .some((v) => v === etag || v === "*");
378 }
379
380 export function responseFor(result: CloudFactsResult, req: Request, channel: string, method: "GET" | "HEAD"): Response {
381 switch (result.kind) {
382 case "none":
383 return errorResponse(404, { error: "no-facts", channel }, method, { "Cache-Control": "public, max-age=60" });
384 case "sha-mismatch":
385 return errorResponse(502, { error: "facts-digest-mismatch", channel, factsVersion: result.factsVersion }, method);
386 case "unverifiable":
387 return errorResponse(503, { error: "facts-unverifiable", reason: result.reason, channel, factsVersion: result.factsVersion }, method, { "Retry-After": "600" });
388 case "unavailable":
389 return errorResponse(503, { error: "facts-unavailable", channel }, method, { "Retry-After": "600" });
390 case "ok": {
391 const headers: Record<string, string> = {
392 ...baseHeaders(),
393 "Cache-Control": cacheControl(result.envelope),
394 ETag: result.etag,
395 "X-Facts-Channel": result.envelope.channel,
396 "X-Facts-Version": String(result.envelope.facts_version),
397 "X-Facts-Source": result.source,
398 "X-Facts-Verified": result.verified,
399 "X-Facts-Key": result.keyId,
400 };
401 if (etagMatches(req.headers.get("if-none-match"), result.etag)) {
402 return new Response(null, { status: 304, headers });
403 }
404 headers["Content-Length"] = String(new TextEncoder().encode(result.body).length);
405 return new Response(method === "HEAD" ? null : result.body, { status: 200, headers });
406 }
407 }
408 }
409
409 lines TYPESCRIPT