| 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 |