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