@naulon/enforce 0.1.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 (66) hide show
  1. package/README.md +89 -0
  2. package/dist/agentDetect.d.ts +94 -0
  3. package/dist/agentDetect.d.ts.map +1 -0
  4. package/dist/agentDetect.js +149 -0
  5. package/dist/agentDetect.js.map +1 -0
  6. package/dist/botAuth.d.ts +159 -0
  7. package/dist/botAuth.d.ts.map +1 -0
  8. package/dist/botAuth.js +659 -0
  9. package/dist/botAuth.js.map +1 -0
  10. package/dist/build402.d.ts +46 -0
  11. package/dist/build402.d.ts.map +1 -0
  12. package/dist/build402.js +137 -0
  13. package/dist/build402.js.map +1 -0
  14. package/dist/decide.d.ts +125 -0
  15. package/dist/decide.d.ts.map +1 -0
  16. package/dist/decide.js +221 -0
  17. package/dist/decide.js.map +1 -0
  18. package/dist/discoverability.d.ts +68 -0
  19. package/dist/discoverability.d.ts.map +1 -0
  20. package/dist/discoverability.js +62 -0
  21. package/dist/discoverability.js.map +1 -0
  22. package/dist/enforce/fetch-handler.d.ts +12 -0
  23. package/dist/enforce/fetch-handler.d.ts.map +1 -0
  24. package/dist/enforce/fetch-handler.js +25 -0
  25. package/dist/enforce/fetch-handler.js.map +1 -0
  26. package/dist/enforce/index.d.ts +11 -0
  27. package/dist/enforce/index.d.ts.map +1 -0
  28. package/dist/enforce/index.js +11 -0
  29. package/dist/enforce/index.js.map +1 -0
  30. package/dist/enforce/middleware.d.ts +27 -0
  31. package/dist/enforce/middleware.d.ts.map +1 -0
  32. package/dist/enforce/middleware.js +88 -0
  33. package/dist/enforce/middleware.js.map +1 -0
  34. package/dist/enforce/next.d.ts +24 -0
  35. package/dist/enforce/next.d.ts.map +1 -0
  36. package/dist/enforce/next.js +32 -0
  37. package/dist/enforce/next.js.map +1 -0
  38. package/dist/enforce/quote-source.d.ts +31 -0
  39. package/dist/enforce/quote-source.d.ts.map +1 -0
  40. package/dist/enforce/quote-source.js +29 -0
  41. package/dist/enforce/quote-source.js.map +1 -0
  42. package/dist/index.d.ts +23 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +31 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/license.d.ts +16 -0
  47. package/dist/license.d.ts.map +1 -0
  48. package/dist/license.js +18 -0
  49. package/dist/license.js.map +1 -0
  50. package/dist/nonce.d.ts +45 -0
  51. package/dist/nonce.d.ts.map +1 -0
  52. package/dist/nonce.js +122 -0
  53. package/dist/nonce.js.map +1 -0
  54. package/dist/pop.d.ts +15 -0
  55. package/dist/pop.d.ts.map +1 -0
  56. package/dist/pop.js +77 -0
  57. package/dist/pop.js.map +1 -0
  58. package/dist/pricing.d.ts +53 -0
  59. package/dist/pricing.d.ts.map +1 -0
  60. package/dist/pricing.js +44 -0
  61. package/dist/pricing.js.map +1 -0
  62. package/dist/revocation.d.ts +6 -0
  63. package/dist/revocation.d.ts.map +1 -0
  64. package/dist/revocation.js +45 -0
  65. package/dist/revocation.js.map +1 -0
  66. package/package.json +47 -0
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @naulon/enforce
2
+
3
+ The runtime-agnostic toll-decision kernel plus the in-app enforcement middleware.
4
+
5
+ This is the neutral low-level core that both `@naulon/tollgate` (the gate shell —
6
+ the reverse-proxy that boots `createApp`) and `@naulon/sdk` (the publisher SDK)
7
+ sit **above**, with no dependency cycle. It depends only on `@naulon/shared` (and
8
+ `viem`, for the holder-of-key proof). The heavy settlement path — the Circle
9
+ facilitator, the pending-leg drain — stays in `@naulon/tollgate`; nothing here
10
+ imports `@circle-fin/x402-batching`.
11
+
12
+ ## Why it exists
13
+
14
+ `decide()` is one pure function: given a web `Request` and a known publisher, it
15
+ returns a verdict — serve free, refuse, or `402` with the payment legs — and
16
+ performs **no** side effects (no proxy, no settle, no observe). Extracting it lets
17
+ two very different runtimes reach the *same* verdict:
18
+
19
+ - **The gate** (`@naulon/tollgate`) runs `decide()` inside its Hono reverse proxy.
20
+ - **In-app middleware** runs the *identical* `decide()` in the publisher's own app,
21
+ so an agent's request reaches the origin directly instead of routing through the
22
+ fleet's single egress IP (which an origin edge can rate-limit).
23
+
24
+ Both build a byte-identical `402`, because they share this code.
25
+
26
+ ## Exports
27
+
28
+ ### `@naulon/enforce`
29
+
30
+ The decision kernel and the framework-agnostic middleware core:
31
+
32
+ - `decide(input)` — the pure verdict function.
33
+ - `naulonMiddleware(opts)` — takes a `Request`, returns `{ response, setHeaders }`:
34
+ a `Response` to short-circuit (`402`/`403`), or `null` to let the app render
35
+ (with `setHeaders` to attach to the app's response on a paid pass).
36
+ - `withNaulon(handler, opts)` — wrap a generic `fetch` handler.
37
+ - `localQuoteSource(fn)` / `httpQuoteSource(url, key)` — pluggable price + payees.
38
+ - The classification, Web Bot Auth, nonce, and proof primitives (`classify`,
39
+ `verifyBotAuth`, …) and the x402 build side (`build402`, `buildRequirements`).
40
+
41
+ ### `@naulon/enforce/next`
42
+
43
+ - `createNaulonMiddleware(opts, NextResponse)` — the Next.js App Router adapter.
44
+ It has no hard `next` dependency; you inject `NextResponse` (your app already has
45
+ it), keeping the core framework-agnostic.
46
+
47
+ ## Usage
48
+
49
+ ```ts
50
+ // middleware.ts (Next.js App Router)
51
+ import { NextResponse } from "next/server";
52
+ import { createNaulonMiddleware } from "@naulon/enforce/next";
53
+ import { httpQuoteSource } from "@naulon/enforce";
54
+
55
+ export const middleware = createNaulonMiddleware(
56
+ {
57
+ publisher: { id: "your-site", articlePrefixes: ["/articles/"] },
58
+ quote: httpQuoteSource("https://<your-control-plane>/_naulon/quote", process.env.NAULON_API_KEY!),
59
+ verifyUrl: "https://<your-control-plane>/_naulon/verify",
60
+ apiKey: process.env.NAULON_API_KEY!,
61
+ },
62
+ NextResponse,
63
+ );
64
+
65
+ export const config = { matcher: ["/articles/:path*"] };
66
+ ```
67
+
68
+ `quote` and `verifyUrl` point at whatever runs the money + catalog legs — the
69
+ managed control plane, or your own self-hosted `POST /_naulon/verify` +
70
+ `GET /_naulon/quote`. The middleware never holds funds: it forwards the buyer's
71
+ signed payment to `verifyUrl`, which settles buyer → author directly.
72
+
73
+ ## Layering
74
+
75
+ ```
76
+ @naulon/shared
77
+
78
+ @naulon/enforce ← this package (decision kernel + middleware)
79
+
80
+ @naulon/tollgate (the gate shell runs decide() inside its reverse proxy)
81
+ ```
82
+
83
+ A publisher vendors `@naulon/enforce` directly (it builds to its own `dist/`
84
+ tarball) and wires the middleware; the gate consumes the very same package, which
85
+ is what guarantees both reach an identical verdict. `@naulon/enforce` is
86
+ deliberately NOT re-exported through `@naulon/sdk` — the SDK imports `@naulon/shared`,
87
+ which re-exports the SDK, so an `sdk → enforce` edge would form a declaration-build
88
+ cycle. Keeping enforce standalone (a second, small dependency alongside the SDK)
89
+ avoids that and keeps the package graph a clean chain.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Human vs. machine classification — the hinge of the whole thesis.
3
+ *
4
+ * "Humans read free, forever. Machines pay." Everything downstream trusts this
5
+ * call. The asymmetry that matters:
6
+ *
7
+ * - False positive (human flagged as agent) -> a human hits a 402 paywall.
8
+ * This BREAKS the "open to all" promise. Worst outcome. Bias away from it.
9
+ * - False negative (agent flagged as human) -> a crawler reads free. We lose a
10
+ * micro-toll. Cheap. Tolerable.
11
+ *
12
+ * So the classifier should be conservative: only charge when we're confident the
13
+ * caller is a machine. Signed intent (an agent that *declares* itself via the
14
+ * x402 handshake) is the strongest, cleanest signal — it sidesteps the UA
15
+ * arms race entirely.
16
+ */
17
+ import type { VerifiedAgent } from "./botAuth.ts";
18
+ export type Verdict = {
19
+ kind: "human" | "agent";
20
+ reason: string;
21
+ confidence: number;
22
+ };
23
+ /** Request facts the classifier reasons over (framework-agnostic). */
24
+ export interface RequestSignals {
25
+ userAgent: string;
26
+ /** Present when the caller already speaks x402 (declared intent to pay). */
27
+ hasPaymentHeader: boolean;
28
+ /** Explicit opt-in header an agent may send: `X-Naulon-Agent: <id>`. */
29
+ declaredAgentId: string | null;
30
+ /** Accept header — browsers ask for text/html; many bots don't. */
31
+ accept: string;
32
+ headers: Record<string, string>;
33
+ /**
34
+ * Cryptographically verified Web Bot Auth identity (RFC 9421/Ed25519), when
35
+ * the request carried a valid signature. Computed once in app.ts — only for
36
+ * requests that present the three signature headers; everyone else pays zero
37
+ * cost and classifies exactly as before.
38
+ */
39
+ verifiedAgent?: VerifiedAgent | null;
40
+ }
41
+ /** Per-publisher classification policy the gate supplies from `PublisherConfig`. */
42
+ export interface ClassifyPolicy {
43
+ /**
44
+ * Verified search / discovery crawler UA fragments that read FREE for this
45
+ * publisher (their SEO allowlist). Honored ahead of the known-agent list, so an
46
+ * allowlisted crawler is freed even if its UA also looks like a bot — the point
47
+ * is to never toll the crawlers a publisher needs for indexing. Matched
48
+ * case-insensitively against the user-agent. UA is spoofable; verifying crawler
49
+ * identity (reverse DNS / Web Bot Auth) is a later hardening — for now a
50
+ * publisher's own allowlist is the stored intent we honor.
51
+ */
52
+ seoAllowlist?: string[];
53
+ /**
54
+ * Publisher-charged UA fragments. Honored between the allowlist and the built-in
55
+ * known-agent list: fragments a publisher explicitly charges are classified as
56
+ * agents even when the conservative default would read the UA as browser-shaped or
57
+ * ambiguous (and thus free). Allow wins over charge on overlap.
58
+ */
59
+ chargeList?: string[];
60
+ }
61
+ /**
62
+ * First fragment (case-insensitive substring) of `fragments` found in `ua`,
63
+ * else undefined. The one matching primitive the tri-state policy shares with
64
+ * the allowlist path — swap THIS for verified identity (Web Bot Auth) later.
65
+ */
66
+ export declare function matchUaFragment(ua: string, fragments: string[] | undefined): string | undefined;
67
+ /**
68
+ * Classify a request as human or agent.
69
+ *
70
+ * TODO(you): implement the core heuristic. A starter is provided so the gate
71
+ * runs today, but this decision shapes the product — own it. Consider:
72
+ *
73
+ * 1. Declared intent first. `signals.hasPaymentHeader` or
74
+ * `signals.declaredAgentId` => almost certainly an agent that WANTS to pay.
75
+ * Treat as high-confidence agent. (Already wired below — keep it first.)
76
+ * 2. Known-bot UA match => agent, but lower confidence (UAs are spoofable and
77
+ * churn). Decide how much weight to give a raw UA match.
78
+ * 3. Browser-shaped requests (Accept: text/html, sec-fetch-* headers,
79
+ * Accept-Language present) => lean human. Decide your "looks like a real
80
+ * browser" bar.
81
+ * 4. The ambiguous middle (empty UA, generic Accept, no browser headers).
82
+ * THIS is the real design call: default-allow (favor humans, lose tolls) or
83
+ * default-charge (capture tolls, risk paywalling a human)? Given the
84
+ * asymmetry above, the starter defaults to HUMAN. Change it if you disagree
85
+ * — and write down why.
86
+ *
87
+ * Return a Verdict with a `reason` (it surfaces in logs + the demo) and a
88
+ * `confidence` in [0,1].
89
+ *
90
+ * `policy` carries per-publisher overrides (the SEO allowlist); the single-tenant
91
+ * default passes none and behaves exactly as before.
92
+ */
93
+ export declare function classify(signals: RequestSignals, policy?: ClassifyPolicy): Verdict;
94
+ //# sourceMappingURL=agentDetect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentDetect.d.ts","sourceRoot":"","sources":["../src/agentDetect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,MAAM,OAAO,GAAG;IAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtF,sEAAsE;AACtE,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,EAAE,OAAO,CAAC;IAC1B,wEAAwE;IACxE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC;;;;;OAKG;IACH,aAAa,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;CACtC;AAED,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AA6CD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAI/F;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,cAAc,GAAG,OAAO,CA2DlF"}
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Human vs. machine classification — the hinge of the whole thesis.
3
+ *
4
+ * "Humans read free, forever. Machines pay." Everything downstream trusts this
5
+ * call. The asymmetry that matters:
6
+ *
7
+ * - False positive (human flagged as agent) -> a human hits a 402 paywall.
8
+ * This BREAKS the "open to all" promise. Worst outcome. Bias away from it.
9
+ * - False negative (agent flagged as human) -> a crawler reads free. We lose a
10
+ * micro-toll. Cheap. Tolerable.
11
+ *
12
+ * So the classifier should be conservative: only charge when we're confident the
13
+ * caller is a machine. Signed intent (an agent that *declares* itself via the
14
+ * x402 handshake) is the strongest, cleanest signal — it sidesteps the UA
15
+ * arms race entirely.
16
+ */
17
+ /**
18
+ * Obvious crawler/agent UA fragments. Extend freely — this is a weak signal.
19
+ * Verified against the operators' own published UA docs 2026-07-03 (Anthropic:
20
+ * ClaudeBot/Claude-User/Claude-SearchBot; OpenAI: GPTBot/ChatGPT-User/
21
+ * OAI-SearchBot/OAI-AdsBot). Dropped as undocumented: claude-web, anthropic-ai.
22
+ * Dropped as unmatchable: google-extended (a robots.txt-only token — Google
23
+ * documents NO UA string for it, so a UA fragment could never fire).
24
+ *
25
+ * Two kinds of machine read are charged here:
26
+ * - training/bulk crawlers (gptbot, claudebot, ccbot, bytespider, amazonbot,
27
+ * applebot-extended, meta-externalagent);
28
+ * - user-triggered assistant fetches (chatgpt-user, claude-user,
29
+ * perplexity-user, perplexitybot) — the citation moment itself. These UAs are
30
+ * machine-only (no human browser carries them), so charging them cannot toll
31
+ * a human; an x402-capable agent answers the 402 by paying, which is the
32
+ * product working, not a wall.
33
+ *
34
+ * Note: pure search-indexer UAs (googlebot, bingbot, claude-searchbot,
35
+ * oai-searchbot, …) are deliberately NOT here. Tolling a search crawler silently
36
+ * deindexes the publisher — the opposite of what they want — so indexing reads
37
+ * free by default. A publisher frees additional crawlers (or re-affirms search
38
+ * ones) via `ClassifyPolicy.seoAllowlist`, or charges one via `chargeList`.
39
+ */
40
+ const KNOWN_AGENT_UA = [
41
+ "gptbot",
42
+ "chatgpt-user",
43
+ "claudebot",
44
+ "claude-user",
45
+ "perplexitybot",
46
+ "perplexity-user",
47
+ "ccbot",
48
+ "bytespider",
49
+ "amazonbot",
50
+ "applebot-extended",
51
+ "meta-externalagent",
52
+ "python-requests",
53
+ "node-fetch",
54
+ "axios",
55
+ "curl",
56
+ "wget",
57
+ "langchain",
58
+ ];
59
+ /**
60
+ * First fragment (case-insensitive substring) of `fragments` found in `ua`,
61
+ * else undefined. The one matching primitive the tri-state policy shares with
62
+ * the allowlist path — swap THIS for verified identity (Web Bot Auth) later.
63
+ */
64
+ export function matchUaFragment(ua, fragments) {
65
+ if (!fragments?.length)
66
+ return undefined;
67
+ const lower = ua.toLowerCase();
68
+ return fragments.find((f) => f.length > 0 && lower.includes(f.toLowerCase()));
69
+ }
70
+ /**
71
+ * Classify a request as human or agent.
72
+ *
73
+ * TODO(you): implement the core heuristic. A starter is provided so the gate
74
+ * runs today, but this decision shapes the product — own it. Consider:
75
+ *
76
+ * 1. Declared intent first. `signals.hasPaymentHeader` or
77
+ * `signals.declaredAgentId` => almost certainly an agent that WANTS to pay.
78
+ * Treat as high-confidence agent. (Already wired below — keep it first.)
79
+ * 2. Known-bot UA match => agent, but lower confidence (UAs are spoofable and
80
+ * churn). Decide how much weight to give a raw UA match.
81
+ * 3. Browser-shaped requests (Accept: text/html, sec-fetch-* headers,
82
+ * Accept-Language present) => lean human. Decide your "looks like a real
83
+ * browser" bar.
84
+ * 4. The ambiguous middle (empty UA, generic Accept, no browser headers).
85
+ * THIS is the real design call: default-allow (favor humans, lose tolls) or
86
+ * default-charge (capture tolls, risk paywalling a human)? Given the
87
+ * asymmetry above, the starter defaults to HUMAN. Change it if you disagree
88
+ * — and write down why.
89
+ *
90
+ * Return a Verdict with a `reason` (it surfaces in logs + the demo) and a
91
+ * `confidence` in [0,1].
92
+ *
93
+ * `policy` carries per-publisher overrides (the SEO allowlist); the single-tenant
94
+ * default passes none and behaves exactly as before.
95
+ */
96
+ export function classify(signals, policy) {
97
+ // 1) Declared intent — strongest, spoof-proof-in-spirit signal. Leave first.
98
+ if (signals.hasPaymentHeader) {
99
+ return { kind: "agent", reason: "presented x402 payment header", confidence: 0.99 };
100
+ }
101
+ if (signals.declaredAgentId) {
102
+ return { kind: "agent", reason: `declared agent id ${signals.declaredAgentId}`, confidence: 0.95 };
103
+ }
104
+ // 1b) Verified identity (Web Bot Auth). A valid Ed25519 signature proves WHO
105
+ // is calling, keyed to the operator's own directory — it outranks every
106
+ // UA-derived signal in BOTH directions: a verified allow is the only
107
+ // spoof-proof allowlist (UA rotation can't fake a signature), and a
108
+ // verified agent is charged even behind a browser-shaped UA (rotation
109
+ // can't dodge it). Same fragment vocabulary as the UA lists, matched
110
+ // against the verified directory host — `allow: ["chatgpt.com"]` just
111
+ // works, no new config shape. Publisher block runs in app.ts BEFORE
112
+ // classification (block beats payment) and covers the verified id there.
113
+ if (signals.verifiedAgent) {
114
+ const va = signals.verifiedAgent.agent;
115
+ const allowed = matchUaFragment(va, policy?.seoAllowlist);
116
+ if (allowed) {
117
+ return { kind: "human", reason: `seo allowlist matched verified agent "${allowed}"`, confidence: 0.98 };
118
+ }
119
+ return { kind: "agent", reason: `verified web-bot-auth (${va})`, confidence: 0.98 };
120
+ }
121
+ const ua = signals.userAgent.toLowerCase();
122
+ // 2) SEO allowlist — verified discovery crawlers this publisher wants indexed
123
+ // read FREE. Before the known-bot check so an allowlisted crawler is freed
124
+ // even when its UA also matches a known-bot fragment.
125
+ const allowed = policy?.seoAllowlist?.find((frag) => ua.includes(frag.toLowerCase()));
126
+ if (allowed) {
127
+ return { kind: "human", reason: `seo allowlist matched "${allowed}"`, confidence: 0.9 };
128
+ }
129
+ // 2b) Publisher-charged crawlers — fragments the publisher explicitly tolls.
130
+ // After allow (allow wins) and before the built-in list, so a publisher
131
+ // can charge a crawler the conservative default would read as human.
132
+ const charged = matchUaFragment(signals.userAgent, policy?.chargeList);
133
+ if (charged) {
134
+ return { kind: "agent", reason: `publisher charge policy matched "${charged}"`, confidence: 0.8 };
135
+ }
136
+ // 3) Known-bot UA — weak, spoofable signal.
137
+ const hit = KNOWN_AGENT_UA.find((frag) => ua.includes(frag));
138
+ if (hit) {
139
+ return { kind: "agent", reason: `user-agent matched "${hit}"`, confidence: 0.8 };
140
+ }
141
+ // 4) Browser-shaped => human.
142
+ const looksBrowser = signals.accept.includes("text/html") || "sec-fetch-mode" in signals.headers;
143
+ if (looksBrowser) {
144
+ return { kind: "human", reason: "browser-shaped request", confidence: 0.85 };
145
+ }
146
+ // 5) Ambiguous middle. Starter favors humans (see asymmetry note).
147
+ return { kind: "human", reason: "ambiguous; defaulting to human (free)", confidence: 0.4 };
148
+ }
149
+ //# sourceMappingURL=agentDetect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agentDetect.js","sourceRoot":"","sources":["../src/agentDetect.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AA8CH;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,cAAc,GAAG;IACrB,QAAQ;IACR,cAAc;IACd,WAAW;IACX,aAAa;IACb,eAAe;IACf,iBAAiB;IACjB,OAAO;IACP,YAAY;IACZ,WAAW;IACX,mBAAmB;IACnB,oBAAoB;IACpB,iBAAiB;IACjB,YAAY;IACZ,OAAO;IACP,MAAM;IACN,MAAM;IACN,WAAW;CACZ,CAAC;AAEF;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,EAAU,EAAE,SAA+B;IACzE,IAAI,CAAC,SAAS,EAAE,MAAM;QAAE,OAAO,SAAS,CAAC;IACzC,MAAM,KAAK,GAAG,EAAE,CAAC,WAAW,EAAE,CAAC;IAC/B,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;AAChF,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,QAAQ,CAAC,OAAuB,EAAE,MAAuB;IACvE,6EAA6E;IAC7E,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC;QAC7B,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,+BAA+B,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;IACtF,CAAC;IACD,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;QAC5B,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,qBAAqB,OAAO,CAAC,eAAe,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;IACrG,CAAC;IAED,6EAA6E;IAC7E,4EAA4E;IAC5E,yEAAyE;IACzE,wEAAwE;IACxE,0EAA0E;IAC1E,yEAAyE;IACzE,0EAA0E;IAC1E,wEAAwE;IACxE,6EAA6E;IAC7E,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;QAC1B,MAAM,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC;QACvC,MAAM,OAAO,GAAG,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,YAAY,CAAC,CAAC;QAC1D,IAAI,OAAO,EAAE,CAAC;YACZ,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,yCAAyC,OAAO,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;QAC1G,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,0BAA0B,EAAE,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;IACtF,CAAC;IAED,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC;IAE3C,8EAA8E;IAC9E,8EAA8E;IAC9E,yDAAyD;IACzD,MAAM,OAAO,GAAG,MAAM,EAAE,YAAY,EAAE,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;IACtF,IAAI,OAAO,EAAE,CAAC;QACZ,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,0BAA0B,OAAO,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;IAC1F,CAAC;IAED,6EAA6E;IAC7E,4EAA4E;IAC5E,yEAAyE;IACzE,MAAM,OAAO,GAAG,eAAe,CAAC,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC;IACvE,IAAI,OAAO,EAAE,CAAC;QACZ,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,oCAAoC,OAAO,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;IACpG,CAAC;IAED,4CAA4C;IAC5C,MAAM,GAAG,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7D,IAAI,GAAG,EAAE,CAAC;QACR,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,uBAAuB,GAAG,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;IACnF,CAAC;IAED,8BAA8B;IAC9B,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,gBAAgB,IAAI,OAAO,CAAC,OAAO,CAAC;IACjG,IAAI,YAAY,EAAE,CAAC;QACjB,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,wBAAwB,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC;IAC/E,CAAC;IAED,mEAAmE;IACnE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,uCAAuC,EAAE,UAAU,EAAE,GAAG,EAAE,CAAC;AAC7F,CAAC"}
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Web Bot Auth — RFC 9421 HTTP Message Signatures verifier, scoped to the
3
+ * web-bot-auth profile (draft-meunier-webbotauth-httpsig-protocol-00 +
4
+ * Cloudflare's deployed operational profile).
5
+ *
6
+ * Hand-rolled minimal structured-field subset by design: the full RFC 8941
7
+ * grammar is far more than the three headers need, and the one candidate npm
8
+ * package (web-bot-auth@0.1.x) is a 4-package unaudited tree. This module is
9
+ * self-contained on node:crypto.
10
+ */
11
+ /** One covered component in the signature input: a name plus optional ;key=. */
12
+ export interface CoveredComponent {
13
+ name: string;
14
+ key?: string;
15
+ /**
16
+ * The component identifier exactly as the signer serialized it (quoted name
17
+ * + params). Base lines reuse it verbatim so the base is byte-exact.
18
+ */
19
+ raw: string;
20
+ }
21
+ export interface SignatureInputEntry {
22
+ label: string;
23
+ components: CoveredComponent[];
24
+ params: {
25
+ created?: number;
26
+ expires?: number;
27
+ keyid?: string;
28
+ tag?: string;
29
+ alg?: string;
30
+ nonce?: string;
31
+ };
32
+ /**
33
+ * The member's verbatim SF text (everything after `label=`). Reused as the
34
+ * `@signature-params` serialization so the base is byte-exact with what the
35
+ * signer signed — no canonical re-serialization bugs.
36
+ */
37
+ rawMemberText: string;
38
+ }
39
+ /**
40
+ * Parse a `Signature-Input` header: an SF dictionary whose members are inner
41
+ * lists of covered components followed by the signature params. Returns null
42
+ * on any grammar trouble — the caller treats that as an invalid signature.
43
+ */
44
+ export declare function parseSignatureInput(value: string): SignatureInputEntry[] | null;
45
+ /**
46
+ * Parse a `Signature` header: an SF dictionary of byte sequences
47
+ * (label=:base64:). Returns null on grammar trouble.
48
+ */
49
+ export declare function parseSignatureHeader(value: string): Map<string, Uint8Array> | null;
50
+ /**
51
+ * Parse a `Signature-Agent` header into the directory URL.
52
+ *
53
+ * Accepts the CF operational profile's plain quoted string (what deployed
54
+ * signers send — CF explicitly REJECTS the dictionary form), tolerates the
55
+ * rev-00 dictionary form (first member wins), and normalizes a bare host to
56
+ * https. Returns null when neither shape parses or the URL is unusable.
57
+ */
58
+ export declare function parseSignatureAgent(value: string): string | null;
59
+ /** The request facts the base builder serializes components from. */
60
+ export interface RequestFacts {
61
+ /** Host the request targeted (the `Host` header / URL authority). */
62
+ authority: string;
63
+ method: string;
64
+ path: string;
65
+ targetUri: string;
66
+ /** Lowercase header-name → raw value. */
67
+ headers: Record<string, string>;
68
+ }
69
+ /**
70
+ * Build the RFC 9421 signature base for one Signature-Input entry. Returns
71
+ * null when a covered component is unsupported or unresolvable — the caller
72
+ * treats that as an invalid signature. Supported: `@authority`, `@method`,
73
+ * `@path`, `@target-uri`, plain headers, and dictionary members via `;key=` —
74
+ * everything real web-bot-auth signers cover (CF profile minimum: @authority).
75
+ */
76
+ export declare function buildSignatureBase(entry: SignatureInputEntry, facts: RequestFacts): string | null;
77
+ /**
78
+ * RFC 7638 JWK thumbprint of an Ed25519 public key (the profile's keyid).
79
+ * Required members in lexicographic order: crv, kty, x.
80
+ */
81
+ export declare function jwkThumbprint(x: string): string;
82
+ /** Ed25519-verify `sig` over the UTF-8 `base` with a raw JWK x. Never throws. */
83
+ export declare function verifyEd25519(base: string, sig: Uint8Array, x: string): boolean;
84
+ /** keyid (RFC 7638 thumbprint) → raw Ed25519 JWK x, for one directory. */
85
+ export type DirectoryKeys = Map<string, string>;
86
+ /**
87
+ * LRU + TTL cache of fetched key directories. `null` is a NEGATIVE entry
88
+ * (directory unreachable/unusable) with its own shorter TTL, so a broken
89
+ * operator doesn't get hammered but recovers quickly. One fetch per operator
90
+ * per TTL — verification cost amortizes to zero on the hot path.
91
+ */
92
+ export declare class DirectoryCache {
93
+ private readonly entries;
94
+ private readonly posTtlMs;
95
+ private readonly negTtlMs;
96
+ private readonly cap;
97
+ private readonly now;
98
+ constructor(opts?: {
99
+ posTtlMs?: number;
100
+ negTtlMs?: number;
101
+ cap?: number;
102
+ now?: () => number;
103
+ });
104
+ /** undefined = no usable entry; null = cached negative; Map = cached keys. */
105
+ get(url: string): DirectoryKeys | null | undefined;
106
+ set(url: string, keys: DirectoryKeys | null): void;
107
+ }
108
+ export interface BotAuthOptions {
109
+ fetchFn?: typeof fetch;
110
+ cache?: DirectoryCache;
111
+ /** epoch ms clock, injectable for tests. */
112
+ now?: () => number;
113
+ /**
114
+ * Permit http:// + localhost directories — LOCAL TEST FIXTURES ONLY (the
115
+ * signer walk serves its directory from a loopback port). Never in prod.
116
+ */
117
+ allowInsecureHttp?: boolean;
118
+ clockSkewSec?: number;
119
+ }
120
+ export interface VerifiedAgent {
121
+ /** The operator's directory host — the identity policy fragments match on. */
122
+ agent: string;
123
+ keyid: string;
124
+ }
125
+ export type BotAuthOutcome =
126
+ /** No web-bot-auth signature on the request (or signed for another protocol). */
127
+ {
128
+ status: "absent";
129
+ } | {
130
+ status: "verified";
131
+ agent: VerifiedAgent;
132
+ }
133
+ /** A signature was presented and is WRONG — masquerade telemetry. */
134
+ | {
135
+ status: "invalid";
136
+ reason: string;
137
+ }
138
+ /** Verifier/network trouble — fail open, treat as unsigned. */
139
+ | {
140
+ status: "unverified";
141
+ reason: string;
142
+ };
143
+ /**
144
+ * Verify a request's Web Bot Auth signature, end to end.
145
+ *
146
+ * Outcome discipline (the product decision, not just plumbing):
147
+ * - absent → no signature / not this protocol: the UA path proceeds as if
148
+ * this module didn't exist (byte-identical regression bar).
149
+ * - verified → cryptographic identity: classify() gets `verifiedAgent`.
150
+ * - invalid → a PRESENTED signature failed: still served via the UA path
151
+ * (fail-open), but flagged `sigInvalid` — masquerade telemetry.
152
+ * - unverified → OUR side couldn't verify (directory down, network): fail
153
+ * open with no flag; a verifier outage must never block traffic.
154
+ *
155
+ * No nonce/replay cache in slice 1: the created/expires window (CF profile
156
+ * ~1 minute) bounds replay; a replayed signature buys the same 402 quote.
157
+ */
158
+ export declare function verifyBotAuth(facts: RequestFacts, opts?: BotAuthOptions): Promise<BotAuthOutcome>;
159
+ //# sourceMappingURL=botAuth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"botAuth.d.ts","sourceRoot":"","sources":["../src/botAuth.ts"],"names":[],"mappings":"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"}