@astrasyncai/verification-gateway 5.21.1 → 5.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +19 -7
  3. package/dist/adapters/express.js +75 -9
  4. package/dist/adapters/express.js.map +1 -1
  5. package/dist/adapters/express.mjs +75 -9
  6. package/dist/adapters/express.mjs.map +1 -1
  7. package/dist/adapters/mcp.js +34 -3
  8. package/dist/adapters/mcp.js.map +1 -1
  9. package/dist/adapters/mcp.mjs +34 -3
  10. package/dist/adapters/mcp.mjs.map +1 -1
  11. package/dist/adapters/nextjs.js +75 -9
  12. package/dist/adapters/nextjs.js.map +1 -1
  13. package/dist/adapters/nextjs.mjs +75 -9
  14. package/dist/adapters/nextjs.mjs.map +1 -1
  15. package/dist/adapters/sdk.js +1 -1
  16. package/dist/adapters/sdk.js.map +1 -1
  17. package/dist/adapters/sdk.mjs +1 -1
  18. package/dist/adapters/sdk.mjs.map +1 -1
  19. package/dist/agent/index.js +1 -1
  20. package/dist/agent/index.js.map +1 -1
  21. package/dist/agent/index.mjs +1 -1
  22. package/dist/agent/index.mjs.map +1 -1
  23. package/dist/bin/astrasync-claude-hook.js +1 -1
  24. package/dist/bin/astrasync-codex-hook.js +1 -1
  25. package/dist/bin/astrasync-guard.js +1 -1
  26. package/dist/bin/astrasync.js +1 -1
  27. package/dist/browser/background.js +1 -1
  28. package/dist/browser/background.js.map +1 -1
  29. package/dist/browser/background.mjs +1 -1
  30. package/dist/browser/background.mjs.map +1 -1
  31. package/dist/cli/index.js +1 -1
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/index.mjs +1 -1
  34. package/dist/cli/index.mjs.map +1 -1
  35. package/dist/codex/index.js +1 -1
  36. package/dist/codex/index.js.map +1 -1
  37. package/dist/codex/index.mjs +1 -1
  38. package/dist/codex/index.mjs.map +1 -1
  39. package/dist/cursor/extension.js +1 -1
  40. package/dist/cursor/extension.js.map +1 -1
  41. package/dist/cursor/extension.mjs +1 -1
  42. package/dist/cursor/extension.mjs.map +1 -1
  43. package/dist/edge-config.js +1 -1
  44. package/dist/edge-config.js.map +1 -1
  45. package/dist/edge-config.mjs +1 -1
  46. package/dist/edge-config.mjs.map +1 -1
  47. package/dist/edge-core/index.d.mts +4 -1
  48. package/dist/edge-core/index.d.ts +4 -1
  49. package/dist/edge-core/index.js +60 -13
  50. package/dist/edge-core/index.js.map +1 -1
  51. package/dist/edge-core/index.mjs +60 -13
  52. package/dist/edge-core/index.mjs.map +1 -1
  53. package/dist/gateway/gateway.js +1 -1
  54. package/dist/gateway/gateway.js.map +1 -1
  55. package/dist/gateway/gateway.mjs +1 -1
  56. package/dist/gateway/gateway.mjs.map +1 -1
  57. package/dist/index.d.mts +27 -6
  58. package/dist/index.d.ts +27 -6
  59. package/dist/index.js +75 -9
  60. package/dist/index.js.map +1 -1
  61. package/dist/index.mjs +75 -9
  62. package/dist/index.mjs.map +1 -1
  63. package/dist/metadata-capture.d.mts +5 -3
  64. package/dist/metadata-capture.d.ts +5 -3
  65. package/dist/metadata-capture.js +33 -2
  66. package/dist/metadata-capture.js.map +1 -1
  67. package/dist/metadata-capture.mjs +33 -2
  68. package/dist/metadata-capture.mjs.map +1 -1
  69. package/dist/platform-signatures.d.mts +20 -1
  70. package/dist/platform-signatures.d.ts +20 -1
  71. package/dist/platform-signatures.js +41 -6
  72. package/dist/platform-signatures.js.map +1 -1
  73. package/dist/platform-signatures.mjs +41 -6
  74. package/dist/platform-signatures.mjs.map +1 -1
  75. package/dist/registration/index.js +1 -1
  76. package/dist/registration/index.js.map +1 -1
  77. package/dist/registration/index.mjs +1 -1
  78. package/dist/registration/index.mjs.map +1 -1
  79. package/dist/transport/index.js +1 -1
  80. package/dist/transport/index.js.map +1 -1
  81. package/dist/transport/index.mjs +1 -1
  82. package/dist/transport/index.mjs.map +1 -1
  83. package/dist/verify.js +1 -1
  84. package/dist/verify.js.map +1 -1
  85. package/dist/verify.mjs +1 -1
  86. package/dist/verify.mjs.map +1 -1
  87. package/package.json +3 -3
@@ -155,9 +155,11 @@ declare const MAX_CONNECTION_VALUE_CHARS = 256;
155
155
  * `connection` keys the edge adapters populate natively, so SDK-only
156
156
  * deployments still get IP / geo / TLS attribution. Precedence per key:
157
157
  *
158
- * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `true-client-ip` >
159
- * `x-real-ip` > `cloudfront-viewer-address` (port stripped) > leftmost
160
- * `x-forwarded-for`
158
+ * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `incap-client-ip`
159
+ * (Imperva) > `true-client-ip` (Akamai et al.) > `x-real-ip` >
160
+ * `cloudfront-viewer-address` (port stripped) > leftmost `x-forwarded-for`
161
+ * - Akamai `x-akamai-edgescape` fills country / region / city / lat / long /
162
+ * timeZone / asn / continent where no header above supplied them
161
163
  * - `country`: `cf-ipcountry` > `x-vercel-ip-country` >
162
164
  * `cloudfront-viewer-country` > `fastly-geo-country`
163
165
  * - `region` / `city` / `timeZone`: Vercel then CloudFront viewer headers
@@ -155,9 +155,11 @@ declare const MAX_CONNECTION_VALUE_CHARS = 256;
155
155
  * `connection` keys the edge adapters populate natively, so SDK-only
156
156
  * deployments still get IP / geo / TLS attribution. Precedence per key:
157
157
  *
158
- * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `true-client-ip` >
159
- * `x-real-ip` > `cloudfront-viewer-address` (port stripped) > leftmost
160
- * `x-forwarded-for`
158
+ * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `incap-client-ip`
159
+ * (Imperva) > `true-client-ip` (Akamai et al.) > `x-real-ip` >
160
+ * `cloudfront-viewer-address` (port stripped) > leftmost `x-forwarded-for`
161
+ * - Akamai `x-akamai-edgescape` fills country / region / city / lat / long /
162
+ * timeZone / asn / continent where no header above supplied them
161
163
  * - `country`: `cf-ipcountry` > `x-vercel-ip-country` >
162
164
  * `cloudfront-viewer-country` > `fastly-geo-country`
163
165
  * - `region` / `city` / `timeZone`: Vercel then CloudFront viewer headers
@@ -68,7 +68,11 @@ var PLATFORM_HEADER_NAMES = /* @__PURE__ */ new Set([
68
68
  "x-goog-api-client",
69
69
  "user-agent",
70
70
  "x-agent-card-url",
71
- "x-agent-provider"
71
+ "x-agent-provider",
72
+ // Round 31 — Web Bot Auth's Signature-Agent names the signing agent's
73
+ // directory host (a public URL, never a secret). It attributes a signed
74
+ // agent even when its UA is stock Chrome.
75
+ "signature-agent"
72
76
  ]);
73
77
  var PLATFORM_HEADER_PREFIXES = [
74
78
  "x-goog-",
@@ -199,7 +203,12 @@ function extractPlatformHeaders(sanitized) {
199
203
  return out;
200
204
  }
201
205
  var CONNECTION_HEADER_SOURCES = [
202
- ["ip", ["cf-connecting-ip", "fastly-client-ip", "true-client-ip", "x-real-ip"]],
206
+ // Round 31: `incap-client-ip` = Imperva Cloud WAF; `true-client-ip` is
207
+ // also Akamai's client-IP header when the property enables it.
208
+ [
209
+ "ip",
210
+ ["cf-connecting-ip", "fastly-client-ip", "incap-client-ip", "true-client-ip", "x-real-ip"]
211
+ ],
203
212
  [
204
213
  "country",
205
214
  ["cf-ipcountry", "x-vercel-ip-country", "cloudfront-viewer-country", "fastly-geo-country"]
@@ -250,6 +259,28 @@ function deriveConnectionFromHeaders(headers) {
250
259
  if (portMatch) connection.sourcePort = portMatch[1];
251
260
  if (!connection.ip && addr) connection.ip = addr.replace(/:\d+$/, "");
252
261
  }
262
+ {
263
+ const edgescape = (lower["x-akamai-edgescape"] ?? lower["akamai-edgescape"])?.trim();
264
+ if (edgescape) {
265
+ const fields = /* @__PURE__ */ new Map();
266
+ for (const pair of edgescape.split(",")) {
267
+ const i = pair.indexOf("=");
268
+ if (i > 0) fields.set(pair.slice(0, i).trim().toLowerCase(), pair.slice(i + 1).trim());
269
+ }
270
+ const fill = (key, field) => {
271
+ const v = fields.get(field);
272
+ if (v && !connection[key]) connection[key] = v.slice(0, MAX_CONNECTION_VALUE_CHARS);
273
+ };
274
+ fill("country", "country_code");
275
+ fill("region", "region_code");
276
+ fill("city", "city");
277
+ fill("latitude", "lat");
278
+ fill("longitude", "long");
279
+ fill("timeZone", "timezone");
280
+ fill("asn", "asnum");
281
+ fill("continent", "continent");
282
+ }
283
+ }
253
284
  if (!connection.ip) {
254
285
  const xff = lower["x-forwarded-for"];
255
286
  const client = xff?.split(",")[0]?.trim();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/metadata-capture.ts"],"sourcesContent":["/**\n * Maximal metadata capture — the shared, edge-safe sanitiser.\n *\n * AstraSync's thesis is total metadata capture: every header and connection\n * signal an inbound agent presents is a signal we want to keep. This module is\n * the ONE place that decides what is safe to store verbatim, what is a secret\n * to drop, and what credential headers reduce to a safe *format prefix* (a\n * platform signal, never the secret itself).\n *\n * It runs in three places that must agree byte-for-byte:\n * - the edge adapter (`@astrasyncai/adapter-lambda`) — the richest capture\n * point (full CloudFront header arrays),\n * - the SDK adapters (express / nextjs) — for SDK-only merchants with no edge,\n * - (indirectly) the backend, which stores whatever the above forward.\n *\n * Pure — no Node built-ins, no I/O — so it is safe in Lambda@Edge, the browser\n * build, and Deno. Capture is cheap: at the edge and SDK it is local string\n * work, and the backend stores it on a fire-and-forget event insert, so it adds\n * ZERO verify-access decision latency. Detection (which few signals imply which\n * vendor) is a separate, curated concern — see `platform-signatures.ts`.\n */\n\n/**\n * Version of the capture semantics (what is kept, dropped, reduced, or\n * derived — and under which key names).\n *\n * Capture semantics are FROZEN within a package major version: a given\n * `schemaVersion` always means the same field vocabulary and the same\n * keep/drop/reduce rules. The number bumps only on a SEMANTIC change to\n * capture (a renamed key, a changed reduction rule) — never for additive\n * signals, and never merely because the package version moved. To tell\n * builds apart, use the `sdkVersion` already present on the verify body;\n * `schemaVersion` answers the different question \"how do I interpret this\n * stored metadata?\".\n */\nexport const CAPTURE_SCHEMA_VERSION = 1;\n\n/**\n * The captured, sanitised view of an inbound agent's request metadata. Attached\n * to verify-access / beacon payloads as `callerMetadata.observedMetadata`.\n */\nexport interface ObservedMetadata {\n /** Header name → value, secrets removed, verbatim otherwise. */\n headers: Record<string, string>;\n /**\n * Safe key-format prefix of a credential header (e.g. `sk-ant-api03`,\n * `sk-proj`, `AIza`) — a platform signal, NEVER the secret. Absent if no\n * credential header was present or none matched a known format.\n */\n apiKeyFormat?: string;\n /**\n * The subset of `headers` that are known platform-signal headers\n * (`openai-organization`, `x-goog-user-project`, `anthropic-version`, …),\n * pulled out for convenient detection + display. Values are the same\n * sanitised strings as in `headers`.\n */\n platformHeaders?: Record<string, string>;\n /**\n * Connection-layer signals (IP, ASN, country, TLS version, HTTP version,\n * device class, TLS fingerprint). Edge adapters populate this from the\n * platform's native connection surface — the authoritative source. SDK\n * adapters populate it as a fallback via {@link deriveConnectionFromHeaders}\n * when a CDN in front of the merchant injected the equivalent headers;\n * absent when neither source had anything.\n */\n connection?: Record<string, string>;\n /**\n * Number of cookie pairs the caller sent. The `cookie` header VALUE is a\n * secret and is always dropped; the COUNT is a client-shape signal\n * (browsers carry cookies, most automation carries none) that would\n * otherwise be unobservable downstream. Absent when no cookie header\n * arrived.\n */\n cookieCount?: number;\n /**\n * Query-string parameter NAMES (deduped, lowercased) — values are NEVER\n * captured (they routinely carry OAuth codes, tokens, and PII, and no\n * deny-list is complete). Names alone fingerprint client shape. Populated\n * by the edge path, which sees the raw query string.\n */\n queryKeys?: string[];\n /**\n * Query-string VALUES for parameter names the endpoint owner explicitly\n * allow-listed (`EdgeConfig.queryValueAllowlist`, seeded `utm_*` — VI\n * uplift Part 4). The default remains NO value capture; this exists so\n * campaign attribution (utm_source/medium/campaign) can be reported\n * without widening the privacy stance. Bounded 16 entries × 256 chars.\n */\n queryParams?: Record<string, string>;\n /**\n * Version of the capture semantics this object was produced under — see\n * {@link CAPTURE_SCHEMA_VERSION}. Optional for backward compatibility with\n * metadata captured before the field existed.\n */\n schemaVersion?: number;\n}\n\n/**\n * Result of sanitising a raw header map. `headers` is always present (possibly\n * empty); `apiKeyFormat` / `platformHeaders` only when a signal was found.\n */\nexport interface SanitizeHeadersResult {\n headers: Record<string, string>;\n apiKeyFormat?: string;\n platformHeaders?: Record<string, string>;\n /** Cookie-pair count; the cookie value itself is always dropped. */\n cookieCount?: number;\n}\n\n/**\n * Header names dropped entirely — genuine secrets or our own credentials that\n * describe the request's transport, not the agent. Matched case-insensitively\n * on the exact header name.\n */\nconst SECRET_HEADER_NAMES = new Set([\n 'cookie',\n 'set-cookie',\n 'x-csrf-token',\n 'proxy-authorization',\n 'x-astrasync-signature',\n 'x-astrasync-secret',\n 'x-hub-signature',\n 'x-hub-signature-256',\n // Short-lived verify dedupe token — must never persist in stored metadata.\n 'x-astra-verified-hop',\n]);\n\n/**\n * Header-name prefixes dropped entirely — our own credential / signing surface\n * (`x-astrasync-*`, `kya-*`) must never round-trip into stored metadata.\n */\nconst SECRET_HEADER_PREFIXES = ['x-astrasync-', 'kya-', 'kya_'];\n\n/**\n * Credential headers whose VALUE is a secret but whose FORMAT is a signal. We\n * keep the leading format prefix (up to the random tail) and drop the rest.\n */\nconst CREDENTIAL_HEADER_NAMES = new Set([\n 'authorization',\n 'x-api-key',\n 'x-goog-api-key',\n 'api-key',\n]);\n\n/**\n * Header names that carry a raw signature value (drop the value; the presence\n * is not worth a bloat risk). Matched as a suffix so `x-*-signature` variants\n * are covered without an exhaustive list.\n */\nfunction isSignatureHeader(name: string): boolean {\n return name === 'signature' || name.endsWith('-signature') || name.endsWith('-signature-256');\n}\n\n/**\n * Known platform-signal headers — pulled into `platformHeaders` for convenient\n * detection + display. Exact names plus a few prefixes (`x-goog-`,\n * `x-stainless-` — the OpenAI/Anthropic SDK client fingerprint headers).\n */\nconst PLATFORM_HEADER_NAMES = new Set([\n 'openai-organization',\n 'openai-project',\n 'openai-version',\n 'openai-processing-ms',\n 'anthropic-version',\n 'anthropic-beta',\n 'x-goog-user-project',\n 'x-goog-api-client',\n 'user-agent',\n 'x-agent-card-url',\n 'x-agent-provider',\n]);\n// `sec-ch-*` client hints are platform signals: Chromium forks (agentic\n// browsers included) often carry their real brand in `sec-ch-ua` even when\n// the UA string mimics stock Chrome.\nconst PLATFORM_HEADER_PREFIXES = [\n 'x-goog-',\n 'x-stainless-',\n 'x-anthropic-',\n 'x-openai-',\n 'sec-ch-',\n];\n\n/**\n * Known API-key format prefixes, longest-first so `sk-ant-api03` wins over\n * `sk-ant` and `sk-`. Each is the SAFE, non-secret leading token of a\n * credential; the random tail is never captured.\n */\nconst KEY_FORMAT_PREFIXES = [\n 'sk-ant-api03',\n 'sk-ant-api',\n 'sk-ant',\n 'sk-proj',\n 'sk-svcacct',\n 'sk-or-v1',\n 'sk-or',\n 'sk-lf',\n 'sk-',\n 'gsk_',\n 'aiza',\n 'ya29',\n 'ghp_',\n 'gho_',\n 'github_pat_',\n 'xai-',\n 'pplx-',\n 'r8_',\n 'hf_',\n 'astra-',\n 'astrae-',\n];\n\n/** Size caps — the only guard against jsonb bloat from a hostile POST. */\nexport const MAX_HEADERS = 64;\nexport const MAX_HEADER_VALUE_BYTES = 1024;\nexport const MAX_TOTAL_BYTES = 16 * 1024;\n\n/**\n * `X-Astra-Source` declared-source protocol (VI uplift Part 2). The header is\n * SELF-ASSERTED and spoofable — it buys attribution, never trust: it is never\n * read by any access decision and never conflated with fingerprint-matched\n * (`platformVendor`) or ASTRA-verified evidence. One shared validator so the\n * edge, backend, and bridge accept byte-identical values.\n */\nexport const DECLARED_SOURCE_MAX = 64;\nconst DECLARED_SOURCE_PATTERN = /^[a-z0-9][a-z0-9 ._/-]{0,63}$/;\n\nexport function normalizeDeclaredSource(value: string | undefined | null): string | undefined {\n const trimmed = value?.trim().slice(0, DECLARED_SOURCE_MAX).toLowerCase();\n return trimmed && DECLARED_SOURCE_PATTERN.test(trimmed) ? trimmed : undefined;\n}\n\nfunction byteLength(s: string): number {\n // Edge-safe (no Buffer): count UTF-8 bytes without allocating a Buffer.\n let bytes = 0;\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code < 0x80) bytes += 1;\n else if (code < 0x800) bytes += 2;\n else if (code >= 0xd800 && code <= 0xdbff) {\n bytes += 4;\n i++; // surrogate pair\n } else bytes += 3;\n }\n return bytes;\n}\n\n/**\n * Extract the safe key-format prefix from a credential header value. Strips a\n * leading `Bearer ` / `Basic ` scheme, then returns the longest known prefix\n * that the (lowercased) value starts with. Returns undefined for opaque values\n * (e.g. a bare UUID token) so we never guess a format we don't recognise.\n */\nexport function extractApiKeyFormat(rawValue: string): string | undefined {\n if (!rawValue) return undefined;\n let value = rawValue.trim();\n const spaceIdx = value.indexOf(' ');\n if (spaceIdx > 0) {\n const scheme = value.slice(0, spaceIdx).toLowerCase();\n if (scheme === 'bearer' || scheme === 'basic' || scheme === 'token') {\n value = value.slice(spaceIdx + 1).trim();\n }\n }\n const lower = value.toLowerCase();\n for (const prefix of KEY_FORMAT_PREFIXES) {\n if (lower.startsWith(prefix)) {\n // Return the prefix as-declared in the list (canonical casing).\n return prefix;\n }\n }\n return undefined;\n}\n\nfunction isSecretHeader(name: string): boolean {\n if (SECRET_HEADER_NAMES.has(name)) return true;\n if (SECRET_HEADER_PREFIXES.some((p) => name.startsWith(p))) return true;\n if (isSignatureHeader(name)) return true;\n return false;\n}\n\nfunction isPlatformHeader(name: string): boolean {\n if (PLATFORM_HEADER_NAMES.has(name)) return true;\n return PLATFORM_HEADER_PREFIXES.some((p) => name.startsWith(p));\n}\n\n/**\n * Sanitise a raw header map for storage. Deny-lists genuine secrets, reduces\n * credential headers to a safe format prefix, keeps everything else verbatim,\n * and enforces the size caps that bound jsonb growth.\n *\n * Accepts any string→(string|string[]|undefined) map (Node's `req.headers`,\n * CloudFront's flattened headers, a plain object). Multi-value headers are\n * joined with `, ` per RFC 7230.\n */\nexport function sanitizeHeaders(\n raw: Record<string, string | string[] | undefined> | undefined | null\n): SanitizeHeadersResult {\n const headers: Record<string, string> = {};\n const platformHeaders: Record<string, string> = {};\n let apiKeyFormat: string | undefined;\n let cookieCount: number | undefined;\n let totalBytes = 0;\n let count = 0;\n\n if (!raw) return { headers };\n\n for (const rawName of Object.keys(raw)) {\n if (count >= MAX_HEADERS) break;\n const name = rawName.toLowerCase();\n const rawValue = raw[rawName];\n if (rawValue === undefined) continue;\n const joined = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);\n\n // Credential headers: capture the format prefix, drop the secret.\n if (CREDENTIAL_HEADER_NAMES.has(name)) {\n const fmt = extractApiKeyFormat(joined);\n if (fmt && !apiKeyFormat) apiKeyFormat = fmt;\n continue;\n }\n\n // Cookie: the value is a secret (dropped below), but the PAIR COUNT is a\n // client-shape signal worth keeping.\n if (name === 'cookie') {\n cookieCount = joined.split(';').filter((s) => s.trim().length > 0).length;\n }\n\n // Genuine secrets: drop entirely.\n if (isSecretHeader(name)) continue;\n\n // Everything else describing the agent: keep, bounded.\n let value = joined;\n if (byteLength(value) > MAX_HEADER_VALUE_BYTES) {\n value = value.slice(0, MAX_HEADER_VALUE_BYTES);\n }\n const entryBytes = byteLength(name) + byteLength(value);\n if (totalBytes + entryBytes > MAX_TOTAL_BYTES) continue;\n totalBytes += entryBytes;\n count++;\n headers[name] = value;\n if (isPlatformHeader(name)) platformHeaders[name] = value;\n }\n\n const result: SanitizeHeadersResult = { headers };\n if (apiKeyFormat) result.apiKeyFormat = apiKeyFormat;\n if (Object.keys(platformHeaders).length > 0) result.platformHeaders = platformHeaders;\n if (cookieCount !== undefined) result.cookieCount = cookieCount;\n return result;\n}\n\n/**\n * Pull the known platform-signal headers out of an ALREADY-sanitised header\n * map. `sanitizeHeaders` already computes this as a by-product; this standalone\n * form is exported for call-sites (edge, tests) that hold a sanitised map and\n * want only the platform subset. Never re-run over raw (unsanitised) headers.\n */\nexport function extractPlatformHeaders(\n sanitized: Record<string, string> | undefined | null\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!sanitized) return out;\n for (const name of Object.keys(sanitized)) {\n if (isPlatformHeader(name.toLowerCase())) out[name.toLowerCase()] = sanitized[name];\n }\n return out;\n}\n\n/**\n * Precedence-ordered CDN header sources for each derived `connection` key.\n * First present header wins; the special `x-forwarded-for` source takes the\n * LEFTMOST (client) entry of the comma-separated chain.\n */\nconst CONNECTION_HEADER_SOURCES: ReadonlyArray<readonly [key: string, headers: readonly string[]]> =\n [\n ['ip', ['cf-connecting-ip', 'fastly-client-ip', 'true-client-ip', 'x-real-ip']],\n [\n 'country',\n ['cf-ipcountry', 'x-vercel-ip-country', 'cloudfront-viewer-country', 'fastly-geo-country'],\n ],\n ['region', ['x-vercel-ip-country-region', 'cloudfront-viewer-country-region']],\n ['countryRegionName', ['cloudfront-viewer-country-region-name']],\n ['city', ['x-vercel-ip-city', 'cloudfront-viewer-city']],\n ['latitude', ['x-vercel-ip-latitude', 'cloudfront-viewer-latitude']],\n ['longitude', ['x-vercel-ip-longitude', 'cloudfront-viewer-longitude']],\n ['postalCode', ['x-vercel-ip-postal-code', 'cloudfront-viewer-postal-code']],\n ['asn', ['cloudfront-viewer-asn']],\n ['tlsVersion', ['cloudfront-viewer-tls']],\n ['httpVersion', ['cloudfront-viewer-http-version']],\n ['timeZone', ['x-vercel-ip-timezone', 'cloudfront-viewer-time-zone']],\n ['metroCode', ['cloudfront-viewer-metro-code']],\n // TLS fingerprints can only be computed where TLS terminates. When a CDN\n // terminates TLS in front of the merchant, its fingerprint headers ARE the\n // connection-layer truth — normalising them here is the permanent design.\n // CloudFront delivers JA3/JA4 only via an origin request policy and only\n // for HTTPS viewer connections.\n ['tlsFingerprint', ['cf-ja3-hash', 'cloudfront-viewer-ja3-fingerprint']],\n ['ja4', ['cf-ja4', 'cloudfront-viewer-ja4-fingerprint']],\n // Header-structure fingerprint: browser/SDK-distinctive header ordering.\n ['headerOrder', ['cloudfront-viewer-header-order']],\n ['headerCount', ['cloudfront-viewer-header-count']],\n ];\n\n/**\n * Backend bound: observedMetadata.connection values longer than this fail\n * the platform's zod parse outright (boundedStringRecord(32, 256)), so every\n * emitter truncates — headerOrder routinely exceeds it.\n */\nexport const MAX_CONNECTION_VALUE_CHARS = 256;\n\n/**\n * Derive the connection-layer block from CDN-injected request headers.\n *\n * When a merchant's SDK-gated origin sits behind a CDN (Cloudflare, Fastly,\n * Vercel, CloudFront), the CDN has already seen the connection layer and\n * injected it as headers. This promotes those headers to the same\n * `connection` keys the edge adapters populate natively, so SDK-only\n * deployments still get IP / geo / TLS attribution. Precedence per key:\n *\n * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `true-client-ip` >\n * `x-real-ip` > `cloudfront-viewer-address` (port stripped) > leftmost\n * `x-forwarded-for`\n * - `country`: `cf-ipcountry` > `x-vercel-ip-country` >\n * `cloudfront-viewer-country` > `fastly-geo-country`\n * - `region` / `city` / `timeZone`: Vercel then CloudFront viewer headers\n * - `countryRegionName` / `asn` / `tlsVersion` / `httpVersion` /\n * `metroCode` / `headerOrder` / `headerCount`: `cloudfront-viewer-*`\n * - `tlsFingerprint`: `cf-ja3-hash` > `cloudfront-viewer-ja3-fingerprint`\n * - `ja4`: `cf-ja4` > `cloudfront-viewer-ja4-fingerprint`\n *\n * All keys are additive under `schemaVersion` 1 (the frozen-capture doctrine\n * bumps only for changed semantics, never for additive signals). Values are\n * truncated to MAX_CONNECTION_VALUE_CHARS — the platform's hard bound.\n *\n * Edge adapters keep their richer native collectors — native platform\n * signals are authoritative; this header derivation is the SDK-level\n * fallback. Header names are matched case-insensitively. Returns undefined\n * when no source header is present, so the `connection` key is simply\n * omitted (matching edge behaviour).\n */\nexport function deriveConnectionFromHeaders(\n headers: Record<string, string> | undefined | null\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n const lower: Record<string, string> = {};\n for (const name of Object.keys(headers)) {\n const value = headers[name];\n if (typeof value === 'string' && value.length > 0) lower[name.toLowerCase()] = value;\n }\n\n const connection: Record<string, string> = {};\n for (const [key, sources] of CONNECTION_HEADER_SOURCES) {\n for (const source of sources) {\n const value = lower[source]?.trim();\n if (value) {\n connection[key] = value.slice(0, MAX_CONNECTION_VALUE_CHARS);\n break;\n }\n }\n }\n // ip fallback 1: cloudfront-viewer-address arrives as ip:port — strip the\n // trailing port only (IPv6-safe), unlike the dedicated client-ip headers.\n // The port itself is kept as its own key: ephemeral-port patterns signal\n // NAT/proxy connection reuse.\n {\n const addr = lower['cloudfront-viewer-address']?.trim();\n const portMatch = addr?.match(/:(\\d+)$/);\n if (portMatch) connection.sourcePort = portMatch[1];\n if (!connection.ip && addr) connection.ip = addr.replace(/:\\d+$/, '');\n }\n // ip fallback 2: leftmost (client) hop of the x-forwarded-for chain.\n if (!connection.ip) {\n const xff = lower['x-forwarded-for'];\n const client = xff?.split(',')[0]?.trim();\n if (client) connection.ip = client;\n }\n // tlsCipher: CloudFront folds the cipher into the viewer-tls string\n // (`TLSv1.3:TLS_AES_128_GCM_SHA256:fullHandshake`). `tlsVersion` stays\n // verbatim (reducing it would be a semantic change → schema v2); the\n // cipher is pulled out additively so it is queryable on its own.\n if (!connection.tlsCipher) {\n const cipher = connection.tlsVersion?.split(':')[1];\n if (cipher) connection.tlsCipher = cipher;\n }\n\n return Object.keys(connection).length > 0 ? connection : undefined;\n}\n\n/**\n * The FULL metadata capture for one request at an SDK adapter (express /\n * nextjs / mcp): sanitised headers, the safe credential format prefix, the\n * platform-signal subset, the CDN-derived connection block, and the capture\n * schema version. This is the ONE builder all SDK adapters share, so their\n * verify-access bodies agree byte-for-byte.\n *\n * Connection derivation runs over the RAW header map (pre-cap) — the size\n * caps guard storage growth and must never hide a geo/IP header that\n * happened to arrive late in an oversized map.\n */\nexport function buildSdkObservedMetadata(\n rawHeaders: Record<string, string | string[] | undefined> | undefined | null\n): ObservedMetadata {\n const { headers, apiKeyFormat, platformHeaders, cookieCount } = sanitizeHeaders(rawHeaders);\n const flat: Record<string, string> = {};\n if (rawHeaders) {\n for (const name of Object.keys(rawHeaders)) {\n const value = rawHeaders[name];\n if (value === undefined) continue;\n flat[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value);\n }\n }\n const connection = deriveConnectionFromHeaders(flat);\n return {\n headers,\n ...(apiKeyFormat && { apiKeyFormat }),\n ...(platformHeaders && { platformHeaders }),\n ...(connection && { connection }),\n ...(cookieCount !== undefined && { cookieCount }),\n schemaVersion: CAPTURE_SCHEMA_VERSION,\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/metadata-capture.ts"],"sourcesContent":["/**\n * Maximal metadata capture — the shared, edge-safe sanitiser.\n *\n * AstraSync's thesis is total metadata capture: every header and connection\n * signal an inbound agent presents is a signal we want to keep. This module is\n * the ONE place that decides what is safe to store verbatim, what is a secret\n * to drop, and what credential headers reduce to a safe *format prefix* (a\n * platform signal, never the secret itself).\n *\n * It runs in three places that must agree byte-for-byte:\n * - the edge adapter (`@astrasyncai/adapter-lambda`) — the richest capture\n * point (full CloudFront header arrays),\n * - the SDK adapters (express / nextjs) — for SDK-only merchants with no edge,\n * - (indirectly) the backend, which stores whatever the above forward.\n *\n * Pure — no Node built-ins, no I/O — so it is safe in Lambda@Edge, the browser\n * build, and Deno. Capture is cheap: at the edge and SDK it is local string\n * work, and the backend stores it on a fire-and-forget event insert, so it adds\n * ZERO verify-access decision latency. Detection (which few signals imply which\n * vendor) is a separate, curated concern — see `platform-signatures.ts`.\n */\n\n/**\n * Version of the capture semantics (what is kept, dropped, reduced, or\n * derived — and under which key names).\n *\n * Capture semantics are FROZEN within a package major version: a given\n * `schemaVersion` always means the same field vocabulary and the same\n * keep/drop/reduce rules. The number bumps only on a SEMANTIC change to\n * capture (a renamed key, a changed reduction rule) — never for additive\n * signals, and never merely because the package version moved. To tell\n * builds apart, use the `sdkVersion` already present on the verify body;\n * `schemaVersion` answers the different question \"how do I interpret this\n * stored metadata?\".\n */\nexport const CAPTURE_SCHEMA_VERSION = 1;\n\n/**\n * The captured, sanitised view of an inbound agent's request metadata. Attached\n * to verify-access / beacon payloads as `callerMetadata.observedMetadata`.\n */\nexport interface ObservedMetadata {\n /** Header name → value, secrets removed, verbatim otherwise. */\n headers: Record<string, string>;\n /**\n * Safe key-format prefix of a credential header (e.g. `sk-ant-api03`,\n * `sk-proj`, `AIza`) — a platform signal, NEVER the secret. Absent if no\n * credential header was present or none matched a known format.\n */\n apiKeyFormat?: string;\n /**\n * The subset of `headers` that are known platform-signal headers\n * (`openai-organization`, `x-goog-user-project`, `anthropic-version`, …),\n * pulled out for convenient detection + display. Values are the same\n * sanitised strings as in `headers`.\n */\n platformHeaders?: Record<string, string>;\n /**\n * Connection-layer signals (IP, ASN, country, TLS version, HTTP version,\n * device class, TLS fingerprint). Edge adapters populate this from the\n * platform's native connection surface — the authoritative source. SDK\n * adapters populate it as a fallback via {@link deriveConnectionFromHeaders}\n * when a CDN in front of the merchant injected the equivalent headers;\n * absent when neither source had anything.\n */\n connection?: Record<string, string>;\n /**\n * Number of cookie pairs the caller sent. The `cookie` header VALUE is a\n * secret and is always dropped; the COUNT is a client-shape signal\n * (browsers carry cookies, most automation carries none) that would\n * otherwise be unobservable downstream. Absent when no cookie header\n * arrived.\n */\n cookieCount?: number;\n /**\n * Query-string parameter NAMES (deduped, lowercased) — values are NEVER\n * captured (they routinely carry OAuth codes, tokens, and PII, and no\n * deny-list is complete). Names alone fingerprint client shape. Populated\n * by the edge path, which sees the raw query string.\n */\n queryKeys?: string[];\n /**\n * Query-string VALUES for parameter names the endpoint owner explicitly\n * allow-listed (`EdgeConfig.queryValueAllowlist`, seeded `utm_*` — VI\n * uplift Part 4). The default remains NO value capture; this exists so\n * campaign attribution (utm_source/medium/campaign) can be reported\n * without widening the privacy stance. Bounded 16 entries × 256 chars.\n */\n queryParams?: Record<string, string>;\n /**\n * Version of the capture semantics this object was produced under — see\n * {@link CAPTURE_SCHEMA_VERSION}. Optional for backward compatibility with\n * metadata captured before the field existed.\n */\n schemaVersion?: number;\n}\n\n/**\n * Result of sanitising a raw header map. `headers` is always present (possibly\n * empty); `apiKeyFormat` / `platformHeaders` only when a signal was found.\n */\nexport interface SanitizeHeadersResult {\n headers: Record<string, string>;\n apiKeyFormat?: string;\n platformHeaders?: Record<string, string>;\n /** Cookie-pair count; the cookie value itself is always dropped. */\n cookieCount?: number;\n}\n\n/**\n * Header names dropped entirely — genuine secrets or our own credentials that\n * describe the request's transport, not the agent. Matched case-insensitively\n * on the exact header name.\n */\nconst SECRET_HEADER_NAMES = new Set([\n 'cookie',\n 'set-cookie',\n 'x-csrf-token',\n 'proxy-authorization',\n 'x-astrasync-signature',\n 'x-astrasync-secret',\n 'x-hub-signature',\n 'x-hub-signature-256',\n // Short-lived verify dedupe token — must never persist in stored metadata.\n 'x-astra-verified-hop',\n]);\n\n/**\n * Header-name prefixes dropped entirely — our own credential / signing surface\n * (`x-astrasync-*`, `kya-*`) must never round-trip into stored metadata.\n */\nconst SECRET_HEADER_PREFIXES = ['x-astrasync-', 'kya-', 'kya_'];\n\n/**\n * Credential headers whose VALUE is a secret but whose FORMAT is a signal. We\n * keep the leading format prefix (up to the random tail) and drop the rest.\n */\nconst CREDENTIAL_HEADER_NAMES = new Set([\n 'authorization',\n 'x-api-key',\n 'x-goog-api-key',\n 'api-key',\n]);\n\n/**\n * Header names that carry a raw signature value (drop the value; the presence\n * is not worth a bloat risk). Matched as a suffix so `x-*-signature` variants\n * are covered without an exhaustive list.\n */\nfunction isSignatureHeader(name: string): boolean {\n return name === 'signature' || name.endsWith('-signature') || name.endsWith('-signature-256');\n}\n\n/**\n * Known platform-signal headers — pulled into `platformHeaders` for convenient\n * detection + display. Exact names plus a few prefixes (`x-goog-`,\n * `x-stainless-` — the OpenAI/Anthropic SDK client fingerprint headers).\n */\nconst PLATFORM_HEADER_NAMES = new Set([\n 'openai-organization',\n 'openai-project',\n 'openai-version',\n 'openai-processing-ms',\n 'anthropic-version',\n 'anthropic-beta',\n 'x-goog-user-project',\n 'x-goog-api-client',\n 'user-agent',\n 'x-agent-card-url',\n 'x-agent-provider',\n // Round 31 — Web Bot Auth's Signature-Agent names the signing agent's\n // directory host (a public URL, never a secret). It attributes a signed\n // agent even when its UA is stock Chrome.\n 'signature-agent',\n]);\n// `sec-ch-*` client hints are platform signals: Chromium forks (agentic\n// browsers included) often carry their real brand in `sec-ch-ua` even when\n// the UA string mimics stock Chrome.\nconst PLATFORM_HEADER_PREFIXES = [\n 'x-goog-',\n 'x-stainless-',\n 'x-anthropic-',\n 'x-openai-',\n 'sec-ch-',\n];\n\n/**\n * Known API-key format prefixes, longest-first so `sk-ant-api03` wins over\n * `sk-ant` and `sk-`. Each is the SAFE, non-secret leading token of a\n * credential; the random tail is never captured.\n */\nconst KEY_FORMAT_PREFIXES = [\n 'sk-ant-api03',\n 'sk-ant-api',\n 'sk-ant',\n 'sk-proj',\n 'sk-svcacct',\n 'sk-or-v1',\n 'sk-or',\n 'sk-lf',\n 'sk-',\n 'gsk_',\n 'aiza',\n 'ya29',\n 'ghp_',\n 'gho_',\n 'github_pat_',\n 'xai-',\n 'pplx-',\n 'r8_',\n 'hf_',\n 'astra-',\n 'astrae-',\n];\n\n/** Size caps — the only guard against jsonb bloat from a hostile POST. */\nexport const MAX_HEADERS = 64;\nexport const MAX_HEADER_VALUE_BYTES = 1024;\nexport const MAX_TOTAL_BYTES = 16 * 1024;\n\n/**\n * `X-Astra-Source` declared-source protocol (VI uplift Part 2). The header is\n * SELF-ASSERTED and spoofable — it buys attribution, never trust: it is never\n * read by any access decision and never conflated with fingerprint-matched\n * (`platformVendor`) or ASTRA-verified evidence. One shared validator so the\n * edge, backend, and bridge accept byte-identical values.\n */\nexport const DECLARED_SOURCE_MAX = 64;\nconst DECLARED_SOURCE_PATTERN = /^[a-z0-9][a-z0-9 ._/-]{0,63}$/;\n\nexport function normalizeDeclaredSource(value: string | undefined | null): string | undefined {\n const trimmed = value?.trim().slice(0, DECLARED_SOURCE_MAX).toLowerCase();\n return trimmed && DECLARED_SOURCE_PATTERN.test(trimmed) ? trimmed : undefined;\n}\n\nfunction byteLength(s: string): number {\n // Edge-safe (no Buffer): count UTF-8 bytes without allocating a Buffer.\n let bytes = 0;\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code < 0x80) bytes += 1;\n else if (code < 0x800) bytes += 2;\n else if (code >= 0xd800 && code <= 0xdbff) {\n bytes += 4;\n i++; // surrogate pair\n } else bytes += 3;\n }\n return bytes;\n}\n\n/**\n * Extract the safe key-format prefix from a credential header value. Strips a\n * leading `Bearer ` / `Basic ` scheme, then returns the longest known prefix\n * that the (lowercased) value starts with. Returns undefined for opaque values\n * (e.g. a bare UUID token) so we never guess a format we don't recognise.\n */\nexport function extractApiKeyFormat(rawValue: string): string | undefined {\n if (!rawValue) return undefined;\n let value = rawValue.trim();\n const spaceIdx = value.indexOf(' ');\n if (spaceIdx > 0) {\n const scheme = value.slice(0, spaceIdx).toLowerCase();\n if (scheme === 'bearer' || scheme === 'basic' || scheme === 'token') {\n value = value.slice(spaceIdx + 1).trim();\n }\n }\n const lower = value.toLowerCase();\n for (const prefix of KEY_FORMAT_PREFIXES) {\n if (lower.startsWith(prefix)) {\n // Return the prefix as-declared in the list (canonical casing).\n return prefix;\n }\n }\n return undefined;\n}\n\nfunction isSecretHeader(name: string): boolean {\n if (SECRET_HEADER_NAMES.has(name)) return true;\n if (SECRET_HEADER_PREFIXES.some((p) => name.startsWith(p))) return true;\n if (isSignatureHeader(name)) return true;\n return false;\n}\n\nfunction isPlatformHeader(name: string): boolean {\n if (PLATFORM_HEADER_NAMES.has(name)) return true;\n return PLATFORM_HEADER_PREFIXES.some((p) => name.startsWith(p));\n}\n\n/**\n * Sanitise a raw header map for storage. Deny-lists genuine secrets, reduces\n * credential headers to a safe format prefix, keeps everything else verbatim,\n * and enforces the size caps that bound jsonb growth.\n *\n * Accepts any string→(string|string[]|undefined) map (Node's `req.headers`,\n * CloudFront's flattened headers, a plain object). Multi-value headers are\n * joined with `, ` per RFC 7230.\n */\nexport function sanitizeHeaders(\n raw: Record<string, string | string[] | undefined> | undefined | null\n): SanitizeHeadersResult {\n const headers: Record<string, string> = {};\n const platformHeaders: Record<string, string> = {};\n let apiKeyFormat: string | undefined;\n let cookieCount: number | undefined;\n let totalBytes = 0;\n let count = 0;\n\n if (!raw) return { headers };\n\n for (const rawName of Object.keys(raw)) {\n if (count >= MAX_HEADERS) break;\n const name = rawName.toLowerCase();\n const rawValue = raw[rawName];\n if (rawValue === undefined) continue;\n const joined = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);\n\n // Credential headers: capture the format prefix, drop the secret.\n if (CREDENTIAL_HEADER_NAMES.has(name)) {\n const fmt = extractApiKeyFormat(joined);\n if (fmt && !apiKeyFormat) apiKeyFormat = fmt;\n continue;\n }\n\n // Cookie: the value is a secret (dropped below), but the PAIR COUNT is a\n // client-shape signal worth keeping.\n if (name === 'cookie') {\n cookieCount = joined.split(';').filter((s) => s.trim().length > 0).length;\n }\n\n // Genuine secrets: drop entirely.\n if (isSecretHeader(name)) continue;\n\n // Everything else describing the agent: keep, bounded.\n let value = joined;\n if (byteLength(value) > MAX_HEADER_VALUE_BYTES) {\n value = value.slice(0, MAX_HEADER_VALUE_BYTES);\n }\n const entryBytes = byteLength(name) + byteLength(value);\n if (totalBytes + entryBytes > MAX_TOTAL_BYTES) continue;\n totalBytes += entryBytes;\n count++;\n headers[name] = value;\n if (isPlatformHeader(name)) platformHeaders[name] = value;\n }\n\n const result: SanitizeHeadersResult = { headers };\n if (apiKeyFormat) result.apiKeyFormat = apiKeyFormat;\n if (Object.keys(platformHeaders).length > 0) result.platformHeaders = platformHeaders;\n if (cookieCount !== undefined) result.cookieCount = cookieCount;\n return result;\n}\n\n/**\n * Pull the known platform-signal headers out of an ALREADY-sanitised header\n * map. `sanitizeHeaders` already computes this as a by-product; this standalone\n * form is exported for call-sites (edge, tests) that hold a sanitised map and\n * want only the platform subset. Never re-run over raw (unsanitised) headers.\n */\nexport function extractPlatformHeaders(\n sanitized: Record<string, string> | undefined | null\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!sanitized) return out;\n for (const name of Object.keys(sanitized)) {\n if (isPlatformHeader(name.toLowerCase())) out[name.toLowerCase()] = sanitized[name];\n }\n return out;\n}\n\n/**\n * Precedence-ordered CDN header sources for each derived `connection` key.\n * First present header wins; the special `x-forwarded-for` source takes the\n * LEFTMOST (client) entry of the comma-separated chain.\n */\nconst CONNECTION_HEADER_SOURCES: ReadonlyArray<readonly [key: string, headers: readonly string[]]> =\n [\n // Round 31: `incap-client-ip` = Imperva Cloud WAF; `true-client-ip` is\n // also Akamai's client-IP header when the property enables it.\n [\n 'ip',\n ['cf-connecting-ip', 'fastly-client-ip', 'incap-client-ip', 'true-client-ip', 'x-real-ip'],\n ],\n [\n 'country',\n ['cf-ipcountry', 'x-vercel-ip-country', 'cloudfront-viewer-country', 'fastly-geo-country'],\n ],\n ['region', ['x-vercel-ip-country-region', 'cloudfront-viewer-country-region']],\n ['countryRegionName', ['cloudfront-viewer-country-region-name']],\n ['city', ['x-vercel-ip-city', 'cloudfront-viewer-city']],\n ['latitude', ['x-vercel-ip-latitude', 'cloudfront-viewer-latitude']],\n ['longitude', ['x-vercel-ip-longitude', 'cloudfront-viewer-longitude']],\n ['postalCode', ['x-vercel-ip-postal-code', 'cloudfront-viewer-postal-code']],\n ['asn', ['cloudfront-viewer-asn']],\n ['tlsVersion', ['cloudfront-viewer-tls']],\n ['httpVersion', ['cloudfront-viewer-http-version']],\n ['timeZone', ['x-vercel-ip-timezone', 'cloudfront-viewer-time-zone']],\n ['metroCode', ['cloudfront-viewer-metro-code']],\n // TLS fingerprints can only be computed where TLS terminates. When a CDN\n // terminates TLS in front of the merchant, its fingerprint headers ARE the\n // connection-layer truth — normalising them here is the permanent design.\n // CloudFront delivers JA3/JA4 only via an origin request policy and only\n // for HTTPS viewer connections.\n ['tlsFingerprint', ['cf-ja3-hash', 'cloudfront-viewer-ja3-fingerprint']],\n ['ja4', ['cf-ja4', 'cloudfront-viewer-ja4-fingerprint']],\n // Header-structure fingerprint: browser/SDK-distinctive header ordering.\n ['headerOrder', ['cloudfront-viewer-header-order']],\n ['headerCount', ['cloudfront-viewer-header-count']],\n ];\n\n/**\n * Backend bound: observedMetadata.connection values longer than this fail\n * the platform's zod parse outright (boundedStringRecord(32, 256)), so every\n * emitter truncates — headerOrder routinely exceeds it.\n */\nexport const MAX_CONNECTION_VALUE_CHARS = 256;\n\n/**\n * Derive the connection-layer block from CDN-injected request headers.\n *\n * When a merchant's SDK-gated origin sits behind a CDN (Cloudflare, Fastly,\n * Vercel, CloudFront), the CDN has already seen the connection layer and\n * injected it as headers. This promotes those headers to the same\n * `connection` keys the edge adapters populate natively, so SDK-only\n * deployments still get IP / geo / TLS attribution. Precedence per key:\n *\n * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `incap-client-ip`\n * (Imperva) > `true-client-ip` (Akamai et al.) > `x-real-ip` >\n * `cloudfront-viewer-address` (port stripped) > leftmost `x-forwarded-for`\n * - Akamai `x-akamai-edgescape` fills country / region / city / lat / long /\n * timeZone / asn / continent where no header above supplied them\n * - `country`: `cf-ipcountry` > `x-vercel-ip-country` >\n * `cloudfront-viewer-country` > `fastly-geo-country`\n * - `region` / `city` / `timeZone`: Vercel then CloudFront viewer headers\n * - `countryRegionName` / `asn` / `tlsVersion` / `httpVersion` /\n * `metroCode` / `headerOrder` / `headerCount`: `cloudfront-viewer-*`\n * - `tlsFingerprint`: `cf-ja3-hash` > `cloudfront-viewer-ja3-fingerprint`\n * - `ja4`: `cf-ja4` > `cloudfront-viewer-ja4-fingerprint`\n *\n * All keys are additive under `schemaVersion` 1 (the frozen-capture doctrine\n * bumps only for changed semantics, never for additive signals). Values are\n * truncated to MAX_CONNECTION_VALUE_CHARS — the platform's hard bound.\n *\n * Edge adapters keep their richer native collectors — native platform\n * signals are authoritative; this header derivation is the SDK-level\n * fallback. Header names are matched case-insensitively. Returns undefined\n * when no source header is present, so the `connection` key is simply\n * omitted (matching edge behaviour).\n */\nexport function deriveConnectionFromHeaders(\n headers: Record<string, string> | undefined | null\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n const lower: Record<string, string> = {};\n for (const name of Object.keys(headers)) {\n const value = headers[name];\n if (typeof value === 'string' && value.length > 0) lower[name.toLowerCase()] = value;\n }\n\n const connection: Record<string, string> = {};\n for (const [key, sources] of CONNECTION_HEADER_SOURCES) {\n for (const source of sources) {\n const value = lower[source]?.trim();\n if (value) {\n connection[key] = value.slice(0, MAX_CONNECTION_VALUE_CHARS);\n break;\n }\n }\n }\n // ip fallback 1: cloudfront-viewer-address arrives as ip:port — strip the\n // trailing port only (IPv6-safe), unlike the dedicated client-ip headers.\n // The port itself is kept as its own key: ephemeral-port patterns signal\n // NAT/proxy connection reuse.\n {\n const addr = lower['cloudfront-viewer-address']?.trim();\n const portMatch = addr?.match(/:(\\d+)$/);\n if (portMatch) connection.sourcePort = portMatch[1];\n if (!connection.ip && addr) connection.ip = addr.replace(/:\\d+$/, '');\n }\n // Round 31 — Akamai EdgeScape (`X-Akamai-Edgescape: country_code=US,\n // region_code=CA,city=SANJOSE,lat=…,long=…,timezone=PST,asnum=7922,…`),\n // added by the property's EdgeScape header behaviour. Fills gaps only.\n {\n const edgescape = (lower['x-akamai-edgescape'] ?? lower['akamai-edgescape'])?.trim();\n if (edgescape) {\n const fields = new Map<string, string>();\n for (const pair of edgescape.split(',')) {\n const i = pair.indexOf('=');\n if (i > 0) fields.set(pair.slice(0, i).trim().toLowerCase(), pair.slice(i + 1).trim());\n }\n const fill = (key: string, field: string) => {\n const v = fields.get(field);\n if (v && !connection[key]) connection[key] = v.slice(0, MAX_CONNECTION_VALUE_CHARS);\n };\n fill('country', 'country_code');\n fill('region', 'region_code');\n fill('city', 'city');\n fill('latitude', 'lat');\n fill('longitude', 'long');\n fill('timeZone', 'timezone');\n fill('asn', 'asnum');\n fill('continent', 'continent');\n }\n }\n // ip fallback 2: leftmost (client) hop of the x-forwarded-for chain.\n if (!connection.ip) {\n const xff = lower['x-forwarded-for'];\n const client = xff?.split(',')[0]?.trim();\n if (client) connection.ip = client;\n }\n // tlsCipher: CloudFront folds the cipher into the viewer-tls string\n // (`TLSv1.3:TLS_AES_128_GCM_SHA256:fullHandshake`). `tlsVersion` stays\n // verbatim (reducing it would be a semantic change → schema v2); the\n // cipher is pulled out additively so it is queryable on its own.\n if (!connection.tlsCipher) {\n const cipher = connection.tlsVersion?.split(':')[1];\n if (cipher) connection.tlsCipher = cipher;\n }\n\n return Object.keys(connection).length > 0 ? connection : undefined;\n}\n\n/**\n * The FULL metadata capture for one request at an SDK adapter (express /\n * nextjs / mcp): sanitised headers, the safe credential format prefix, the\n * platform-signal subset, the CDN-derived connection block, and the capture\n * schema version. This is the ONE builder all SDK adapters share, so their\n * verify-access bodies agree byte-for-byte.\n *\n * Connection derivation runs over the RAW header map (pre-cap) — the size\n * caps guard storage growth and must never hide a geo/IP header that\n * happened to arrive late in an oversized map.\n */\nexport function buildSdkObservedMetadata(\n rawHeaders: Record<string, string | string[] | undefined> | undefined | null\n): ObservedMetadata {\n const { headers, apiKeyFormat, platformHeaders, cookieCount } = sanitizeHeaders(rawHeaders);\n const flat: Record<string, string> = {};\n if (rawHeaders) {\n for (const name of Object.keys(rawHeaders)) {\n const value = rawHeaders[name];\n if (value === undefined) continue;\n flat[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value);\n }\n }\n const connection = deriveConnectionFromHeaders(flat);\n return {\n headers,\n ...(apiKeyFormat && { apiKeyFormat }),\n ...(platformHeaders && { platformHeaders }),\n ...(connection && { connection }),\n ...(cookieCount !== undefined && { cookieCount }),\n schemaVersion: CAPTURE_SCHEMA_VERSION,\n };\n}\n"],"mappings":"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","names":[]}
@@ -33,7 +33,11 @@ var PLATFORM_HEADER_NAMES = /* @__PURE__ */ new Set([
33
33
  "x-goog-api-client",
34
34
  "user-agent",
35
35
  "x-agent-card-url",
36
- "x-agent-provider"
36
+ "x-agent-provider",
37
+ // Round 31 — Web Bot Auth's Signature-Agent names the signing agent's
38
+ // directory host (a public URL, never a secret). It attributes a signed
39
+ // agent even when its UA is stock Chrome.
40
+ "signature-agent"
37
41
  ]);
38
42
  var PLATFORM_HEADER_PREFIXES = [
39
43
  "x-goog-",
@@ -164,7 +168,12 @@ function extractPlatformHeaders(sanitized) {
164
168
  return out;
165
169
  }
166
170
  var CONNECTION_HEADER_SOURCES = [
167
- ["ip", ["cf-connecting-ip", "fastly-client-ip", "true-client-ip", "x-real-ip"]],
171
+ // Round 31: `incap-client-ip` = Imperva Cloud WAF; `true-client-ip` is
172
+ // also Akamai's client-IP header when the property enables it.
173
+ [
174
+ "ip",
175
+ ["cf-connecting-ip", "fastly-client-ip", "incap-client-ip", "true-client-ip", "x-real-ip"]
176
+ ],
168
177
  [
169
178
  "country",
170
179
  ["cf-ipcountry", "x-vercel-ip-country", "cloudfront-viewer-country", "fastly-geo-country"]
@@ -215,6 +224,28 @@ function deriveConnectionFromHeaders(headers) {
215
224
  if (portMatch) connection.sourcePort = portMatch[1];
216
225
  if (!connection.ip && addr) connection.ip = addr.replace(/:\d+$/, "");
217
226
  }
227
+ {
228
+ const edgescape = (lower["x-akamai-edgescape"] ?? lower["akamai-edgescape"])?.trim();
229
+ if (edgescape) {
230
+ const fields = /* @__PURE__ */ new Map();
231
+ for (const pair of edgescape.split(",")) {
232
+ const i = pair.indexOf("=");
233
+ if (i > 0) fields.set(pair.slice(0, i).trim().toLowerCase(), pair.slice(i + 1).trim());
234
+ }
235
+ const fill = (key, field) => {
236
+ const v = fields.get(field);
237
+ if (v && !connection[key]) connection[key] = v.slice(0, MAX_CONNECTION_VALUE_CHARS);
238
+ };
239
+ fill("country", "country_code");
240
+ fill("region", "region_code");
241
+ fill("city", "city");
242
+ fill("latitude", "lat");
243
+ fill("longitude", "long");
244
+ fill("timeZone", "timezone");
245
+ fill("asn", "asnum");
246
+ fill("continent", "continent");
247
+ }
248
+ }
218
249
  if (!connection.ip) {
219
250
  const xff = lower["x-forwarded-for"];
220
251
  const client = xff?.split(",")[0]?.trim();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/metadata-capture.ts"],"sourcesContent":["/**\n * Maximal metadata capture — the shared, edge-safe sanitiser.\n *\n * AstraSync's thesis is total metadata capture: every header and connection\n * signal an inbound agent presents is a signal we want to keep. This module is\n * the ONE place that decides what is safe to store verbatim, what is a secret\n * to drop, and what credential headers reduce to a safe *format prefix* (a\n * platform signal, never the secret itself).\n *\n * It runs in three places that must agree byte-for-byte:\n * - the edge adapter (`@astrasyncai/adapter-lambda`) — the richest capture\n * point (full CloudFront header arrays),\n * - the SDK adapters (express / nextjs) — for SDK-only merchants with no edge,\n * - (indirectly) the backend, which stores whatever the above forward.\n *\n * Pure — no Node built-ins, no I/O — so it is safe in Lambda@Edge, the browser\n * build, and Deno. Capture is cheap: at the edge and SDK it is local string\n * work, and the backend stores it on a fire-and-forget event insert, so it adds\n * ZERO verify-access decision latency. Detection (which few signals imply which\n * vendor) is a separate, curated concern — see `platform-signatures.ts`.\n */\n\n/**\n * Version of the capture semantics (what is kept, dropped, reduced, or\n * derived — and under which key names).\n *\n * Capture semantics are FROZEN within a package major version: a given\n * `schemaVersion` always means the same field vocabulary and the same\n * keep/drop/reduce rules. The number bumps only on a SEMANTIC change to\n * capture (a renamed key, a changed reduction rule) — never for additive\n * signals, and never merely because the package version moved. To tell\n * builds apart, use the `sdkVersion` already present on the verify body;\n * `schemaVersion` answers the different question \"how do I interpret this\n * stored metadata?\".\n */\nexport const CAPTURE_SCHEMA_VERSION = 1;\n\n/**\n * The captured, sanitised view of an inbound agent's request metadata. Attached\n * to verify-access / beacon payloads as `callerMetadata.observedMetadata`.\n */\nexport interface ObservedMetadata {\n /** Header name → value, secrets removed, verbatim otherwise. */\n headers: Record<string, string>;\n /**\n * Safe key-format prefix of a credential header (e.g. `sk-ant-api03`,\n * `sk-proj`, `AIza`) — a platform signal, NEVER the secret. Absent if no\n * credential header was present or none matched a known format.\n */\n apiKeyFormat?: string;\n /**\n * The subset of `headers` that are known platform-signal headers\n * (`openai-organization`, `x-goog-user-project`, `anthropic-version`, …),\n * pulled out for convenient detection + display. Values are the same\n * sanitised strings as in `headers`.\n */\n platformHeaders?: Record<string, string>;\n /**\n * Connection-layer signals (IP, ASN, country, TLS version, HTTP version,\n * device class, TLS fingerprint). Edge adapters populate this from the\n * platform's native connection surface — the authoritative source. SDK\n * adapters populate it as a fallback via {@link deriveConnectionFromHeaders}\n * when a CDN in front of the merchant injected the equivalent headers;\n * absent when neither source had anything.\n */\n connection?: Record<string, string>;\n /**\n * Number of cookie pairs the caller sent. The `cookie` header VALUE is a\n * secret and is always dropped; the COUNT is a client-shape signal\n * (browsers carry cookies, most automation carries none) that would\n * otherwise be unobservable downstream. Absent when no cookie header\n * arrived.\n */\n cookieCount?: number;\n /**\n * Query-string parameter NAMES (deduped, lowercased) — values are NEVER\n * captured (they routinely carry OAuth codes, tokens, and PII, and no\n * deny-list is complete). Names alone fingerprint client shape. Populated\n * by the edge path, which sees the raw query string.\n */\n queryKeys?: string[];\n /**\n * Query-string VALUES for parameter names the endpoint owner explicitly\n * allow-listed (`EdgeConfig.queryValueAllowlist`, seeded `utm_*` — VI\n * uplift Part 4). The default remains NO value capture; this exists so\n * campaign attribution (utm_source/medium/campaign) can be reported\n * without widening the privacy stance. Bounded 16 entries × 256 chars.\n */\n queryParams?: Record<string, string>;\n /**\n * Version of the capture semantics this object was produced under — see\n * {@link CAPTURE_SCHEMA_VERSION}. Optional for backward compatibility with\n * metadata captured before the field existed.\n */\n schemaVersion?: number;\n}\n\n/**\n * Result of sanitising a raw header map. `headers` is always present (possibly\n * empty); `apiKeyFormat` / `platformHeaders` only when a signal was found.\n */\nexport interface SanitizeHeadersResult {\n headers: Record<string, string>;\n apiKeyFormat?: string;\n platformHeaders?: Record<string, string>;\n /** Cookie-pair count; the cookie value itself is always dropped. */\n cookieCount?: number;\n}\n\n/**\n * Header names dropped entirely — genuine secrets or our own credentials that\n * describe the request's transport, not the agent. Matched case-insensitively\n * on the exact header name.\n */\nconst SECRET_HEADER_NAMES = new Set([\n 'cookie',\n 'set-cookie',\n 'x-csrf-token',\n 'proxy-authorization',\n 'x-astrasync-signature',\n 'x-astrasync-secret',\n 'x-hub-signature',\n 'x-hub-signature-256',\n // Short-lived verify dedupe token — must never persist in stored metadata.\n 'x-astra-verified-hop',\n]);\n\n/**\n * Header-name prefixes dropped entirely — our own credential / signing surface\n * (`x-astrasync-*`, `kya-*`) must never round-trip into stored metadata.\n */\nconst SECRET_HEADER_PREFIXES = ['x-astrasync-', 'kya-', 'kya_'];\n\n/**\n * Credential headers whose VALUE is a secret but whose FORMAT is a signal. We\n * keep the leading format prefix (up to the random tail) and drop the rest.\n */\nconst CREDENTIAL_HEADER_NAMES = new Set([\n 'authorization',\n 'x-api-key',\n 'x-goog-api-key',\n 'api-key',\n]);\n\n/**\n * Header names that carry a raw signature value (drop the value; the presence\n * is not worth a bloat risk). Matched as a suffix so `x-*-signature` variants\n * are covered without an exhaustive list.\n */\nfunction isSignatureHeader(name: string): boolean {\n return name === 'signature' || name.endsWith('-signature') || name.endsWith('-signature-256');\n}\n\n/**\n * Known platform-signal headers — pulled into `platformHeaders` for convenient\n * detection + display. Exact names plus a few prefixes (`x-goog-`,\n * `x-stainless-` — the OpenAI/Anthropic SDK client fingerprint headers).\n */\nconst PLATFORM_HEADER_NAMES = new Set([\n 'openai-organization',\n 'openai-project',\n 'openai-version',\n 'openai-processing-ms',\n 'anthropic-version',\n 'anthropic-beta',\n 'x-goog-user-project',\n 'x-goog-api-client',\n 'user-agent',\n 'x-agent-card-url',\n 'x-agent-provider',\n]);\n// `sec-ch-*` client hints are platform signals: Chromium forks (agentic\n// browsers included) often carry their real brand in `sec-ch-ua` even when\n// the UA string mimics stock Chrome.\nconst PLATFORM_HEADER_PREFIXES = [\n 'x-goog-',\n 'x-stainless-',\n 'x-anthropic-',\n 'x-openai-',\n 'sec-ch-',\n];\n\n/**\n * Known API-key format prefixes, longest-first so `sk-ant-api03` wins over\n * `sk-ant` and `sk-`. Each is the SAFE, non-secret leading token of a\n * credential; the random tail is never captured.\n */\nconst KEY_FORMAT_PREFIXES = [\n 'sk-ant-api03',\n 'sk-ant-api',\n 'sk-ant',\n 'sk-proj',\n 'sk-svcacct',\n 'sk-or-v1',\n 'sk-or',\n 'sk-lf',\n 'sk-',\n 'gsk_',\n 'aiza',\n 'ya29',\n 'ghp_',\n 'gho_',\n 'github_pat_',\n 'xai-',\n 'pplx-',\n 'r8_',\n 'hf_',\n 'astra-',\n 'astrae-',\n];\n\n/** Size caps — the only guard against jsonb bloat from a hostile POST. */\nexport const MAX_HEADERS = 64;\nexport const MAX_HEADER_VALUE_BYTES = 1024;\nexport const MAX_TOTAL_BYTES = 16 * 1024;\n\n/**\n * `X-Astra-Source` declared-source protocol (VI uplift Part 2). The header is\n * SELF-ASSERTED and spoofable — it buys attribution, never trust: it is never\n * read by any access decision and never conflated with fingerprint-matched\n * (`platformVendor`) or ASTRA-verified evidence. One shared validator so the\n * edge, backend, and bridge accept byte-identical values.\n */\nexport const DECLARED_SOURCE_MAX = 64;\nconst DECLARED_SOURCE_PATTERN = /^[a-z0-9][a-z0-9 ._/-]{0,63}$/;\n\nexport function normalizeDeclaredSource(value: string | undefined | null): string | undefined {\n const trimmed = value?.trim().slice(0, DECLARED_SOURCE_MAX).toLowerCase();\n return trimmed && DECLARED_SOURCE_PATTERN.test(trimmed) ? trimmed : undefined;\n}\n\nfunction byteLength(s: string): number {\n // Edge-safe (no Buffer): count UTF-8 bytes without allocating a Buffer.\n let bytes = 0;\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code < 0x80) bytes += 1;\n else if (code < 0x800) bytes += 2;\n else if (code >= 0xd800 && code <= 0xdbff) {\n bytes += 4;\n i++; // surrogate pair\n } else bytes += 3;\n }\n return bytes;\n}\n\n/**\n * Extract the safe key-format prefix from a credential header value. Strips a\n * leading `Bearer ` / `Basic ` scheme, then returns the longest known prefix\n * that the (lowercased) value starts with. Returns undefined for opaque values\n * (e.g. a bare UUID token) so we never guess a format we don't recognise.\n */\nexport function extractApiKeyFormat(rawValue: string): string | undefined {\n if (!rawValue) return undefined;\n let value = rawValue.trim();\n const spaceIdx = value.indexOf(' ');\n if (spaceIdx > 0) {\n const scheme = value.slice(0, spaceIdx).toLowerCase();\n if (scheme === 'bearer' || scheme === 'basic' || scheme === 'token') {\n value = value.slice(spaceIdx + 1).trim();\n }\n }\n const lower = value.toLowerCase();\n for (const prefix of KEY_FORMAT_PREFIXES) {\n if (lower.startsWith(prefix)) {\n // Return the prefix as-declared in the list (canonical casing).\n return prefix;\n }\n }\n return undefined;\n}\n\nfunction isSecretHeader(name: string): boolean {\n if (SECRET_HEADER_NAMES.has(name)) return true;\n if (SECRET_HEADER_PREFIXES.some((p) => name.startsWith(p))) return true;\n if (isSignatureHeader(name)) return true;\n return false;\n}\n\nfunction isPlatformHeader(name: string): boolean {\n if (PLATFORM_HEADER_NAMES.has(name)) return true;\n return PLATFORM_HEADER_PREFIXES.some((p) => name.startsWith(p));\n}\n\n/**\n * Sanitise a raw header map for storage. Deny-lists genuine secrets, reduces\n * credential headers to a safe format prefix, keeps everything else verbatim,\n * and enforces the size caps that bound jsonb growth.\n *\n * Accepts any string→(string|string[]|undefined) map (Node's `req.headers`,\n * CloudFront's flattened headers, a plain object). Multi-value headers are\n * joined with `, ` per RFC 7230.\n */\nexport function sanitizeHeaders(\n raw: Record<string, string | string[] | undefined> | undefined | null\n): SanitizeHeadersResult {\n const headers: Record<string, string> = {};\n const platformHeaders: Record<string, string> = {};\n let apiKeyFormat: string | undefined;\n let cookieCount: number | undefined;\n let totalBytes = 0;\n let count = 0;\n\n if (!raw) return { headers };\n\n for (const rawName of Object.keys(raw)) {\n if (count >= MAX_HEADERS) break;\n const name = rawName.toLowerCase();\n const rawValue = raw[rawName];\n if (rawValue === undefined) continue;\n const joined = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);\n\n // Credential headers: capture the format prefix, drop the secret.\n if (CREDENTIAL_HEADER_NAMES.has(name)) {\n const fmt = extractApiKeyFormat(joined);\n if (fmt && !apiKeyFormat) apiKeyFormat = fmt;\n continue;\n }\n\n // Cookie: the value is a secret (dropped below), but the PAIR COUNT is a\n // client-shape signal worth keeping.\n if (name === 'cookie') {\n cookieCount = joined.split(';').filter((s) => s.trim().length > 0).length;\n }\n\n // Genuine secrets: drop entirely.\n if (isSecretHeader(name)) continue;\n\n // Everything else describing the agent: keep, bounded.\n let value = joined;\n if (byteLength(value) > MAX_HEADER_VALUE_BYTES) {\n value = value.slice(0, MAX_HEADER_VALUE_BYTES);\n }\n const entryBytes = byteLength(name) + byteLength(value);\n if (totalBytes + entryBytes > MAX_TOTAL_BYTES) continue;\n totalBytes += entryBytes;\n count++;\n headers[name] = value;\n if (isPlatformHeader(name)) platformHeaders[name] = value;\n }\n\n const result: SanitizeHeadersResult = { headers };\n if (apiKeyFormat) result.apiKeyFormat = apiKeyFormat;\n if (Object.keys(platformHeaders).length > 0) result.platformHeaders = platformHeaders;\n if (cookieCount !== undefined) result.cookieCount = cookieCount;\n return result;\n}\n\n/**\n * Pull the known platform-signal headers out of an ALREADY-sanitised header\n * map. `sanitizeHeaders` already computes this as a by-product; this standalone\n * form is exported for call-sites (edge, tests) that hold a sanitised map and\n * want only the platform subset. Never re-run over raw (unsanitised) headers.\n */\nexport function extractPlatformHeaders(\n sanitized: Record<string, string> | undefined | null\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!sanitized) return out;\n for (const name of Object.keys(sanitized)) {\n if (isPlatformHeader(name.toLowerCase())) out[name.toLowerCase()] = sanitized[name];\n }\n return out;\n}\n\n/**\n * Precedence-ordered CDN header sources for each derived `connection` key.\n * First present header wins; the special `x-forwarded-for` source takes the\n * LEFTMOST (client) entry of the comma-separated chain.\n */\nconst CONNECTION_HEADER_SOURCES: ReadonlyArray<readonly [key: string, headers: readonly string[]]> =\n [\n ['ip', ['cf-connecting-ip', 'fastly-client-ip', 'true-client-ip', 'x-real-ip']],\n [\n 'country',\n ['cf-ipcountry', 'x-vercel-ip-country', 'cloudfront-viewer-country', 'fastly-geo-country'],\n ],\n ['region', ['x-vercel-ip-country-region', 'cloudfront-viewer-country-region']],\n ['countryRegionName', ['cloudfront-viewer-country-region-name']],\n ['city', ['x-vercel-ip-city', 'cloudfront-viewer-city']],\n ['latitude', ['x-vercel-ip-latitude', 'cloudfront-viewer-latitude']],\n ['longitude', ['x-vercel-ip-longitude', 'cloudfront-viewer-longitude']],\n ['postalCode', ['x-vercel-ip-postal-code', 'cloudfront-viewer-postal-code']],\n ['asn', ['cloudfront-viewer-asn']],\n ['tlsVersion', ['cloudfront-viewer-tls']],\n ['httpVersion', ['cloudfront-viewer-http-version']],\n ['timeZone', ['x-vercel-ip-timezone', 'cloudfront-viewer-time-zone']],\n ['metroCode', ['cloudfront-viewer-metro-code']],\n // TLS fingerprints can only be computed where TLS terminates. When a CDN\n // terminates TLS in front of the merchant, its fingerprint headers ARE the\n // connection-layer truth — normalising them here is the permanent design.\n // CloudFront delivers JA3/JA4 only via an origin request policy and only\n // for HTTPS viewer connections.\n ['tlsFingerprint', ['cf-ja3-hash', 'cloudfront-viewer-ja3-fingerprint']],\n ['ja4', ['cf-ja4', 'cloudfront-viewer-ja4-fingerprint']],\n // Header-structure fingerprint: browser/SDK-distinctive header ordering.\n ['headerOrder', ['cloudfront-viewer-header-order']],\n ['headerCount', ['cloudfront-viewer-header-count']],\n ];\n\n/**\n * Backend bound: observedMetadata.connection values longer than this fail\n * the platform's zod parse outright (boundedStringRecord(32, 256)), so every\n * emitter truncates — headerOrder routinely exceeds it.\n */\nexport const MAX_CONNECTION_VALUE_CHARS = 256;\n\n/**\n * Derive the connection-layer block from CDN-injected request headers.\n *\n * When a merchant's SDK-gated origin sits behind a CDN (Cloudflare, Fastly,\n * Vercel, CloudFront), the CDN has already seen the connection layer and\n * injected it as headers. This promotes those headers to the same\n * `connection` keys the edge adapters populate natively, so SDK-only\n * deployments still get IP / geo / TLS attribution. Precedence per key:\n *\n * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `true-client-ip` >\n * `x-real-ip` > `cloudfront-viewer-address` (port stripped) > leftmost\n * `x-forwarded-for`\n * - `country`: `cf-ipcountry` > `x-vercel-ip-country` >\n * `cloudfront-viewer-country` > `fastly-geo-country`\n * - `region` / `city` / `timeZone`: Vercel then CloudFront viewer headers\n * - `countryRegionName` / `asn` / `tlsVersion` / `httpVersion` /\n * `metroCode` / `headerOrder` / `headerCount`: `cloudfront-viewer-*`\n * - `tlsFingerprint`: `cf-ja3-hash` > `cloudfront-viewer-ja3-fingerprint`\n * - `ja4`: `cf-ja4` > `cloudfront-viewer-ja4-fingerprint`\n *\n * All keys are additive under `schemaVersion` 1 (the frozen-capture doctrine\n * bumps only for changed semantics, never for additive signals). Values are\n * truncated to MAX_CONNECTION_VALUE_CHARS — the platform's hard bound.\n *\n * Edge adapters keep their richer native collectors — native platform\n * signals are authoritative; this header derivation is the SDK-level\n * fallback. Header names are matched case-insensitively. Returns undefined\n * when no source header is present, so the `connection` key is simply\n * omitted (matching edge behaviour).\n */\nexport function deriveConnectionFromHeaders(\n headers: Record<string, string> | undefined | null\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n const lower: Record<string, string> = {};\n for (const name of Object.keys(headers)) {\n const value = headers[name];\n if (typeof value === 'string' && value.length > 0) lower[name.toLowerCase()] = value;\n }\n\n const connection: Record<string, string> = {};\n for (const [key, sources] of CONNECTION_HEADER_SOURCES) {\n for (const source of sources) {\n const value = lower[source]?.trim();\n if (value) {\n connection[key] = value.slice(0, MAX_CONNECTION_VALUE_CHARS);\n break;\n }\n }\n }\n // ip fallback 1: cloudfront-viewer-address arrives as ip:port — strip the\n // trailing port only (IPv6-safe), unlike the dedicated client-ip headers.\n // The port itself is kept as its own key: ephemeral-port patterns signal\n // NAT/proxy connection reuse.\n {\n const addr = lower['cloudfront-viewer-address']?.trim();\n const portMatch = addr?.match(/:(\\d+)$/);\n if (portMatch) connection.sourcePort = portMatch[1];\n if (!connection.ip && addr) connection.ip = addr.replace(/:\\d+$/, '');\n }\n // ip fallback 2: leftmost (client) hop of the x-forwarded-for chain.\n if (!connection.ip) {\n const xff = lower['x-forwarded-for'];\n const client = xff?.split(',')[0]?.trim();\n if (client) connection.ip = client;\n }\n // tlsCipher: CloudFront folds the cipher into the viewer-tls string\n // (`TLSv1.3:TLS_AES_128_GCM_SHA256:fullHandshake`). `tlsVersion` stays\n // verbatim (reducing it would be a semantic change → schema v2); the\n // cipher is pulled out additively so it is queryable on its own.\n if (!connection.tlsCipher) {\n const cipher = connection.tlsVersion?.split(':')[1];\n if (cipher) connection.tlsCipher = cipher;\n }\n\n return Object.keys(connection).length > 0 ? connection : undefined;\n}\n\n/**\n * The FULL metadata capture for one request at an SDK adapter (express /\n * nextjs / mcp): sanitised headers, the safe credential format prefix, the\n * platform-signal subset, the CDN-derived connection block, and the capture\n * schema version. This is the ONE builder all SDK adapters share, so their\n * verify-access bodies agree byte-for-byte.\n *\n * Connection derivation runs over the RAW header map (pre-cap) — the size\n * caps guard storage growth and must never hide a geo/IP header that\n * happened to arrive late in an oversized map.\n */\nexport function buildSdkObservedMetadata(\n rawHeaders: Record<string, string | string[] | undefined> | undefined | null\n): ObservedMetadata {\n const { headers, apiKeyFormat, platformHeaders, cookieCount } = sanitizeHeaders(rawHeaders);\n const flat: Record<string, string> = {};\n if (rawHeaders) {\n for (const name of Object.keys(rawHeaders)) {\n const value = rawHeaders[name];\n if (value === undefined) continue;\n flat[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value);\n }\n }\n const connection = deriveConnectionFromHeaders(flat);\n return {\n headers,\n ...(apiKeyFormat && { apiKeyFormat }),\n ...(platformHeaders && { platformHeaders }),\n ...(connection && { connection }),\n ...(cookieCount !== undefined && { cookieCount }),\n schemaVersion: CAPTURE_SCHEMA_VERSION,\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/metadata-capture.ts"],"sourcesContent":["/**\n * Maximal metadata capture — the shared, edge-safe sanitiser.\n *\n * AstraSync's thesis is total metadata capture: every header and connection\n * signal an inbound agent presents is a signal we want to keep. This module is\n * the ONE place that decides what is safe to store verbatim, what is a secret\n * to drop, and what credential headers reduce to a safe *format prefix* (a\n * platform signal, never the secret itself).\n *\n * It runs in three places that must agree byte-for-byte:\n * - the edge adapter (`@astrasyncai/adapter-lambda`) — the richest capture\n * point (full CloudFront header arrays),\n * - the SDK adapters (express / nextjs) — for SDK-only merchants with no edge,\n * - (indirectly) the backend, which stores whatever the above forward.\n *\n * Pure — no Node built-ins, no I/O — so it is safe in Lambda@Edge, the browser\n * build, and Deno. Capture is cheap: at the edge and SDK it is local string\n * work, and the backend stores it on a fire-and-forget event insert, so it adds\n * ZERO verify-access decision latency. Detection (which few signals imply which\n * vendor) is a separate, curated concern — see `platform-signatures.ts`.\n */\n\n/**\n * Version of the capture semantics (what is kept, dropped, reduced, or\n * derived — and under which key names).\n *\n * Capture semantics are FROZEN within a package major version: a given\n * `schemaVersion` always means the same field vocabulary and the same\n * keep/drop/reduce rules. The number bumps only on a SEMANTIC change to\n * capture (a renamed key, a changed reduction rule) — never for additive\n * signals, and never merely because the package version moved. To tell\n * builds apart, use the `sdkVersion` already present on the verify body;\n * `schemaVersion` answers the different question \"how do I interpret this\n * stored metadata?\".\n */\nexport const CAPTURE_SCHEMA_VERSION = 1;\n\n/**\n * The captured, sanitised view of an inbound agent's request metadata. Attached\n * to verify-access / beacon payloads as `callerMetadata.observedMetadata`.\n */\nexport interface ObservedMetadata {\n /** Header name → value, secrets removed, verbatim otherwise. */\n headers: Record<string, string>;\n /**\n * Safe key-format prefix of a credential header (e.g. `sk-ant-api03`,\n * `sk-proj`, `AIza`) — a platform signal, NEVER the secret. Absent if no\n * credential header was present or none matched a known format.\n */\n apiKeyFormat?: string;\n /**\n * The subset of `headers` that are known platform-signal headers\n * (`openai-organization`, `x-goog-user-project`, `anthropic-version`, …),\n * pulled out for convenient detection + display. Values are the same\n * sanitised strings as in `headers`.\n */\n platformHeaders?: Record<string, string>;\n /**\n * Connection-layer signals (IP, ASN, country, TLS version, HTTP version,\n * device class, TLS fingerprint). Edge adapters populate this from the\n * platform's native connection surface — the authoritative source. SDK\n * adapters populate it as a fallback via {@link deriveConnectionFromHeaders}\n * when a CDN in front of the merchant injected the equivalent headers;\n * absent when neither source had anything.\n */\n connection?: Record<string, string>;\n /**\n * Number of cookie pairs the caller sent. The `cookie` header VALUE is a\n * secret and is always dropped; the COUNT is a client-shape signal\n * (browsers carry cookies, most automation carries none) that would\n * otherwise be unobservable downstream. Absent when no cookie header\n * arrived.\n */\n cookieCount?: number;\n /**\n * Query-string parameter NAMES (deduped, lowercased) — values are NEVER\n * captured (they routinely carry OAuth codes, tokens, and PII, and no\n * deny-list is complete). Names alone fingerprint client shape. Populated\n * by the edge path, which sees the raw query string.\n */\n queryKeys?: string[];\n /**\n * Query-string VALUES for parameter names the endpoint owner explicitly\n * allow-listed (`EdgeConfig.queryValueAllowlist`, seeded `utm_*` — VI\n * uplift Part 4). The default remains NO value capture; this exists so\n * campaign attribution (utm_source/medium/campaign) can be reported\n * without widening the privacy stance. Bounded 16 entries × 256 chars.\n */\n queryParams?: Record<string, string>;\n /**\n * Version of the capture semantics this object was produced under — see\n * {@link CAPTURE_SCHEMA_VERSION}. Optional for backward compatibility with\n * metadata captured before the field existed.\n */\n schemaVersion?: number;\n}\n\n/**\n * Result of sanitising a raw header map. `headers` is always present (possibly\n * empty); `apiKeyFormat` / `platformHeaders` only when a signal was found.\n */\nexport interface SanitizeHeadersResult {\n headers: Record<string, string>;\n apiKeyFormat?: string;\n platformHeaders?: Record<string, string>;\n /** Cookie-pair count; the cookie value itself is always dropped. */\n cookieCount?: number;\n}\n\n/**\n * Header names dropped entirely — genuine secrets or our own credentials that\n * describe the request's transport, not the agent. Matched case-insensitively\n * on the exact header name.\n */\nconst SECRET_HEADER_NAMES = new Set([\n 'cookie',\n 'set-cookie',\n 'x-csrf-token',\n 'proxy-authorization',\n 'x-astrasync-signature',\n 'x-astrasync-secret',\n 'x-hub-signature',\n 'x-hub-signature-256',\n // Short-lived verify dedupe token — must never persist in stored metadata.\n 'x-astra-verified-hop',\n]);\n\n/**\n * Header-name prefixes dropped entirely — our own credential / signing surface\n * (`x-astrasync-*`, `kya-*`) must never round-trip into stored metadata.\n */\nconst SECRET_HEADER_PREFIXES = ['x-astrasync-', 'kya-', 'kya_'];\n\n/**\n * Credential headers whose VALUE is a secret but whose FORMAT is a signal. We\n * keep the leading format prefix (up to the random tail) and drop the rest.\n */\nconst CREDENTIAL_HEADER_NAMES = new Set([\n 'authorization',\n 'x-api-key',\n 'x-goog-api-key',\n 'api-key',\n]);\n\n/**\n * Header names that carry a raw signature value (drop the value; the presence\n * is not worth a bloat risk). Matched as a suffix so `x-*-signature` variants\n * are covered without an exhaustive list.\n */\nfunction isSignatureHeader(name: string): boolean {\n return name === 'signature' || name.endsWith('-signature') || name.endsWith('-signature-256');\n}\n\n/**\n * Known platform-signal headers — pulled into `platformHeaders` for convenient\n * detection + display. Exact names plus a few prefixes (`x-goog-`,\n * `x-stainless-` — the OpenAI/Anthropic SDK client fingerprint headers).\n */\nconst PLATFORM_HEADER_NAMES = new Set([\n 'openai-organization',\n 'openai-project',\n 'openai-version',\n 'openai-processing-ms',\n 'anthropic-version',\n 'anthropic-beta',\n 'x-goog-user-project',\n 'x-goog-api-client',\n 'user-agent',\n 'x-agent-card-url',\n 'x-agent-provider',\n // Round 31 — Web Bot Auth's Signature-Agent names the signing agent's\n // directory host (a public URL, never a secret). It attributes a signed\n // agent even when its UA is stock Chrome.\n 'signature-agent',\n]);\n// `sec-ch-*` client hints are platform signals: Chromium forks (agentic\n// browsers included) often carry their real brand in `sec-ch-ua` even when\n// the UA string mimics stock Chrome.\nconst PLATFORM_HEADER_PREFIXES = [\n 'x-goog-',\n 'x-stainless-',\n 'x-anthropic-',\n 'x-openai-',\n 'sec-ch-',\n];\n\n/**\n * Known API-key format prefixes, longest-first so `sk-ant-api03` wins over\n * `sk-ant` and `sk-`. Each is the SAFE, non-secret leading token of a\n * credential; the random tail is never captured.\n */\nconst KEY_FORMAT_PREFIXES = [\n 'sk-ant-api03',\n 'sk-ant-api',\n 'sk-ant',\n 'sk-proj',\n 'sk-svcacct',\n 'sk-or-v1',\n 'sk-or',\n 'sk-lf',\n 'sk-',\n 'gsk_',\n 'aiza',\n 'ya29',\n 'ghp_',\n 'gho_',\n 'github_pat_',\n 'xai-',\n 'pplx-',\n 'r8_',\n 'hf_',\n 'astra-',\n 'astrae-',\n];\n\n/** Size caps — the only guard against jsonb bloat from a hostile POST. */\nexport const MAX_HEADERS = 64;\nexport const MAX_HEADER_VALUE_BYTES = 1024;\nexport const MAX_TOTAL_BYTES = 16 * 1024;\n\n/**\n * `X-Astra-Source` declared-source protocol (VI uplift Part 2). The header is\n * SELF-ASSERTED and spoofable — it buys attribution, never trust: it is never\n * read by any access decision and never conflated with fingerprint-matched\n * (`platformVendor`) or ASTRA-verified evidence. One shared validator so the\n * edge, backend, and bridge accept byte-identical values.\n */\nexport const DECLARED_SOURCE_MAX = 64;\nconst DECLARED_SOURCE_PATTERN = /^[a-z0-9][a-z0-9 ._/-]{0,63}$/;\n\nexport function normalizeDeclaredSource(value: string | undefined | null): string | undefined {\n const trimmed = value?.trim().slice(0, DECLARED_SOURCE_MAX).toLowerCase();\n return trimmed && DECLARED_SOURCE_PATTERN.test(trimmed) ? trimmed : undefined;\n}\n\nfunction byteLength(s: string): number {\n // Edge-safe (no Buffer): count UTF-8 bytes without allocating a Buffer.\n let bytes = 0;\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code < 0x80) bytes += 1;\n else if (code < 0x800) bytes += 2;\n else if (code >= 0xd800 && code <= 0xdbff) {\n bytes += 4;\n i++; // surrogate pair\n } else bytes += 3;\n }\n return bytes;\n}\n\n/**\n * Extract the safe key-format prefix from a credential header value. Strips a\n * leading `Bearer ` / `Basic ` scheme, then returns the longest known prefix\n * that the (lowercased) value starts with. Returns undefined for opaque values\n * (e.g. a bare UUID token) so we never guess a format we don't recognise.\n */\nexport function extractApiKeyFormat(rawValue: string): string | undefined {\n if (!rawValue) return undefined;\n let value = rawValue.trim();\n const spaceIdx = value.indexOf(' ');\n if (spaceIdx > 0) {\n const scheme = value.slice(0, spaceIdx).toLowerCase();\n if (scheme === 'bearer' || scheme === 'basic' || scheme === 'token') {\n value = value.slice(spaceIdx + 1).trim();\n }\n }\n const lower = value.toLowerCase();\n for (const prefix of KEY_FORMAT_PREFIXES) {\n if (lower.startsWith(prefix)) {\n // Return the prefix as-declared in the list (canonical casing).\n return prefix;\n }\n }\n return undefined;\n}\n\nfunction isSecretHeader(name: string): boolean {\n if (SECRET_HEADER_NAMES.has(name)) return true;\n if (SECRET_HEADER_PREFIXES.some((p) => name.startsWith(p))) return true;\n if (isSignatureHeader(name)) return true;\n return false;\n}\n\nfunction isPlatformHeader(name: string): boolean {\n if (PLATFORM_HEADER_NAMES.has(name)) return true;\n return PLATFORM_HEADER_PREFIXES.some((p) => name.startsWith(p));\n}\n\n/**\n * Sanitise a raw header map for storage. Deny-lists genuine secrets, reduces\n * credential headers to a safe format prefix, keeps everything else verbatim,\n * and enforces the size caps that bound jsonb growth.\n *\n * Accepts any string→(string|string[]|undefined) map (Node's `req.headers`,\n * CloudFront's flattened headers, a plain object). Multi-value headers are\n * joined with `, ` per RFC 7230.\n */\nexport function sanitizeHeaders(\n raw: Record<string, string | string[] | undefined> | undefined | null\n): SanitizeHeadersResult {\n const headers: Record<string, string> = {};\n const platformHeaders: Record<string, string> = {};\n let apiKeyFormat: string | undefined;\n let cookieCount: number | undefined;\n let totalBytes = 0;\n let count = 0;\n\n if (!raw) return { headers };\n\n for (const rawName of Object.keys(raw)) {\n if (count >= MAX_HEADERS) break;\n const name = rawName.toLowerCase();\n const rawValue = raw[rawName];\n if (rawValue === undefined) continue;\n const joined = Array.isArray(rawValue) ? rawValue.join(', ') : String(rawValue);\n\n // Credential headers: capture the format prefix, drop the secret.\n if (CREDENTIAL_HEADER_NAMES.has(name)) {\n const fmt = extractApiKeyFormat(joined);\n if (fmt && !apiKeyFormat) apiKeyFormat = fmt;\n continue;\n }\n\n // Cookie: the value is a secret (dropped below), but the PAIR COUNT is a\n // client-shape signal worth keeping.\n if (name === 'cookie') {\n cookieCount = joined.split(';').filter((s) => s.trim().length > 0).length;\n }\n\n // Genuine secrets: drop entirely.\n if (isSecretHeader(name)) continue;\n\n // Everything else describing the agent: keep, bounded.\n let value = joined;\n if (byteLength(value) > MAX_HEADER_VALUE_BYTES) {\n value = value.slice(0, MAX_HEADER_VALUE_BYTES);\n }\n const entryBytes = byteLength(name) + byteLength(value);\n if (totalBytes + entryBytes > MAX_TOTAL_BYTES) continue;\n totalBytes += entryBytes;\n count++;\n headers[name] = value;\n if (isPlatformHeader(name)) platformHeaders[name] = value;\n }\n\n const result: SanitizeHeadersResult = { headers };\n if (apiKeyFormat) result.apiKeyFormat = apiKeyFormat;\n if (Object.keys(platformHeaders).length > 0) result.platformHeaders = platformHeaders;\n if (cookieCount !== undefined) result.cookieCount = cookieCount;\n return result;\n}\n\n/**\n * Pull the known platform-signal headers out of an ALREADY-sanitised header\n * map. `sanitizeHeaders` already computes this as a by-product; this standalone\n * form is exported for call-sites (edge, tests) that hold a sanitised map and\n * want only the platform subset. Never re-run over raw (unsanitised) headers.\n */\nexport function extractPlatformHeaders(\n sanitized: Record<string, string> | undefined | null\n): Record<string, string> {\n const out: Record<string, string> = {};\n if (!sanitized) return out;\n for (const name of Object.keys(sanitized)) {\n if (isPlatformHeader(name.toLowerCase())) out[name.toLowerCase()] = sanitized[name];\n }\n return out;\n}\n\n/**\n * Precedence-ordered CDN header sources for each derived `connection` key.\n * First present header wins; the special `x-forwarded-for` source takes the\n * LEFTMOST (client) entry of the comma-separated chain.\n */\nconst CONNECTION_HEADER_SOURCES: ReadonlyArray<readonly [key: string, headers: readonly string[]]> =\n [\n // Round 31: `incap-client-ip` = Imperva Cloud WAF; `true-client-ip` is\n // also Akamai's client-IP header when the property enables it.\n [\n 'ip',\n ['cf-connecting-ip', 'fastly-client-ip', 'incap-client-ip', 'true-client-ip', 'x-real-ip'],\n ],\n [\n 'country',\n ['cf-ipcountry', 'x-vercel-ip-country', 'cloudfront-viewer-country', 'fastly-geo-country'],\n ],\n ['region', ['x-vercel-ip-country-region', 'cloudfront-viewer-country-region']],\n ['countryRegionName', ['cloudfront-viewer-country-region-name']],\n ['city', ['x-vercel-ip-city', 'cloudfront-viewer-city']],\n ['latitude', ['x-vercel-ip-latitude', 'cloudfront-viewer-latitude']],\n ['longitude', ['x-vercel-ip-longitude', 'cloudfront-viewer-longitude']],\n ['postalCode', ['x-vercel-ip-postal-code', 'cloudfront-viewer-postal-code']],\n ['asn', ['cloudfront-viewer-asn']],\n ['tlsVersion', ['cloudfront-viewer-tls']],\n ['httpVersion', ['cloudfront-viewer-http-version']],\n ['timeZone', ['x-vercel-ip-timezone', 'cloudfront-viewer-time-zone']],\n ['metroCode', ['cloudfront-viewer-metro-code']],\n // TLS fingerprints can only be computed where TLS terminates. When a CDN\n // terminates TLS in front of the merchant, its fingerprint headers ARE the\n // connection-layer truth — normalising them here is the permanent design.\n // CloudFront delivers JA3/JA4 only via an origin request policy and only\n // for HTTPS viewer connections.\n ['tlsFingerprint', ['cf-ja3-hash', 'cloudfront-viewer-ja3-fingerprint']],\n ['ja4', ['cf-ja4', 'cloudfront-viewer-ja4-fingerprint']],\n // Header-structure fingerprint: browser/SDK-distinctive header ordering.\n ['headerOrder', ['cloudfront-viewer-header-order']],\n ['headerCount', ['cloudfront-viewer-header-count']],\n ];\n\n/**\n * Backend bound: observedMetadata.connection values longer than this fail\n * the platform's zod parse outright (boundedStringRecord(32, 256)), so every\n * emitter truncates — headerOrder routinely exceeds it.\n */\nexport const MAX_CONNECTION_VALUE_CHARS = 256;\n\n/**\n * Derive the connection-layer block from CDN-injected request headers.\n *\n * When a merchant's SDK-gated origin sits behind a CDN (Cloudflare, Fastly,\n * Vercel, CloudFront), the CDN has already seen the connection layer and\n * injected it as headers. This promotes those headers to the same\n * `connection` keys the edge adapters populate natively, so SDK-only\n * deployments still get IP / geo / TLS attribution. Precedence per key:\n *\n * - `ip`: `cf-connecting-ip` > `fastly-client-ip` > `incap-client-ip`\n * (Imperva) > `true-client-ip` (Akamai et al.) > `x-real-ip` >\n * `cloudfront-viewer-address` (port stripped) > leftmost `x-forwarded-for`\n * - Akamai `x-akamai-edgescape` fills country / region / city / lat / long /\n * timeZone / asn / continent where no header above supplied them\n * - `country`: `cf-ipcountry` > `x-vercel-ip-country` >\n * `cloudfront-viewer-country` > `fastly-geo-country`\n * - `region` / `city` / `timeZone`: Vercel then CloudFront viewer headers\n * - `countryRegionName` / `asn` / `tlsVersion` / `httpVersion` /\n * `metroCode` / `headerOrder` / `headerCount`: `cloudfront-viewer-*`\n * - `tlsFingerprint`: `cf-ja3-hash` > `cloudfront-viewer-ja3-fingerprint`\n * - `ja4`: `cf-ja4` > `cloudfront-viewer-ja4-fingerprint`\n *\n * All keys are additive under `schemaVersion` 1 (the frozen-capture doctrine\n * bumps only for changed semantics, never for additive signals). Values are\n * truncated to MAX_CONNECTION_VALUE_CHARS — the platform's hard bound.\n *\n * Edge adapters keep their richer native collectors — native platform\n * signals are authoritative; this header derivation is the SDK-level\n * fallback. Header names are matched case-insensitively. Returns undefined\n * when no source header is present, so the `connection` key is simply\n * omitted (matching edge behaviour).\n */\nexport function deriveConnectionFromHeaders(\n headers: Record<string, string> | undefined | null\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n const lower: Record<string, string> = {};\n for (const name of Object.keys(headers)) {\n const value = headers[name];\n if (typeof value === 'string' && value.length > 0) lower[name.toLowerCase()] = value;\n }\n\n const connection: Record<string, string> = {};\n for (const [key, sources] of CONNECTION_HEADER_SOURCES) {\n for (const source of sources) {\n const value = lower[source]?.trim();\n if (value) {\n connection[key] = value.slice(0, MAX_CONNECTION_VALUE_CHARS);\n break;\n }\n }\n }\n // ip fallback 1: cloudfront-viewer-address arrives as ip:port — strip the\n // trailing port only (IPv6-safe), unlike the dedicated client-ip headers.\n // The port itself is kept as its own key: ephemeral-port patterns signal\n // NAT/proxy connection reuse.\n {\n const addr = lower['cloudfront-viewer-address']?.trim();\n const portMatch = addr?.match(/:(\\d+)$/);\n if (portMatch) connection.sourcePort = portMatch[1];\n if (!connection.ip && addr) connection.ip = addr.replace(/:\\d+$/, '');\n }\n // Round 31 — Akamai EdgeScape (`X-Akamai-Edgescape: country_code=US,\n // region_code=CA,city=SANJOSE,lat=…,long=…,timezone=PST,asnum=7922,…`),\n // added by the property's EdgeScape header behaviour. Fills gaps only.\n {\n const edgescape = (lower['x-akamai-edgescape'] ?? lower['akamai-edgescape'])?.trim();\n if (edgescape) {\n const fields = new Map<string, string>();\n for (const pair of edgescape.split(',')) {\n const i = pair.indexOf('=');\n if (i > 0) fields.set(pair.slice(0, i).trim().toLowerCase(), pair.slice(i + 1).trim());\n }\n const fill = (key: string, field: string) => {\n const v = fields.get(field);\n if (v && !connection[key]) connection[key] = v.slice(0, MAX_CONNECTION_VALUE_CHARS);\n };\n fill('country', 'country_code');\n fill('region', 'region_code');\n fill('city', 'city');\n fill('latitude', 'lat');\n fill('longitude', 'long');\n fill('timeZone', 'timezone');\n fill('asn', 'asnum');\n fill('continent', 'continent');\n }\n }\n // ip fallback 2: leftmost (client) hop of the x-forwarded-for chain.\n if (!connection.ip) {\n const xff = lower['x-forwarded-for'];\n const client = xff?.split(',')[0]?.trim();\n if (client) connection.ip = client;\n }\n // tlsCipher: CloudFront folds the cipher into the viewer-tls string\n // (`TLSv1.3:TLS_AES_128_GCM_SHA256:fullHandshake`). `tlsVersion` stays\n // verbatim (reducing it would be a semantic change → schema v2); the\n // cipher is pulled out additively so it is queryable on its own.\n if (!connection.tlsCipher) {\n const cipher = connection.tlsVersion?.split(':')[1];\n if (cipher) connection.tlsCipher = cipher;\n }\n\n return Object.keys(connection).length > 0 ? connection : undefined;\n}\n\n/**\n * The FULL metadata capture for one request at an SDK adapter (express /\n * nextjs / mcp): sanitised headers, the safe credential format prefix, the\n * platform-signal subset, the CDN-derived connection block, and the capture\n * schema version. This is the ONE builder all SDK adapters share, so their\n * verify-access bodies agree byte-for-byte.\n *\n * Connection derivation runs over the RAW header map (pre-cap) — the size\n * caps guard storage growth and must never hide a geo/IP header that\n * happened to arrive late in an oversized map.\n */\nexport function buildSdkObservedMetadata(\n rawHeaders: Record<string, string | string[] | undefined> | undefined | null\n): ObservedMetadata {\n const { headers, apiKeyFormat, platformHeaders, cookieCount } = sanitizeHeaders(rawHeaders);\n const flat: Record<string, string> = {};\n if (rawHeaders) {\n for (const name of Object.keys(rawHeaders)) {\n const value = rawHeaders[name];\n if (value === undefined) continue;\n flat[name.toLowerCase()] = Array.isArray(value) ? value.join(', ') : String(value);\n }\n }\n const connection = deriveConnectionFromHeaders(flat);\n return {\n headers,\n ...(apiKeyFormat && { apiKeyFormat }),\n ...(platformHeaders && { platformHeaders }),\n ...(connection && { connection }),\n ...(cookieCount !== undefined && { cookieCount }),\n schemaVersion: CAPTURE_SCHEMA_VERSION,\n };\n}\n"],"mappings":"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","names":[]}
@@ -12,7 +12,7 @@
12
12
  * Pure regex — no Node built-ins, edge-safe. Detection is regex-only for
13
13
  * v2.9 — TLS fingerprinting / behavioural signatures are deferred.
14
14
  */
15
- type PlatformAgentVendor = 'claude' | 'chatgpt' | 'gemini' | 'cursor' | 'goose' | 'perplexity' | 'chatgpt-atlas' | 'perplexity-comet' | 'astrasync-bridge' | 'astrasync-sdk' | 'unknown';
15
+ type PlatformAgentVendor = 'claude' | 'chatgpt' | 'gemini' | 'cursor' | 'goose' | 'perplexity' | 'grok' | 'meta-ai' | 'chatgpt-atlas' | 'perplexity-comet' | 'astrasync-bridge' | 'astrasync-sdk' | 'unknown';
16
16
  interface PlatformFingerprint {
17
17
  vendor: PlatformAgentVendor;
18
18
  /** A stable identifier used as the orphan-agent dedup key. */
@@ -48,6 +48,13 @@ interface PlatformDetectionInput {
48
48
  * provider — registry validation rejects ASN-only signatures.
49
49
  */
50
50
  asn?: string;
51
+ /**
52
+ * Round 31 — bot labels a customer's WAF attached to the request (AWS Bot
53
+ * Control `…:bot:name:x`, Akamai Bot Manager bot name, Cloudflare
54
+ * verified-bot category, Imperva client classification). Present only on
55
+ * rows read from WAF security logs. Matched against `wafLabelPatterns`.
56
+ */
57
+ wafLabels?: string[];
51
58
  }
52
59
  /**
53
60
  * A signature definition, generic over the vendor key. The seed constant
@@ -76,6 +83,8 @@ interface PlatformSignatureDef<V extends string = string> {
76
83
  ja4Patterns?: RegExp[];
77
84
  /** Exact ASN strings this signature matches (never regex). */
78
85
  asnList?: string[];
86
+ /** Patterns tested against each WAF bot label (round 31; see PlatformDetectionInput.wafLabels). */
87
+ wafLabelPatterns?: RegExp[];
79
88
  }
80
89
  /**
81
90
  * A fingerprint whose vendor may be a dynamically-promoted (non-seed) key.
@@ -92,6 +101,16 @@ interface DynamicPlatformFingerprint {
92
101
  * Patterns are intentionally permissive (case-insensitive substring match) since
93
102
  * platform UA strings drift between versions. False positives are tolerable —
94
103
  * the orphan record is informational, not enforcement.
104
+ *
105
+ * Round 31: `wafLabelPatterns` attribute rows read from a customer's WAF
106
+ * security logs by the bot name THEIR WAF assigned; they never match inline
107
+ * traffic (no labels there).
108
+ *
109
+ * NOT detectable here, by design (Oct 2026): Meta's Muse and OpenAI's Dots
110
+ * publish no user agent and browse through real Chromium sessions that are
111
+ * indistinguishable from a person's. There is no signature to find — don't
112
+ * chase one. Verified identity (an ASTRA-id the agent presents) is the only
113
+ * durable way to know such an agent is there; that is the product's point.
95
114
  */
96
115
  declare const PLATFORM_AGENT_SIGNATURES: ReadonlyArray<PlatformSignatureDef<PlatformAgentVendor>>;
97
116
  /**
@@ -12,7 +12,7 @@
12
12
  * Pure regex — no Node built-ins, edge-safe. Detection is regex-only for
13
13
  * v2.9 — TLS fingerprinting / behavioural signatures are deferred.
14
14
  */
15
- type PlatformAgentVendor = 'claude' | 'chatgpt' | 'gemini' | 'cursor' | 'goose' | 'perplexity' | 'chatgpt-atlas' | 'perplexity-comet' | 'astrasync-bridge' | 'astrasync-sdk' | 'unknown';
15
+ type PlatformAgentVendor = 'claude' | 'chatgpt' | 'gemini' | 'cursor' | 'goose' | 'perplexity' | 'grok' | 'meta-ai' | 'chatgpt-atlas' | 'perplexity-comet' | 'astrasync-bridge' | 'astrasync-sdk' | 'unknown';
16
16
  interface PlatformFingerprint {
17
17
  vendor: PlatformAgentVendor;
18
18
  /** A stable identifier used as the orphan-agent dedup key. */
@@ -48,6 +48,13 @@ interface PlatformDetectionInput {
48
48
  * provider — registry validation rejects ASN-only signatures.
49
49
  */
50
50
  asn?: string;
51
+ /**
52
+ * Round 31 — bot labels a customer's WAF attached to the request (AWS Bot
53
+ * Control `…:bot:name:x`, Akamai Bot Manager bot name, Cloudflare
54
+ * verified-bot category, Imperva client classification). Present only on
55
+ * rows read from WAF security logs. Matched against `wafLabelPatterns`.
56
+ */
57
+ wafLabels?: string[];
51
58
  }
52
59
  /**
53
60
  * A signature definition, generic over the vendor key. The seed constant
@@ -76,6 +83,8 @@ interface PlatformSignatureDef<V extends string = string> {
76
83
  ja4Patterns?: RegExp[];
77
84
  /** Exact ASN strings this signature matches (never regex). */
78
85
  asnList?: string[];
86
+ /** Patterns tested against each WAF bot label (round 31; see PlatformDetectionInput.wafLabels). */
87
+ wafLabelPatterns?: RegExp[];
79
88
  }
80
89
  /**
81
90
  * A fingerprint whose vendor may be a dynamically-promoted (non-seed) key.
@@ -92,6 +101,16 @@ interface DynamicPlatformFingerprint {
92
101
  * Patterns are intentionally permissive (case-insensitive substring match) since
93
102
  * platform UA strings drift between versions. False positives are tolerable —
94
103
  * the orphan record is informational, not enforcement.
104
+ *
105
+ * Round 31: `wafLabelPatterns` attribute rows read from a customer's WAF
106
+ * security logs by the bot name THEIR WAF assigned; they never match inline
107
+ * traffic (no labels there).
108
+ *
109
+ * NOT detectable here, by design (Oct 2026): Meta's Muse and OpenAI's Dots
110
+ * publish no user agent and browse through real Chromium sessions that are
111
+ * indistinguishable from a person's. There is no signature to find — don't
112
+ * chase one. Verified identity (an ASTRA-id the agent presents) is the only
113
+ * durable way to know such an agent is there; that is the product's point.
95
114
  */
96
115
  declare const PLATFORM_AGENT_SIGNATURES: ReadonlyArray<PlatformSignatureDef<PlatformAgentVendor>>;
97
116
  /**
@@ -32,7 +32,8 @@ var PLATFORM_AGENT_SIGNATURES = [
32
32
  uaPatterns: [/claude/i, /anthropic/i],
33
33
  agentCardPatterns: [/anthropic\.com/i, /claude\.ai/i],
34
34
  apiKeyFormatPatterns: [/^sk-ant/i],
35
- headerPatterns: [/^anthropic-version/i, /^anthropic-beta/i, /^x-anthropic-/i]
35
+ headerPatterns: [/^anthropic-version/i, /^anthropic-beta/i, /^x-anthropic-/i],
36
+ wafLabelPatterns: [/\bclaude/i, /\banthropic/i]
36
37
  },
37
38
  {
38
39
  vendor: "chatgpt",
@@ -43,7 +44,14 @@ var PLATFORM_AGENT_SIGNATURES = [
43
44
  uaPatterns: [/chatgpt/i, /openai/i, /gpt-?\d/i, /\bGPTBot\b/i, /\bOAI-SearchBot\b/i],
44
45
  agentCardPatterns: [/openai\.com/i, /chatgpt\.com/i],
45
46
  apiKeyFormatPatterns: [/^sk-proj/i, /^sk-svcacct/i],
46
- headerPatterns: [/^openai-organization/i, /^openai-project/i, /^openai-version/i]
47
+ headerPatterns: [
48
+ /^openai-organization/i,
49
+ /^openai-project/i,
50
+ /^openai-version/i,
51
+ // ChatGPT agent signs with Web Bot Auth under this directory host.
52
+ /^signature-agent: "?https:\/\/chatgpt\.com/i
53
+ ],
54
+ wafLabelPatterns: [/\bgptbot\b/i, /\bchatgpt/i, /\boai-searchbot\b/i, /\bopenai/i]
47
55
  },
48
56
  {
49
57
  vendor: "gemini",
@@ -64,7 +72,8 @@ var PLATFORM_AGENT_SIGNATURES = [
64
72
  ],
65
73
  agentCardPatterns: [/gemini\.google\.com/i, /ai\.google\.dev/i],
66
74
  apiKeyFormatPatterns: [/^aiza/i],
67
- headerPatterns: [/^x-goog-user-project/i, /^x-goog-api-client/i]
75
+ headerPatterns: [/^x-goog-user-project/i, /^x-goog-api-client/i],
76
+ wafLabelPatterns: [/\bgoogle-extended\b/i, /\bgoogleother\b/i, /\bgemini/i]
68
77
  },
69
78
  {
70
79
  vendor: "cursor",
@@ -83,7 +92,31 @@ var PLATFORM_AGENT_SIGNATURES = [
83
92
  displayName: "Perplexity",
84
93
  uaPatterns: [/perplexity/i],
85
94
  agentCardPatterns: [/perplexity\.ai/i],
86
- apiKeyFormatPatterns: [/^pplx-/i]
95
+ apiKeyFormatPatterns: [/^pplx-/i],
96
+ wafLabelPatterns: [/\bperplexity/i]
97
+ },
98
+ // Round 30.1: xAI's documented crawlers (GrokBot/1.0, xAI-Grok/1.0,
99
+ // Grok-DeepSearch/1.0). Anchored, versioned tokens — never in a stock UA.
100
+ {
101
+ vendor: "grok",
102
+ displayName: "xAI Grok",
103
+ uaPatterns: [/\bGrokBot\//i, /\bxAI-Grok\//i, /\bGrok-DeepSearch\//i],
104
+ agentCardPatterns: [/\bx\.ai\b/i, /grok\.com/i],
105
+ apiKeyFormatPatterns: [/^xai-/i],
106
+ wafLabelPatterns: [/\bgrok/i, /\bxai\b/i]
107
+ },
108
+ // Round 30.1: Meta AI's index and fetch crawlers. facebookexternalhit
109
+ // (link previews) is deliberately NOT here — it stays a generic crawler.
110
+ {
111
+ vendor: "meta-ai",
112
+ displayName: "Meta AI",
113
+ uaPatterns: [/\bMeta-WebIndexer\b/i, /\bMeta-ExternalFetcher\b/i],
114
+ agentCardPatterns: [/meta\.ai/i],
115
+ wafLabelPatterns: [
116
+ /\bmeta-webindexer\b/i,
117
+ /\bmeta-externalfetcher\b/i,
118
+ /\bmeta-externalagent\b/i
119
+ ]
87
120
  },
88
121
  // Agentic browsers — versioned-token patterns ONLY (a stock Chrome UA must
89
122
  // never match). Distinct vendor keys (not 'chatgpt'/'perplexity'): the
@@ -131,6 +164,7 @@ function matchPlatformSignature(signatures, input) {
131
164
  const apiKeyFormat = input.apiKeyFormat ?? "";
132
165
  const ja4 = input.ja4 ?? "";
133
166
  const asn = input.asn ?? "";
167
+ const wafLabels = input.wafLabels ?? [];
134
168
  const headerLines = [];
135
169
  if (input.platformHeaders) {
136
170
  for (const [name, value] of Object.entries(input.platformHeaders)) {
@@ -144,7 +178,8 @@ function matchPlatformSignature(signatures, input) {
144
178
  const headerMatch = headerLines.length > 0 && sig.headerPatterns?.some((p) => headerLines.some((line) => p.test(line)));
145
179
  const ja4Match = ja4.length > 0 && sig.ja4Patterns?.some((p) => p.test(ja4));
146
180
  const asnMatch = asn.length > 0 && sig.asnList?.includes(asn);
147
- if (uaMatch || cardMatch || keyMatch || headerMatch || ja4Match || asnMatch) {
181
+ const wafMatch = wafLabels.length > 0 && sig.wafLabelPatterns?.some((p) => wafLabels.some((label) => p.test(label)));
182
+ if (uaMatch || cardMatch || keyMatch || headerMatch || ja4Match || asnMatch || wafMatch) {
148
183
  const uaBase = ua.split(/[\s/]/)[0]?.toLowerCase() ?? "";
149
184
  const cardHost = (() => {
150
185
  try {
@@ -153,7 +188,7 @@ function matchPlatformSignature(signatures, input) {
153
188
  return "";
154
189
  }
155
190
  })();
156
- const keyBasis = uaBase || cardHost || apiKeyFormat || ja4 || asn && `asn${asn}` || "unknown";
191
+ const keyBasis = uaBase || cardHost || apiKeyFormat || ja4 || asn && `asn${asn}` || wafMatch && `waf:${wafLabels[0]?.toLowerCase()}` || "unknown";
157
192
  return {
158
193
  vendor: sig.vendor,
159
194
  fingerprintKey: `${sig.vendor}:${keyBasis}`,