@devlab.group/agent-commerce 1.3.0 → 1.4.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.
package/dist/ap2.js ADDED
@@ -0,0 +1,669 @@
1
+ import { AP2_CHECKOUT_MANDATE_VCT, AP2_SIGNING_ALGORITHM, AP2_DIGEST_ALGORITHM, AP2_CHECKOUT_PROFILE, AP2_SPEC_VERSION, AP2_KEY_TYPE, AP2_JWK_CURVE, openSqliteDatabase } from './chunk-7NKWFGBY.js';
2
+ export { AP2_CHECKOUT_MANDATE_VCT, AP2_CHECKOUT_PROFILE, AP2_DEFAULT_CLOCK_SKEW_SECONDS, AP2_MAX_CLOCK_SKEW_SECONDS, AP2_SIGNING_ALGORITHM, AP2_SPEC_VERSION } from './chunk-7NKWFGBY.js';
3
+ import { systemClock, NOOP_LOGGER, CommerceError, PACKAGE_VERSION } from './chunk-ZDVJ7EVL.js';
4
+ import { SignJWT, importPKCS8, importJWK, jwtVerify } from 'jose';
5
+ import canonicalize from 'canonicalize';
6
+ import { decodeSdJwt, splitSdJwt, getClaims } from '@sd-jwt/core';
7
+
8
+ // src/authorization/ap2/errors.ts
9
+ var AP2_REJECTION_REASONS = [
10
+ "malformed_presentation",
11
+ "untrusted_issuer",
12
+ "unknown_key",
13
+ "invalid_signature",
14
+ "unsupported_algorithm",
15
+ "invalid_claims",
16
+ "expired",
17
+ "wrong_audience",
18
+ "unsupported_mandate_type",
19
+ "checkout_binding_failed",
20
+ "purchase_mismatch"
21
+ ];
22
+ var MESSAGES = {
23
+ malformed_presentation: "The authorization is not a well-formed SD-JWT presentation.",
24
+ untrusted_issuer: "The mandate was issued by a party this merchant does not trust.",
25
+ unknown_key: "The mandate names a signing key this merchant does not trust.",
26
+ invalid_signature: "The mandate signature did not verify.",
27
+ unsupported_algorithm: "The mandate uses a signature or digest algorithm that is not accepted.",
28
+ invalid_claims: "The mandate is missing required claims or they are malformed.",
29
+ expired: "The mandate is expired or not yet valid.",
30
+ wrong_audience: "The mandate is addressed to a different audience.",
31
+ unsupported_mandate_type: "The mandate is not a closed Direct Checkout Mandate.",
32
+ checkout_binding_failed: "The merchant checkout document bound to the mandate did not verify.",
33
+ purchase_mismatch: "The mandate does not authorize this purchase."
34
+ };
35
+ function ap2Rejected(reason, context = {}) {
36
+ return new CommerceError("AUTHORIZATION_INVALID", MESSAGES[reason], {
37
+ details: { method: "ap2", reason },
38
+ ...context.requestId !== void 0 ? { requestId: context.requestId } : {},
39
+ ...context.resourceId !== void 0 ? { resourceId: context.resourceId } : {},
40
+ ...context.cause !== void 0 ? { cause: context.cause } : {}
41
+ });
42
+ }
43
+ function ap2Replayed(state, context = {}) {
44
+ return new CommerceError(
45
+ "AUTHORIZATION_REPLAYED",
46
+ "This mandate has already been presented and cannot authorize another purchase.",
47
+ {
48
+ details: { method: "ap2", reason: "replayed", state },
49
+ ...context.requestId !== void 0 ? { requestId: context.requestId } : {},
50
+ ...context.resourceId !== void 0 ? { resourceId: context.resourceId } : {}
51
+ }
52
+ );
53
+ }
54
+ function ap2Unavailable(detail, context = {}) {
55
+ return new CommerceError(
56
+ "AUTHORIZATION_PROVIDER_UNAVAILABLE",
57
+ "Authorization could not be verified right now. This is a merchant-side fault, not a problem with the presented mandate.",
58
+ {
59
+ details: { method: "ap2", reason: "verifier_unavailable", detail },
60
+ ...context.requestId !== void 0 ? { requestId: context.requestId } : {},
61
+ ...context.resourceId !== void 0 ? { resourceId: context.resourceId } : {},
62
+ ...context.cause !== void 0 ? { cause: context.cause } : {}
63
+ }
64
+ );
65
+ }
66
+
67
+ // src/authorization/ap2/profile.ts
68
+ var REQUIRED_PROFILE_CLAIMS = [
69
+ "profile",
70
+ "resource_id",
71
+ "input_hash",
72
+ "amount",
73
+ "currency",
74
+ "payment_method"
75
+ ];
76
+ async function computeInputHash(input) {
77
+ const canonical = canonicalize(input ?? {});
78
+ if (canonical === void 0) {
79
+ throw new TypeError("resource input is not canonicalizable JSON");
80
+ }
81
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
82
+ return Buffer.from(digest).toString("base64url");
83
+ }
84
+ function asRecord(value) {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
86
+ }
87
+ async function bindMandateToPurchase(mandate, context, errorContext) {
88
+ const profile = asRecord(mandate.checkoutClaims["agent_commerce"]);
89
+ if (profile === void 0) throw ap2Rejected("purchase_mismatch", errorContext);
90
+ for (const claim of REQUIRED_PROFILE_CLAIMS) {
91
+ const value = profile[claim];
92
+ if (typeof value !== "string" || value.length === 0) {
93
+ throw ap2Rejected("purchase_mismatch", errorContext);
94
+ }
95
+ }
96
+ const requirement = context.requirement;
97
+ const expectedInputHash = await computeInputHash(context.input);
98
+ const mustMatch = [
99
+ ["profile", AP2_CHECKOUT_PROFILE],
100
+ ["resource_id", context.resourceId],
101
+ ["input_hash", expectedInputHash],
102
+ // Decimal strings on both sides. Comparing numerically would make "0.10"
103
+ // and "0.1" equal, and a mandate says what it says.
104
+ ["amount", requirement.amount],
105
+ ["currency", requirement.currency],
106
+ ["payment_method", requirement.provider]
107
+ ];
108
+ for (const [claim, expected] of mustMatch) {
109
+ if (profile[claim] !== expected) throw ap2Rejected("purchase_mismatch", errorContext);
110
+ }
111
+ for (const [claim, expected] of [
112
+ ["destination", requirement.destination],
113
+ ["network", requirement.network],
114
+ ["asset", requirement.asset]
115
+ ]) {
116
+ const declared = profile[claim];
117
+ if (declared === void 0 && expected === void 0) continue;
118
+ if (declared !== expected) throw ap2Rejected("purchase_mismatch", errorContext);
119
+ }
120
+ return {
121
+ resourceId: profile["resource_id"],
122
+ amount: profile["amount"],
123
+ currency: profile["currency"],
124
+ paymentMethod: profile["payment_method"]
125
+ };
126
+ }
127
+
128
+ // src/authorization/ap2/checkout-signer.ts
129
+ var DEFAULT_EXPIRES_IN_SECONDS = 900;
130
+ function requireText(value, field) {
131
+ if (typeof value !== "string" || value.length === 0) {
132
+ throw new TypeError(
133
+ `createCheckoutJwt: ${field} must be a non-empty string, received ${describe(value)}`
134
+ );
135
+ }
136
+ return value;
137
+ }
138
+ function describe(value) {
139
+ return value === null ? "null" : typeof value;
140
+ }
141
+ async function resolveKey(key) {
142
+ if (typeof key === "string") {
143
+ if (!key.includes("BEGIN PRIVATE KEY")) {
144
+ throw new TypeError(
145
+ 'createCheckoutJwt: a string privateKey must be a PKCS#8 PEM beginning "-----BEGIN PRIVATE KEY-----"'
146
+ );
147
+ }
148
+ return importPKCS8(key, AP2_SIGNING_ALGORITHM);
149
+ }
150
+ if (typeof key !== "object" || key === null) {
151
+ throw new TypeError("createCheckoutJwt: privateKey must be a private JWK or a PKCS#8 PEM");
152
+ }
153
+ if (key["d"] === void 0) {
154
+ throw new TypeError(
155
+ 'createCheckoutJwt: privateKey is a public JWK (no "d"). Use the private half of the pair whose public key is configured under checkoutIssuers'
156
+ );
157
+ }
158
+ if (key["kty"] !== AP2_KEY_TYPE || key["crv"] !== AP2_JWK_CURVE) {
159
+ throw new TypeError(
160
+ `createCheckoutJwt: privateKey must be ${AP2_KEY_TYPE}/${AP2_JWK_CURVE}, the pair ${AP2_SIGNING_ALGORITHM} implies`
161
+ );
162
+ }
163
+ return key;
164
+ }
165
+ async function createCheckoutJwt(options) {
166
+ const kid = requireText(options.kid, "kid");
167
+ const issuer = requireText(options.issuer, "issuer");
168
+ const audience = requireText(options.audience, "audience");
169
+ const resourceId = requireText(options.resourceId, "resourceId");
170
+ const currency = requireText(options.currency, "currency");
171
+ const paymentMethod = requireText(options.paymentMethod, "paymentMethod");
172
+ if (typeof options.amount === "number") {
173
+ throw new TypeError(
174
+ 'createCheckoutJwt: amount must be a decimal string, not a number. It is compared as a string, so 0.1 and "0.10" are different mandates'
175
+ );
176
+ }
177
+ const amount = requireText(options.amount, "amount");
178
+ const key = await resolveKey(options.privateKey);
179
+ const issuedAt = Math.floor((options.now ?? /* @__PURE__ */ new Date()).getTime() / 1e3);
180
+ const expiresIn = options.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS;
181
+ if (!Number.isInteger(expiresIn) || expiresIn <= 0) {
182
+ throw new TypeError("createCheckoutJwt: expiresInSeconds must be a positive whole number");
183
+ }
184
+ const agentCommerce = {
185
+ profile: AP2_CHECKOUT_PROFILE,
186
+ resource_id: resourceId,
187
+ input_hash: await computeInputHash(options.input),
188
+ amount,
189
+ currency,
190
+ payment_method: paymentMethod,
191
+ ...options.destination !== void 0 ? { destination: options.destination } : {},
192
+ ...options.network !== void 0 ? { network: options.network } : {},
193
+ ...options.asset !== void 0 ? { asset: options.asset } : {}
194
+ };
195
+ return new SignJWT({ agent_commerce: agentCommerce }).setProtectedHeader({ alg: AP2_SIGNING_ALGORITHM, kid, typ: "JWT" }).setIssuer(issuer).setAudience(audience).setIssuedAt(issuedAt).setExpirationTime(issuedAt + expiresIn).setJti(options.jwtId ?? crypto.randomUUID()).sign(key);
196
+ }
197
+
198
+ // src/authorization/ap2/descriptor.ts
199
+ var AP2_CAPABILITIES = [
200
+ "direct-mode",
201
+ AP2_CHECKOUT_MANDATE_VCT,
202
+ "sd-jwt-presentation",
203
+ AP2_SIGNING_ALGORITHM,
204
+ AP2_DIGEST_ALGORITHM,
205
+ "static-inline-trust",
206
+ "merchant-checkout-jwt-binding",
207
+ AP2_CHECKOUT_PROFILE,
208
+ "purchase-binding",
209
+ "replay-defence"
210
+ ];
211
+ var AP2_UNSUPPORTED = [
212
+ // Mandate kinds. Open mandates carry spending constraints nothing here
213
+ // evaluates, so accepting one would tell a buyer their limits were checked.
214
+ "autonomous mode",
215
+ "open checkout mandates (mandate.checkout.open.1)",
216
+ "intent mandates",
217
+ "cart mandates",
218
+ "spending constraint evaluation",
219
+ "cnf-bound agent keys",
220
+ // Key handling. Every key is written into config by an operator
221
+ "JWKS and any key discovery by URL (jku, x5u)",
222
+ "issuer metadata fetching",
223
+ "key rotation without a config change",
224
+ // Algorithms
225
+ "signature algorithms other than ES256",
226
+ "digest algorithms other than sha-256",
227
+ // Roles this gateway does not play
228
+ "mandate issuance",
229
+ "merchant checkout JWT issuance",
230
+ "AP2 over the ACP checkout adapter"
231
+ ];
232
+ function buildAp2Descriptor(implementationVersion) {
233
+ return {
234
+ name: "ap2",
235
+ kind: "authorization",
236
+ implementationVersion,
237
+ supportedSpec: `ap2/v${AP2_SPEC_VERSION} mode=direct`,
238
+ capabilities: AP2_CAPABILITIES,
239
+ status: "experimental",
240
+ unsupported: AP2_UNSUPPORTED
241
+ };
242
+ }
243
+
244
+ // src/authorization/ap2/replay-store.ts
245
+ function createAp2ReplayStore(options) {
246
+ const logger = options.logger ?? NOOP_LOGGER;
247
+ const now = options.now ?? (() => Date.now());
248
+ const db = openSqliteDatabase({
249
+ path: options.path,
250
+ label: "AP2 authorization database",
251
+ logger
252
+ });
253
+ migrate(db);
254
+ const selectByReference = db.prepare(
255
+ "SELECT state FROM ap2_authorizations WHERE reference = ?"
256
+ );
257
+ const selectLiveByJti = db.prepare(
258
+ `SELECT state FROM ap2_authorizations
259
+ WHERE checkout_jti = ? AND reference != ? AND state != 'released'`
260
+ );
261
+ const insert = db.prepare(
262
+ `INSERT INTO ap2_authorizations
263
+ (reference, checkout_jti, mandate_issuer, checkout_issuer, resource_id, request_id,
264
+ state, created_at, updated_at)
265
+ VALUES (@reference, @checkout_jti, @mandate_issuer, @checkout_issuer, @resource_id,
266
+ @request_id, 'reserved', @at, @at)`
267
+ );
268
+ const reReserve = db.prepare(
269
+ `UPDATE ap2_authorizations
270
+ SET state = 'reserved', request_id = @request_id, updated_at = @at
271
+ WHERE reference = @reference AND state = 'released'`
272
+ );
273
+ const transition = db.prepare(
274
+ `UPDATE ap2_authorizations SET state = @state, updated_at = @at
275
+ WHERE reference = @reference AND state = 'reserved'`
276
+ );
277
+ const reserveTx = db.transaction((request) => {
278
+ const at = new Date(now()).toISOString();
279
+ const existing = selectByReference.get(request.reference);
280
+ if (existing !== void 0) {
281
+ if (existing.state !== "released") {
282
+ return { kind: "replayed", state: existing.state };
283
+ }
284
+ reReserve.run({ reference: request.reference, request_id: request.requestId, at });
285
+ return { kind: "reserved" };
286
+ }
287
+ const sameCheckout = selectLiveByJti.get(request.checkoutJti, request.reference);
288
+ if (sameCheckout !== void 0) {
289
+ return { kind: "replayed", state: sameCheckout.state };
290
+ }
291
+ insert.run({
292
+ reference: request.reference,
293
+ checkout_jti: request.checkoutJti,
294
+ mandate_issuer: request.mandateIssuer,
295
+ checkout_issuer: request.checkoutIssuer,
296
+ resource_id: request.resourceId,
297
+ request_id: request.requestId,
298
+ at
299
+ });
300
+ return { kind: "reserved" };
301
+ });
302
+ const move = (reference, state) => {
303
+ transition.run({ reference, state, at: new Date(now()).toISOString() });
304
+ };
305
+ let closed = false;
306
+ return {
307
+ reserve(request) {
308
+ return reserveTx(request);
309
+ },
310
+ consume(reference) {
311
+ move(reference, "consumed");
312
+ },
313
+ release(reference) {
314
+ move(reference, "released");
315
+ },
316
+ markUncertain(reference) {
317
+ move(reference, "uncertain");
318
+ },
319
+ stateOf(reference) {
320
+ return selectByReference.get(reference)?.state;
321
+ },
322
+ close() {
323
+ if (closed) return;
324
+ closed = true;
325
+ db.close();
326
+ }
327
+ };
328
+ }
329
+ function migrate(db) {
330
+ const current = db.pragma("user_version", { simple: true });
331
+ if (current >= 1) return;
332
+ const apply = db.transaction(() => {
333
+ db.exec(`
334
+ CREATE TABLE IF NOT EXISTS ap2_authorizations (
335
+ reference TEXT PRIMARY KEY,
336
+ checkout_jti TEXT NOT NULL,
337
+ mandate_issuer TEXT NOT NULL,
338
+ checkout_issuer TEXT NOT NULL,
339
+ resource_id TEXT NOT NULL,
340
+ request_id TEXT NOT NULL,
341
+ state TEXT NOT NULL
342
+ CHECK (state IN ('reserved', 'consumed', 'released', 'uncertain')),
343
+ created_at TEXT NOT NULL,
344
+ updated_at TEXT NOT NULL
345
+ );
346
+ CREATE INDEX IF NOT EXISTS idx_ap2_authorizations_checkout_jti
347
+ ON ap2_authorizations(checkout_jti);
348
+ `);
349
+ db.pragma("user_version = 1");
350
+ });
351
+ apply();
352
+ }
353
+ function decodeSegment(jwt, index) {
354
+ const segment = jwt.split(".")[index];
355
+ if (segment === void 0) return void 0;
356
+ try {
357
+ const parsed = JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
358
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
359
+ } catch {
360
+ return void 0;
361
+ }
362
+ }
363
+ async function verifyCheckoutJwt(mandateClaims, deps, context) {
364
+ const compact = mandateClaims["checkout_jwt"];
365
+ const expectedHash = mandateClaims["checkout_hash"];
366
+ if (typeof compact !== "string" || compact.length === 0) {
367
+ throw ap2Rejected("invalid_claims", context);
368
+ }
369
+ if (typeof expectedHash !== "string" || expectedHash.length === 0) {
370
+ throw ap2Rejected("invalid_claims", context);
371
+ }
372
+ await requireMatchingHash(compact, expectedHash, context);
373
+ const header = decodeSegment(compact, 0);
374
+ const unverifiedPayload = decodeSegment(compact, 1);
375
+ if (header === void 0 || unverifiedPayload === void 0) {
376
+ throw ap2Rejected("checkout_binding_failed", context);
377
+ }
378
+ const { issuer, key } = await deps.trust.resolve(
379
+ unverifiedPayload["iss"],
380
+ header["kid"],
381
+ context
382
+ );
383
+ const options = {
384
+ algorithms: [AP2_SIGNING_ALGORITHM],
385
+ issuer: issuer.issuer,
386
+ audience: issuer.audience,
387
+ clockTolerance: deps.clockSkewSeconds,
388
+ currentDate: deps.clock.now()
389
+ };
390
+ let claims;
391
+ try {
392
+ const result = await jwtVerify(compact, key, options);
393
+ claims = result.payload;
394
+ } catch (cause) {
395
+ throw ap2Rejected("checkout_binding_failed", { ...context, cause });
396
+ }
397
+ const exp = claims["exp"];
398
+ const iat = claims["iat"];
399
+ const jti = claims["jti"];
400
+ if (typeof exp !== "number" || typeof iat !== "number") {
401
+ throw ap2Rejected("invalid_claims", context);
402
+ }
403
+ if (typeof jti !== "string" || jti.length === 0) {
404
+ throw ap2Rejected("invalid_claims", context);
405
+ }
406
+ const nowSeconds = Math.floor(deps.clock.now().getTime() / 1e3);
407
+ if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected("expired", context);
408
+ return { issuer: issuer.issuer, jwtId: jti, claims };
409
+ }
410
+ async function requireMatchingHash(compact, expected, context) {
411
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(compact));
412
+ const actual = Buffer.from(digest).toString("base64url");
413
+ if (actual !== expected) throw ap2Rejected("checkout_binding_failed", context);
414
+ }
415
+ async function hasher(data, algorithm) {
416
+ if (algorithm.toLowerCase() !== AP2_DIGEST_ALGORITHM) {
417
+ throw new Error(`unsupported digest algorithm ${algorithm}`);
418
+ }
419
+ const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data);
420
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
421
+ }
422
+ function asRecord2(value) {
423
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
424
+ }
425
+ async function verifyMandate(presentation, deps, context) {
426
+ let decoded;
427
+ let encodedJws;
428
+ try {
429
+ decoded = await decodeSdJwt(presentation, hasher);
430
+ encodedJws = splitSdJwt(presentation).jwt;
431
+ } catch (cause) {
432
+ throw ap2Rejected("malformed_presentation", { ...context, cause });
433
+ }
434
+ if (decoded.kbJwt !== void 0) {
435
+ throw ap2Rejected("unsupported_mandate_type", context);
436
+ }
437
+ const rawPayload = asRecord2(decoded.jwt.payload);
438
+ const header = asRecord2(decoded.jwt.header);
439
+ if (rawPayload === void 0 || header === void 0) {
440
+ throw ap2Rejected("malformed_presentation", context);
441
+ }
442
+ const { issuer, key } = await deps.trust.resolve(rawPayload["iss"], header["kid"], context);
443
+ const options = {
444
+ // Redundant today (the key is EC, so jose already refuses `alg: none` and
445
+ // a forged HMAC) and kept for the day this resolves a key set instead of
446
+ // one key, where the header would get to pick
447
+ algorithms: [AP2_SIGNING_ALGORITHM],
448
+ // Also redundant, and for the same reason: the key was resolved *from*
449
+ // `iss` against the configured allowlist, so nothing reaching here can
450
+ // carry a different one. It backstops a future resolver that matches on
451
+ // something else. `audience` below is not redundant - `aud` is the
452
+ // presenter's claim, checked against what the operator configured.
453
+ issuer: issuer.issuer,
454
+ audience: issuer.audience,
455
+ clockTolerance: deps.clockSkewSeconds,
456
+ // Injected clock, not jose's `Date.now()`, so expiry tests are tests
457
+ currentDate: deps.clock.now()
458
+ };
459
+ let verifiedPayload;
460
+ try {
461
+ const result = await jwtVerify(encodedJws, key, options);
462
+ verifiedPayload = result.payload;
463
+ } catch (cause) {
464
+ throw ap2Rejected(classifyJoseFailure(cause), { ...context, cause });
465
+ }
466
+ requireFreshness(verifiedPayload, deps, context);
467
+ let claims;
468
+ try {
469
+ claims = await getClaims(verifiedPayload, decoded.disclosures, hasher);
470
+ } catch (cause) {
471
+ throw ap2Rejected("malformed_presentation", { ...context, cause });
472
+ }
473
+ requireClosedCheckoutMandate(claims, context);
474
+ return { issuer: issuer.issuer, claims, signedToken: encodedJws };
475
+ }
476
+ function classifyJoseFailure(cause) {
477
+ const code = cause?.code;
478
+ if (code === "ERR_JWT_EXPIRED") return "expired";
479
+ if (code === "ERR_JWT_CLAIM_VALIDATION_FAILED") {
480
+ const claim = cause.claim;
481
+ if (claim === "aud") return "wrong_audience";
482
+ if (claim === "nbf" || claim === "exp") return "expired";
483
+ }
484
+ return "invalid_signature";
485
+ }
486
+ function requireFreshness(payload, deps, context) {
487
+ const exp = payload["exp"];
488
+ const iat = payload["iat"];
489
+ if (typeof exp !== "number" || typeof iat !== "number") {
490
+ throw ap2Rejected("invalid_claims", context);
491
+ }
492
+ const nowSeconds = Math.floor(deps.clock.now().getTime() / 1e3);
493
+ if (iat > nowSeconds + deps.clockSkewSeconds) throw ap2Rejected("expired", context);
494
+ }
495
+ function requireClosedCheckoutMandate(claims, context) {
496
+ if (claims["vct"] !== AP2_CHECKOUT_MANDATE_VCT) {
497
+ throw ap2Rejected("unsupported_mandate_type", context);
498
+ }
499
+ }
500
+ function createTrustStore(issuers) {
501
+ const byIssuer = new Map(issuers.map((entry) => [entry.issuer, entry]));
502
+ const imported = /* @__PURE__ */ new Map();
503
+ return {
504
+ /** Every trusted issuer id, for diagnostics. Never the keys themselves */
505
+ issuerIds() {
506
+ return [...byIssuer.keys()];
507
+ },
508
+ async resolve(iss, kid, context) {
509
+ if (typeof iss !== "string" || iss.length === 0) {
510
+ throw ap2Rejected("invalid_claims", context);
511
+ }
512
+ const issuer = byIssuer.get(iss);
513
+ if (issuer === void 0) throw ap2Rejected("untrusted_issuer", context);
514
+ if (typeof kid !== "string" || kid.length === 0) {
515
+ throw ap2Rejected("unknown_key", context);
516
+ }
517
+ const trusted = issuer.keys.find((candidate) => candidate.kid === kid);
518
+ if (trusted === void 0) throw ap2Rejected("unknown_key", context);
519
+ const cacheKey = JSON.stringify([iss, kid]);
520
+ let pending = imported.get(cacheKey);
521
+ if (pending === void 0) {
522
+ pending = importJWK(trusted.jwk, AP2_SIGNING_ALGORITHM);
523
+ imported.set(cacheKey, pending);
524
+ }
525
+ try {
526
+ return { issuer, key: await pending };
527
+ } catch (cause) {
528
+ imported.delete(cacheKey);
529
+ throw ap2Unavailable("configured verification key could not be imported", {
530
+ ...context,
531
+ cause
532
+ });
533
+ }
534
+ }
535
+ };
536
+ }
537
+
538
+ // src/authorization/ap2/verifier.ts
539
+ async function mandateReference(signedToken) {
540
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signedToken));
541
+ return `sha256:${Buffer.from(digest).toString("base64url")}`;
542
+ }
543
+ function createAp2MandateVerifier(options) {
544
+ const mandateTrust = createTrustStore(options.config.trust.mandateIssuers);
545
+ const checkoutTrust = createTrustStore(options.config.trust.checkoutIssuers);
546
+ const deps = { clock: options.clock, clockSkewSeconds: options.config.clockSkewSeconds };
547
+ return {
548
+ async verify(presentation, context = {}) {
549
+ if (typeof presentation !== "string" || presentation.length === 0) {
550
+ throw ap2Rejected("malformed_presentation", context);
551
+ }
552
+ const mandate = await verifyMandate(presentation, { ...deps, trust: mandateTrust }, context);
553
+ const checkout = await verifyCheckoutJwt(
554
+ mandate.claims,
555
+ { ...deps, trust: checkoutTrust },
556
+ context
557
+ );
558
+ return {
559
+ reference: await mandateReference(mandate.signedToken),
560
+ mandateIssuer: mandate.issuer,
561
+ checkoutIssuer: checkout.issuer,
562
+ checkoutJwtId: checkout.jwtId,
563
+ checkoutClaims: checkout.claims,
564
+ mandateClaims: mandate.claims
565
+ };
566
+ },
567
+ trustedIssuers() {
568
+ return { mandate: mandateTrust.issuerIds(), checkout: checkoutTrust.issuerIds() };
569
+ }
570
+ };
571
+ }
572
+
573
+ // src/authorization/ap2/provider.ts
574
+ function createAp2AuthorizationProvider(options) {
575
+ const clock = options.clock ?? systemClock;
576
+ const logger = options.logger ?? NOOP_LOGGER;
577
+ const verifier = createAp2MandateVerifier({ config: options.config, clock });
578
+ const replay = options.replayStore ?? createAp2ReplayStore({ path: options.config.replay.path, logger });
579
+ const requirement = {
580
+ method: "ap2",
581
+ version: AP2_SPEC_VERSION,
582
+ profile: AP2_CHECKOUT_PROFILE
583
+ };
584
+ async function verifyAndReserve(context) {
585
+ const errorContext = {
586
+ requestId: context.requestId,
587
+ resourceId: context.resourceId
588
+ };
589
+ const mandate = await verifier.verify(context.submission.payload, errorContext);
590
+ await bindMandateToPurchase(mandate, context, errorContext);
591
+ let reservation;
592
+ try {
593
+ reservation = replay.reserve({
594
+ reference: mandate.reference,
595
+ checkoutJti: mandate.checkoutJwtId,
596
+ mandateIssuer: mandate.mandateIssuer,
597
+ checkoutIssuer: mandate.checkoutIssuer,
598
+ resourceId: context.resourceId,
599
+ requestId: context.requestId
600
+ });
601
+ } catch (cause) {
602
+ throw ap2Unavailable("replay store could not be written", { ...errorContext, cause });
603
+ }
604
+ if (reservation.kind === "replayed") {
605
+ throw ap2Replayed(reservation.state, errorContext);
606
+ }
607
+ return {
608
+ status: "verified",
609
+ method: "ap2",
610
+ reference: mandate.reference,
611
+ reservationId: mandate.reference,
612
+ // Opaque identifiers an operator can reconcile with. Never a claim from
613
+ // the mandate: those carry the buyer's purchase and their personal data.
614
+ // `checkoutId`, not `checkoutJwtId` - the receipt store redacts any key
615
+ // that reads as secret-shaped, and "jwt" is on that list.
616
+ metadata: {
617
+ mandateIssuer: mandate.mandateIssuer,
618
+ checkoutIssuer: mandate.checkoutIssuer,
619
+ checkoutId: mandate.checkoutJwtId
620
+ }
621
+ };
622
+ }
623
+ const finalize = (action, reservationId, context) => {
624
+ try {
625
+ replay[action](reservationId);
626
+ } catch (cause) {
627
+ throw ap2Unavailable(`replay store could not record ${action}`, { ...context, cause });
628
+ }
629
+ };
630
+ return {
631
+ name: "ap2",
632
+ descriptor: buildAp2Descriptor(PACKAGE_VERSION),
633
+ requirement,
634
+ verifyAndReserve,
635
+ async consume(reservationId, context) {
636
+ finalize("consume", reservationId, context);
637
+ },
638
+ async release(reservationId, context) {
639
+ finalize("release", reservationId, context);
640
+ },
641
+ async markUncertain(reservationId, context) {
642
+ finalize("markUncertain", reservationId, context);
643
+ },
644
+ async health() {
645
+ const startedAt = clock.monotonicMs();
646
+ const trusted = verifier.trustedIssuers();
647
+ const checkedAt = clock.nowIso();
648
+ const durationMs = Math.round(clock.monotonicMs() - startedAt);
649
+ try {
650
+ replay.stateOf("health-probe");
651
+ } catch {
652
+ return { status: "fail", checkedAt, durationMs, detail: "replay-store-unavailable" };
653
+ }
654
+ return {
655
+ status: "pass",
656
+ checkedAt,
657
+ durationMs,
658
+ detail: `mandate-issuers=${trusted.mandate.length} checkout-issuers=${trusted.checkout.length}`
659
+ };
660
+ },
661
+ close() {
662
+ replay.close();
663
+ }
664
+ };
665
+ }
666
+
667
+ export { AP2_CAPABILITIES, AP2_REJECTION_REASONS, AP2_UNSUPPORTED, createAp2AuthorizationProvider as ap2, createAp2AuthorizationProvider, createCheckoutJwt };
668
+ //# sourceMappingURL=ap2.js.map
669
+ //# sourceMappingURL=ap2.js.map