@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
@@ -0,0 +1,45 @@
1
+ /** The payment facts a nonce is cryptographically bound to. */
2
+ export interface NonceBinding {
3
+ amount: string;
4
+ payTo: string;
5
+ network: string;
6
+ }
7
+ /** Mint a nonce bound to a payment requirement, valid for NONCE_TTL_SECONDS. */
8
+ export declare function issueNonce(binding: NonceBinding, now: number): string;
9
+ /**
10
+ * The "has this nonce been spent?" store — the one piece of nonce state that
11
+ * isn't carried inside the nonce itself. Issuance is stateless (the HMAC is the
12
+ * proof); only single-use enforcement needs memory. Behind this seam it can be
13
+ * an in-process Map (default) or a shared table (Supabase) so the guarantee
14
+ * survives across serverless instances.
15
+ */
16
+ export interface ConsumedStore {
17
+ /**
18
+ * Atomically mark `nonce` as spent. Resolve `true` if this call was the first
19
+ * to spend it, `false` if it was already spent (a replay). Must be a single
20
+ * atomic step — a check-then-set with a gap would let two concurrent replays
21
+ * both win.
22
+ */
23
+ consume(nonce: string, expMs: number, now: number): Promise<boolean>;
24
+ }
25
+ /**
26
+ * Build a single-use store using the configured backend. Each caller gets its
27
+ * own instance — the memory backend is a private Map, and the Supabase backend
28
+ * shares the nonces table but callers namespace their keys (e.g. the PoP path
29
+ * prefixes `pop:`) so distinct uses never collide. Reused by the holder-of-key
30
+ * proof verifier (pop.ts) so its replay protection inherits the same seam.
31
+ */
32
+ export declare function makeConsumedStore(): ConsumedStore;
33
+ export type ConsumeResult = {
34
+ ok: true;
35
+ } | {
36
+ ok: false;
37
+ error: string;
38
+ };
39
+ /**
40
+ * Validate a nonce against a payment binding and spend it. Fails closed on a
41
+ * bad shape, a forged/mismatched HMAC, expiry, or replay. The signature/expiry
42
+ * checks run before we touch the store, so a forged nonce never hits the DB.
43
+ */
44
+ export declare function consumeNonce(nonce: string, binding: NonceBinding, now: number): Promise<ConsumeResult>;
45
+ //# sourceMappingURL=nonce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nonce.d.ts","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"AAwCA,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAQD,gFAAgF;AAChF,wBAAgB,UAAU,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAIrE;AAED;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACtE;AAyCD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,IAAI,aAAa,CAEjD;AAID,MAAM,MAAM,aAAa,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExE;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,aAAa,CAAC,CAmBxB"}
package/dist/nonce.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Payment nonces — replay protection for the x402 handshake.
3
+ *
4
+ * Without this, a captured `payment-signature` could be replayed forever to read
5
+ * for free (mock mode has no chain to stop it; even live, defence-in-depth is
6
+ * cheap). Each 402 carries a fresh nonce the agent must echo in its payment.
7
+ *
8
+ * Design: issuance is STATELESS — the nonce is its own proof. It's
9
+ * `${expMs}.${rand}.${hmac}`
10
+ * where the HMAC is taken over the expiry, the random salt, AND the payment
11
+ * binding (amount + payTo + network). Two consequences:
12
+ * - a nonce minted for a $0.001 read can't be reused to satisfy a $0.005
13
+ * citation — the binding wouldn't match.
14
+ * - the gate needs no memory of what it issued to validate a nonce later.
15
+ *
16
+ * The only state is a CONSUMED set so a valid nonce works exactly once. It lives
17
+ * behind the `ConsumedStore` seam below: an in-process Map by default (single
18
+ * instance), or a shared Supabase table when NONCE_BACKEND=supabase — which is
19
+ * what makes the gate safe to run as many serverless instances (Vercel). For
20
+ * either multi-instance path, also set TOLLGATE_SECRET so every instance signs
21
+ * and verifies nonces with the same key.
22
+ */
23
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
24
+ import { getConfig, supabaseRest } from "@naulon/shared";
25
+ const cfg = getConfig();
26
+ const secret = cfg.TOLLGATE_SECRET ??
27
+ (() => {
28
+ const ephemeral = randomBytes(32).toString("hex");
29
+ console.warn("[tollgate] TOLLGATE_SECRET not set — using an ephemeral nonce secret. " +
30
+ "Outstanding 402s are invalidated on restart; set TOLLGATE_SECRET for stability/multi-instance.");
31
+ return ephemeral;
32
+ })();
33
+ const ttlMs = cfg.NONCE_TTL_SECONDS * 1000;
34
+ function sign(expMs, rand, b) {
35
+ return createHmac("sha256", secret)
36
+ .update(`${expMs}.${rand}.${b.amount}.${b.payTo}.${b.network}`)
37
+ .digest("hex");
38
+ }
39
+ /** Mint a nonce bound to a payment requirement, valid for NONCE_TTL_SECONDS. */
40
+ export function issueNonce(binding, now) {
41
+ const expMs = now + ttlMs;
42
+ const rand = randomBytes(12).toString("hex");
43
+ return `${expMs}.${rand}.${sign(expMs, rand, binding)}`;
44
+ }
45
+ /** Default store: an in-process Map. Correct for a single instance. */
46
+ function memoryStore() {
47
+ // nonce -> expiry ms. A present key means "already spent".
48
+ const consumed = new Map();
49
+ const sweep = (now) => {
50
+ for (const [n, exp] of consumed)
51
+ if (exp <= now)
52
+ consumed.delete(n);
53
+ };
54
+ return {
55
+ async consume(nonce, expMs, now) {
56
+ if (consumed.has(nonce))
57
+ return false;
58
+ if (consumed.size > 10_000)
59
+ sweep(now); // amortized cleanup under load
60
+ consumed.set(nonce, expMs);
61
+ return true;
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Shared store: a Supabase table with `nonce` as the primary key. The DB is the
67
+ * arbiter of single-use — we INSERT and let the unique constraint decide. With
68
+ * `resolution=ignore-duplicates` a conflicting insert is silently dropped and,
69
+ * thanks to `return=representation`, the response is an EMPTY array — so "a row
70
+ * came back" means we won the insert (fresh), and "[]" means someone already
71
+ * spent it (replay). One round-trip, atomic, race-safe across instances.
72
+ */
73
+ function supabaseStore() {
74
+ const table = getConfig().SUPABASE_NONCES_TABLE;
75
+ return {
76
+ async consume(nonce, expMs) {
77
+ const rows = (await supabaseRest(`/rest/v1/${table}?on_conflict=nonce`, {
78
+ method: "POST",
79
+ headers: { Prefer: "resolution=ignore-duplicates,return=representation" },
80
+ body: JSON.stringify([{ nonce, exp_ms: expMs }]),
81
+ }));
82
+ return rows.length > 0;
83
+ },
84
+ };
85
+ }
86
+ /**
87
+ * Build a single-use store using the configured backend. Each caller gets its
88
+ * own instance — the memory backend is a private Map, and the Supabase backend
89
+ * shares the nonces table but callers namespace their keys (e.g. the PoP path
90
+ * prefixes `pop:`) so distinct uses never collide. Reused by the holder-of-key
91
+ * proof verifier (pop.ts) so its replay protection inherits the same seam.
92
+ */
93
+ export function makeConsumedStore() {
94
+ return cfg.NONCE_BACKEND === "supabase" ? supabaseStore() : memoryStore();
95
+ }
96
+ const store = makeConsumedStore();
97
+ /**
98
+ * Validate a nonce against a payment binding and spend it. Fails closed on a
99
+ * bad shape, a forged/mismatched HMAC, expiry, or replay. The signature/expiry
100
+ * checks run before we touch the store, so a forged nonce never hits the DB.
101
+ */
102
+ export async function consumeNonce(nonce, binding, now) {
103
+ const parts = nonce.split(".");
104
+ if (parts.length !== 3)
105
+ return { ok: false, error: "malformed nonce" };
106
+ const [expStr, rand, mac] = parts;
107
+ const expMs = Number(expStr);
108
+ if (!Number.isFinite(expMs))
109
+ return { ok: false, error: "malformed nonce" };
110
+ if (expMs <= now)
111
+ return { ok: false, error: "nonce expired" };
112
+ const expected = sign(expMs, rand, binding);
113
+ // Constant-time compare; lengths match (both hex SHA-256) so this is safe.
114
+ if (mac.length !== expected.length || !timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) {
115
+ return { ok: false, error: "invalid nonce signature" };
116
+ }
117
+ if (!(await store.consume(nonce, expMs, now))) {
118
+ return { ok: false, error: "nonce already used (replay)" };
119
+ }
120
+ return { ok: true };
121
+ }
122
+ //# sourceMappingURL=nonce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nonce.js","sourceRoot":"","sources":["../src/nonce.ts"],"names":[],"mappings":"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"}
package/dist/pop.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { type CitationLicenseClaims } from "@naulon/shared";
2
+ export interface PopContext {
3
+ claims: CitationLicenseClaims;
4
+ slug: string;
5
+ /** The gate identity (= license aud); pins the proof to this deployment. */
6
+ identity: string;
7
+ /** Epoch ms (the gate clock). */
8
+ now: number;
9
+ }
10
+ /**
11
+ * Verify a proof-of-possession header for a holder-of-key license. Returns true
12
+ * only if the proof is fresh, single-use, and signed by the bound wallet.
13
+ */
14
+ export declare function verifyPopProof(proofHeader: string, ctx: PopContext): Promise<boolean>;
15
+ //# sourceMappingURL=pop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pop.d.ts","sourceRoot":"","sources":["../src/pop.ts"],"names":[],"mappings":"AAsBA,OAAO,EAA0C,KAAK,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAepG,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,qBAAqB,CAAC;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;IACjB,iCAAiC;IACjC,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAoC3F"}
package/dist/pop.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Holder-of-key proof-of-possession (P5). See docs/citation-license.md.
3
+ *
4
+ * A holder-of-key license carries a `cnf` claim binding it to the payer wallet.
5
+ * Re-reading it free is no longer a bearer right: the caller must prove it holds
6
+ * that wallet's key by signing a fresh challenge (EIP-191 personal_sign). So a
7
+ * captured `X-Naulon-License` header is worthless without the private key.
8
+ *
9
+ * The proof rides in `X-Naulon-Proof: <ts>.<nonce>.<sig>` where the agent signed
10
+ * `popMessage({ aud, jti, slug, ts, nonce })`. The gate reconstructs that exact
11
+ * message from the (already signature-verified) license claims plus the proof's
12
+ * own ts/nonce, recovers the signer with viem, and accepts iff:
13
+ * - the recovered address equals the wallet named in `cnf` (constant-compare
14
+ * by lowercased hex),
15
+ * - `ts` is within ±LICENSE_POP_WINDOW_SECONDS of the gate clock (freshness),
16
+ * - the (jti, nonce) pair has not been spent — single-use via the same
17
+ * ConsumedStore seam the 402 nonces use, so replay is closed in-window too.
18
+ *
19
+ * Fails CLOSED: any defect returns false, which drops the caller to the normal
20
+ * 402 path (pay again) — never a 500, never a free pass.
21
+ */
22
+ import { recoverMessageAddress } from "viem";
23
+ import { getConfig, popBoundAddress, popMessage } from "@naulon/shared";
24
+ import { makeConsumedStore } from "./nonce.js";
25
+ const cfg = getConfig();
26
+ const windowSec = cfg.LICENSE_POP_WINDOW_SECONDS;
27
+ // Single-use store for proof nonces. Keys are namespaced `pop:<jti>:<nonce>` so
28
+ // they never collide with the 402 payment nonces in a shared Supabase table.
29
+ const proofStore = makeConsumedStore();
30
+ /** Hard cap on the proof header before any parsing (an EIP-191 sig is ~132 chars). */
31
+ const MAX_PROOF_LEN = 1024;
32
+ const HEX_NONCE = /^[0-9a-f]{8,64}$/;
33
+ const ETH_SIG = /^0x[0-9a-fA-F]{130}$/;
34
+ /**
35
+ * Verify a proof-of-possession header for a holder-of-key license. Returns true
36
+ * only if the proof is fresh, single-use, and signed by the bound wallet.
37
+ */
38
+ export async function verifyPopProof(proofHeader, ctx) {
39
+ try {
40
+ if (typeof proofHeader !== "string" || proofHeader.length === 0 || proofHeader.length > MAX_PROOF_LEN) {
41
+ return false;
42
+ }
43
+ const bound = popBoundAddress(ctx.claims);
44
+ if (!bound)
45
+ return false; // caller should only invoke this for cnf-bound licenses
46
+ const parts = proofHeader.split(".");
47
+ if (parts.length !== 3)
48
+ return false;
49
+ const [tsStr, nonce, sig] = parts;
50
+ if (!HEX_NONCE.test(nonce) || !ETH_SIG.test(sig))
51
+ return false;
52
+ const ts = Number(tsStr);
53
+ if (!Number.isInteger(ts))
54
+ return false;
55
+ // Freshness: the proof's self-asserted timestamp must sit within the window
56
+ // of the gate clock, in BOTH directions (stale replay and future-dating).
57
+ const nowSec = Math.floor(ctx.now / 1000);
58
+ if (Math.abs(nowSec - ts) > windowSec)
59
+ return false;
60
+ // Reconstruct the exact bytes the holder signed from the trusted claims.
61
+ const message = popMessage({ aud: ctx.identity, jti: ctx.claims.jti, slug: ctx.slug, ts, nonce });
62
+ const recovered = await recoverMessageAddress({ message, signature: sig });
63
+ if (recovered.toLowerCase() !== bound)
64
+ return false;
65
+ // Single-use: spend (jti, nonce) once. expMs bounds how long we must remember
66
+ // it — exactly the freshness window past the proof's timestamp.
67
+ const key = `pop:${ctx.claims.jti}:${nonce}`;
68
+ const expMs = (ts + windowSec) * 1000;
69
+ if (!(await proofStore.consume(key, expMs, ctx.now)))
70
+ return false; // replay
71
+ return true;
72
+ }
73
+ catch {
74
+ return false; // fail closed on any recover/parse error
75
+ }
76
+ }
77
+ //# sourceMappingURL=pop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pop.js","sourceRoot":"","sources":["../src/pop.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,MAAM,CAAC;AAC7C,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,UAAU,EAA8B,MAAM,gBAAgB,CAAC;AACpG,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE/C,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC;AACxB,MAAM,SAAS,GAAG,GAAG,CAAC,0BAA0B,CAAC;AAEjD,gFAAgF;AAChF,6EAA6E;AAC7E,MAAM,UAAU,GAAG,iBAAiB,EAAE,CAAC;AAEvC,sFAAsF;AACtF,MAAM,aAAa,GAAG,IAAI,CAAC;AAC3B,MAAM,SAAS,GAAG,kBAAkB,CAAC;AACrC,MAAM,OAAO,GAAG,sBAAsB,CAAC;AAWvC;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,WAAmB,EAAE,GAAe;IACvE,IAAI,CAAC;QACH,IAAI,OAAO,WAAW,KAAK,QAAQ,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC,IAAI,WAAW,CAAC,MAAM,GAAG,aAAa,EAAE,CAAC;YACtG,OAAO,KAAK,CAAC;QACf,CAAC;QACD,MAAM,KAAK,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC1C,IAAI,CAAC,KAAK;YAAE,OAAO,KAAK,CAAC,CAAC,wDAAwD;QAElF,MAAM,KAAK,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACrC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,CAAC,GAAG,KAAiC,CAAC;QAE9D,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;QAC/D,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;QACzB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;YAAE,OAAO,KAAK,CAAC;QAExC,4EAA4E;QAC5E,0EAA0E;QAC1E,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC;QAC1C,IAAI,IAAI,CAAC,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,GAAG,SAAS;YAAE,OAAO,KAAK,CAAC;QAEpD,yEAAyE;QACzE,MAAM,OAAO,GAAG,UAAU,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,QAAQ,EAAE,GAAG,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;QAClG,MAAM,SAAS,GAAG,MAAM,qBAAqB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,GAAoB,EAAE,CAAC,CAAC;QAC5F,IAAI,SAAS,CAAC,WAAW,EAAE,KAAK,KAAK;YAAE,OAAO,KAAK,CAAC;QAEpD,8EAA8E;QAC9E,gEAAgE;QAChE,MAAM,GAAG,GAAG,OAAO,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,KAAK,EAAE,CAAC;QAC7C,MAAM,KAAK,GAAG,CAAC,EAAE,GAAG,SAAS,CAAC,GAAG,IAAI,CAAC;QACtC,IAAI,CAAC,CAAC,MAAM,UAAU,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC,CAAC,SAAS;QAE7E,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC,CAAC,yCAAyC;IACzD,CAAC;AACH,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Price a read/citation and resolve who gets paid.
3
+ *
4
+ * Pricing is publisher-agnostic: it asks the resolved publisher's `CreditsResolver`
5
+ * for the article's credits graph, then flattens it to payees via the shared
6
+ * attribution logic. Everything publisher-specific (the credits source, the base
7
+ * price) arrives on the `PublisherConfig` — this function reads no global config, so
8
+ * one gate prices many publishers correctly.
9
+ */
10
+ import { type AuthorShare, type PayoutLeg, type PublisherConfig, type TollKind, type Usdc } from "@naulon/shared";
11
+ export interface Quote {
12
+ slug: string;
13
+ title: string;
14
+ kind: TollKind;
15
+ price: Usdc;
16
+ payees: AuthorShare[];
17
+ /**
18
+ * Additional settlement legs beyond the author payment, resolved from the
19
+ * publisher's `extraLegs` hook. Empty for the single-tenant default and every
20
+ * publisher that declares no extra legs — in which case the 402 and settle path
21
+ * are byte-identical to a plain single-author toll. Additive: these never alter
22
+ * `price`/`payees`; the buyer's total is `price + Σ legs`.
23
+ */
24
+ extraLegs: PayoutLeg[];
25
+ /**
26
+ * Pay co-authors directly on-chain (split-at-source) — carried from the resolved
27
+ * `PublisherConfig.coauthorSplit`. When true AND `payees.length > 1`, `build402`
28
+ * divides the author `price` into the primary's synchronous leg + one deferred leg
29
+ * per other co-author (custody-free). Off / single-author → the stock single
30
+ * author-leg toll. Never changes `price` or `payees` (the recorded truth); only the
31
+ * on-chain leg recipients/amounts.
32
+ */
33
+ coauthorSplit: boolean;
34
+ /**
35
+ * Optional reconciliation id for the on-chain memo (Arc only). When set AND the
36
+ * active network ships the Memo predeploy, the synchronous author leg settles via
37
+ * the self-relay path and emits a `Memo` event keyed by this id (keccak256'd to
38
+ * `bytes32` if not already 32-byte hex) — tying the settlement to a citation /
39
+ * license id for offchain reconciliation. Absent, or on a memo-less network (Base),
40
+ * the settle path is byte-identical to the stock Circle Gateway toll. Supplied by
41
+ * the control plane; the open-core gate never invents
42
+ * one, so the default single-tenant gate is unaffected.
43
+ */
44
+ memoId?: string;
45
+ }
46
+ /**
47
+ * Price a toll event for one publisher. Citations cost more than a single read (a
48
+ * citation has downstream reach), but both resolve to the same author payees.
49
+ * Returns undefined for an article the publisher's credits source doesn't know —
50
+ * the gate treats that as "don't gate".
51
+ */
52
+ export declare function quote(publisher: PublisherConfig, slug: string, kind: TollKind): Promise<Quote | undefined>;
53
+ //# sourceMappingURL=pricing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricing.d.ts","sourceRoot":"","sources":["../src/pricing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,IAAI,EACV,MAAM,gBAAgB,CAAC;AAExB,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,IAAI,CAAC;IACZ,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB;;;;;;OAMG;IACH,SAAS,EAAE,SAAS,EAAE,CAAC;IACvB;;;;;;;OAOG;IACH,aAAa,EAAE,OAAO,CAAC;IACvB;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAsB,KAAK,CACzB,SAAS,EAAE,eAAe,EAC1B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,QAAQ,GACb,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CA2B5B"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Price a read/citation and resolve who gets paid.
3
+ *
4
+ * Pricing is publisher-agnostic: it asks the resolved publisher's `CreditsResolver`
5
+ * for the article's credits graph, then flattens it to payees via the shared
6
+ * attribution logic. Everything publisher-specific (the credits source, the base
7
+ * price) arrives on the `PublisherConfig` — this function reads no global config, so
8
+ * one gate prices many publishers correctly.
9
+ */
10
+ import { resolvePayees, usdc, } from "@naulon/shared";
11
+ /**
12
+ * Price a toll event for one publisher. Citations cost more than a single read (a
13
+ * citation has downstream reach), but both resolve to the same author payees.
14
+ * Returns undefined for an article the publisher's credits source doesn't know —
15
+ * the gate treats that as "don't gate".
16
+ */
17
+ export async function quote(publisher, slug, kind) {
18
+ const credits = await publisher.credits.resolve(slug);
19
+ if (!credits)
20
+ return undefined;
21
+ const price = usdc(kind === "citation" ? publisher.price * publisher.citationMultiplier : publisher.price);
22
+ return {
23
+ slug: credits.slug,
24
+ title: credits.title,
25
+ kind,
26
+ price,
27
+ payees: resolvePayees(credits),
28
+ // Additive secondary legs (none for the single-tenant default). The hook owns
29
+ // all amount math; pricing just carries what it returns through to the quote.
30
+ extraLegs: publisher.extraLegs?.(price, kind) ?? [],
31
+ // Carried through to build402, which owns the split-at-source math (it has the
32
+ // primary-payee tiebreak config). Off unless the resolver opts the publisher in.
33
+ coauthorSplit: publisher.coauthorSplit ?? false,
34
+ // The control plane owns the memo id's format; the core just carries what the
35
+ // hook returns. Spread so an unset hook (or one returning undefined) leaves the
36
+ // key absent entirely — the settle path then keys the memo off the auth nonce,
37
+ // byte-identical to the stock single-tenant toll.
38
+ ...(() => {
39
+ const memoId = publisher.memoId?.({ slug: credits.slug, kind });
40
+ return memoId ? { memoId } : {};
41
+ })(),
42
+ };
43
+ }
44
+ //# sourceMappingURL=pricing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricing.js","sourceRoot":"","sources":["../src/pricing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,aAAa,EACb,IAAI,GAML,MAAM,gBAAgB,CAAC;AAsCxB;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CACzB,SAA0B,EAC1B,IAAY,EACZ,IAAc;IAEd,MAAM,OAAO,GAAG,MAAM,SAAS,CAAC,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,CAAC,OAAO;QAAE,OAAO,SAAS,CAAC;IAE/B,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,GAAG,SAAS,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IAE3G,OAAO;QACL,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,IAAI;QACJ,KAAK;QACL,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC;QAC9B,8EAA8E;QAC9E,8EAA8E;QAC9E,SAAS,EAAE,SAAS,CAAC,SAAS,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,EAAE;QACnD,+EAA+E;QAC/E,iFAAiF;QACjF,aAAa,EAAE,SAAS,CAAC,aAAa,IAAI,KAAK;QAC/C,8EAA8E;QAC9E,gFAAgF;QAChF,+EAA+E;QAC/E,kDAAkD;QAClD,GAAG,CAAC,GAAG,EAAE;YACP,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAChE,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAClC,CAAC,CAAC,EAAE;KACL,CAAC;AACJ,CAAC"}
@@ -0,0 +1,6 @@
1
+ export interface RevocationStore {
2
+ isRevoked(jti: string): Promise<boolean>;
3
+ revoke(jti: string): Promise<void>;
4
+ }
5
+ export declare const revocations: RevocationStore;
6
+ //# sourceMappingURL=revocation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revocation.d.ts","sourceRoot":"","sources":["../src/revocation.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAkCD,eAAO,MAAM,WAAW,EAAE,eAA0E,CAAC"}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * License revocation seam (the v1 kill switch for a leaked token before its exp).
3
+ *
4
+ * A CLT is a bearer entitlement, so the only defence against a captured-but-
5
+ * unexpired token — beyond the short TTL — is denying its `jti`. This is enforced
6
+ * ONLY on the trusted tiers: the gate's own re-read path and `GET /licenses/:jti`
7
+ * (when LICENSE_ONLINE_CHECK is on). The offline JWKS tier can't consult it by
8
+ * design; that residual window is bounded by the TTL (see docs/citation-license.md).
9
+ *
10
+ * Backend mirrors the event/nonce stores: an in-process Set by default, or a
11
+ * shared Supabase table (`jti` primary key) when a supabase backend is in play so
12
+ * revocations hold across instances.
13
+ */
14
+ import { getConfig, supabaseRest } from "@naulon/shared";
15
+ const cfg = getConfig();
16
+ function memoryRevocation() {
17
+ const revoked = new Set();
18
+ return {
19
+ async isRevoked(jti) {
20
+ return revoked.has(jti);
21
+ },
22
+ async revoke(jti) {
23
+ revoked.add(jti);
24
+ },
25
+ };
26
+ }
27
+ function supabaseRevocation() {
28
+ const table = cfg.SUPABASE_REVOCATIONS_TABLE;
29
+ return {
30
+ async isRevoked(jti) {
31
+ const rows = (await supabaseRest(`/rest/v1/${table}?jti=eq.${encodeURIComponent(jti)}&select=jti&limit=1`));
32
+ return rows.length > 0;
33
+ },
34
+ async revoke(jti) {
35
+ await supabaseRest(`/rest/v1/${table}?on_conflict=jti`, {
36
+ method: "POST",
37
+ headers: { Prefer: "resolution=ignore-duplicates" },
38
+ body: JSON.stringify([{ jti }]),
39
+ });
40
+ },
41
+ };
42
+ }
43
+ const usesSupabase = cfg.EVENTS_BACKEND === "supabase" || cfg.NONCE_BACKEND === "supabase";
44
+ export const revocations = usesSupabase ? supabaseRevocation() : memoryRevocation();
45
+ //# sourceMappingURL=revocation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revocation.js","sourceRoot":"","sources":["../src/revocation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEzD,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC;AAOxB,SAAS,gBAAgB;IACvB,MAAM,OAAO,GAAG,IAAI,GAAG,EAAU,CAAC;IAClC,OAAO;QACL,KAAK,CAAC,SAAS,CAAC,GAAG;YACjB,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC1B,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,GAAG;YACd,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC;KACF,CAAC;AACJ,CAAC;AAED,SAAS,kBAAkB;IACzB,MAAM,KAAK,GAAG,GAAG,CAAC,0BAA0B,CAAC;IAC7C,OAAO;QACL,KAAK,CAAC,SAAS,CAAC,GAAG;YACjB,MAAM,IAAI,GAAG,CAAC,MAAM,YAAY,CAC9B,YAAY,KAAK,WAAW,kBAAkB,CAAC,GAAG,CAAC,qBAAqB,CACzE,CAAc,CAAC;YAChB,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;QACzB,CAAC;QACD,KAAK,CAAC,MAAM,CAAC,GAAG;YACd,MAAM,YAAY,CAAC,YAAY,KAAK,kBAAkB,EAAE;gBACtD,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,MAAM,EAAE,8BAA8B,EAAE;gBACnD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC;aAChC,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,YAAY,GAAG,GAAG,CAAC,cAAc,KAAK,UAAU,IAAI,GAAG,CAAC,aAAa,KAAK,UAAU,CAAC;AAC3F,MAAM,CAAC,MAAM,WAAW,GAAoB,YAAY,CAAC,CAAC,CAAC,kBAAkB,EAAE,CAAC,CAAC,CAAC,gBAAgB,EAAE,CAAC"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@naulon/enforce",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/naulonapp/naulon.git",
7
+ "directory": "packages/enforce"
8
+ },
9
+ "description": "Runtime-agnostic naulon toll-decision kernel + in-app enforcement middleware. The neutral low-level core shared by @naulon/tollgate (the gate) and @naulon/sdk (the publisher SDK) — no dependency cycle.",
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./next": {
20
+ "types": "./dist/enforce/next.d.ts",
21
+ "import": "./dist/enforce/next.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.json",
29
+ "prepublishOnly": "npm run build",
30
+ "test": "node --import tsx --test \"src/**/*.test.ts\""
31
+ },
32
+ "dependencies": {
33
+ "@naulon/shared": "^0.0.1",
34
+ "viem": "^2.52.2"
35
+ },
36
+ "peerDependencies": {
37
+ "next": ">=15"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "next": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "engines": {
45
+ "node": ">=22"
46
+ }
47
+ }