openpay-x402-sdk 0.7.1 → 0.8.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1
4
+
5
+ - Fix `openpay-x402-sdk/delivery` on Cloudflare Workers: the JWKS fetch called a
6
+ detached `fetch`, which Workers reject with "Illegal invocation", so every ticket
7
+ failed with `keys_unavailable`. Verified on a real Worker deployment (2026-09-10).
8
+ - Templates: send `Content-Disposition: attachment; filename="<object key>"` only on
9
+ successful file responses. An error response carrying `attachment` made Chrome
10
+ show ERR_INVALID_RESPONSE instead of the JSON body.
11
+
12
+ ## 0.8.0
13
+
14
+ - Add the typed `openpay-x402-sdk/delivery` Web-API-only subpath: strict Ed25519
15
+ delivery-ticket verification, request extraction, startup readiness and RFC 7638
16
+ thumbprints. Preserve all root exports and dependencies.
17
+ - Bound JWKS fetches, enforce complete key-set validation, honor Age in a 300-second
18
+ cache, share concurrent fetches, throttle unknown-kid refresh and reject stale
19
+ trust. Supplied keys never fetch or automatically refresh.
20
+ - Add optional atomic replay consumption with fail-closed errors and final expiry
21
+ rechecks; ship private R2/Durable Object and Node presigned-redirect templates.
22
+ - Cross-check shared fixtures and fresh server signatures, packed subpath imports,
23
+ types, runtime capability failures, and template authorization boundaries.
24
+ - Document bearer/session-wallet semantics and the Node/Workers acceptance matrix.
25
+ No dependencies added. Initial generation only: human review and real private R2
26
+ deployment acceptance remain required before adoption; publication is separate.
27
+
3
28
  ## 0.7.1
4
29
 
5
30
  - Add `resolveLicense({ product, origin?, fetch? })` for validated v1 product
package/README.md CHANGED
@@ -171,7 +171,7 @@ and is never transmitted.
171
171
 
172
172
  ## 利用ライセンス (License NFT)
173
173
 
174
- SDK 0.7.1 (workspace update; not yet published) resolves the NFT definition from
174
+ SDK 0.7.1 resolves the NFT definition from
175
175
  one product ID. Set only `LICENSE_PRODUCT_ID` and `LICENSE_SESSION_SECRET` on
176
176
  your server. The secret must contain at least 32 random bytes of key material
177
177
  (for example 32 random bytes encoded as hex). Replace the service URLs below
@@ -374,6 +374,130 @@ and real EOA signatures. Existing buyer/seller regression and tarball tests
374
374
  remain in the root Vitest suite; `npm run typecheck` also checks license API
375
375
  consumer types.
376
376
 
377
+ ## 保護配布 (Delivery ticket)
378
+
379
+ SDK 0.8.0 adds delivery-ticket verification. OpenPay signs a 60-second bearer ticket after checking
380
+ entitlement. Sellers verify it with the public JWKS; no secret is shared with
381
+ OpenPay. The token is signed, not encrypted, and its claims are readable.
382
+ Possession authorizes admission during its lifetime; it is access control, not
383
+ copy protection or an allowance/payment balance.
384
+
385
+ Import the dedicated typed subpath. The existing package root remains Node-only
386
+ and does **not** re-export delivery helpers or types.
387
+
388
+ ```js
389
+ import { createDeliveryGate, DeliveryError } from 'openpay-x402-sdk/delivery';
390
+
391
+ const delivery = createDeliveryGate({
392
+ product: 'h_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
393
+ audience: 'https://files.example', // Trusted seller configuration, not Host/header input.
394
+ });
395
+ await delivery.ready(); // Probe standard Ed25519 and prefetch validated JWKS.
396
+
397
+ async function authorize(request) {
398
+ try {
399
+ const { product, revision, exp, address } = await delivery.verifyRequest(request);
400
+ // Resolve (product, revision) using YOUR trusted map, then serve private bytes.
401
+ return { product, revision, exp, address };
402
+ } catch (error) {
403
+ if (error instanceof DeliveryError) return null; // Deny; never log the request/ticket.
404
+ throw error;
405
+ }
406
+ }
407
+ ```
408
+
409
+ `verifyDeliveryTicket({ ticket, product, audience, issuer?, origin?, fetch?, now?,
410
+ keys?, maxSkewSeconds?, replayStore? })` returns
411
+ `{ address, product, revision, basis, exp, iat, jti, kid }`.
412
+ `createDeliveryGate` takes the same options except `ticket`, and exposes async
413
+ `ready()`, `verify(ticket)`, and `verifyRequest(request)`.
414
+ `ticketFromRequest(request)` reads exactly one `?ticket=` or
415
+ `Authorization: Bearer <ticket>` and returns `null` if absent. Duplicate/query-plus-
416
+ authorization credentials, empty tickets and malformed authorization are rejected.
417
+ `deliveryKeyThumbprint(x)` computes the RFC 7638 public-key ID.
418
+
419
+ The expected `issuer` defaults to `https://open-pay.jp`; `origin` defaults to
420
+ `issuer` and controls only trusted HTTPS JWKS transport. Configure these yourself,
421
+ never from token headers/claims. Issuer/audience configuration is normalized with
422
+ `new URL(value).origin`; the signed claims must exactly match that normalized
423
+ origin. `now` returns Unix **milliseconds** (default `Date.now`); returned `iat`
424
+ and `exp` are Unix **seconds**. Future `iat` allows `maxSkewSeconds` (default 30);
425
+ expiry is strict, never extended by skew, and rechecked after async verification
426
+ and immediately before returning. `sub`/`address` means the session wallet at
427
+ issuance, **not proof that the presenter controls that wallet**. The SDK checks
428
+ `0x` plus 40 hex characters and preserves spelling; EIP-55 checksum is enforced
429
+ server-side at issuance, without adding a crypto dependency to the subpath.
430
+
431
+ Optional single-use storage must implement:
432
+
433
+ ```ts
434
+ consume(jti: string, expSeconds: number): Promise<boolean>;
435
+ ```
436
+
437
+ Reserve the jti **atomically across instances** until its absolute Unix-second
438
+ expiry: true for the first consume, false for a replay, throw on storage failure.
439
+ Namespace storage per issuer/product/audience. The SDK calls it only after full
440
+ verification, and denies on false (`replay`) or exceptions (`replay_store_error`).
441
+ Other return values also deny. Concurrent calls must yield exactly one true.
442
+ Without a store, reuse within TTL is allowed. After consumption, a downstream
443
+ failure, HEAD request or retry needs a fresh ticket; no consume is rolled back.
444
+ Workers KV get/put is not equivalent to atomic consume; the bundled example uses
445
+ a Durable Object. An admitted stream may finish after expiry; subsequent requests
446
+ (including resume/Range) must authenticate again.
447
+
448
+ Public keys are validated as a whole (at most 8, public Ed25519 only, matching
449
+ thumbprints, no duplicate kids). Fetches use an 8-second deadline, manual redirect
450
+ rejection and a 16 KiB response cap. Cache is scoped by issuer and configured key
451
+ origin for at most 300 seconds, subtracting upstream `Age` and fetch elapsed time.
452
+ Age >= 300 is rejected; expired cache entries are refetched and **never** used on
453
+ failure. Concurrent fetches share a request. Unknown kids trigger at most one
454
+ extra refresh per 60 seconds per scope, including failed attempts. Invalid refreshes
455
+ never replace good keys: still-fresh known cached keys remain usable, while the
456
+ failed refresh request denies. `keys` supplied directly are validated and used
457
+ exclusively, never fetched or automatically refreshed (even if empty/invalid).
458
+
459
+ Rotation must publish `old,new` before signing with new, wait at least 15 minutes,
460
+ switch to `new,old`, and retain old through propagation plus ticket lifetime.
461
+ Emergency removal requires CDN purge and verifier refresh/reconfiguration; leaked
462
+ keys can sign new tickets while cached public keys remain trusted. Turning off
463
+ issuance does not revoke an attacker's signing ability or recall downloaded bytes.
464
+
465
+ | Runtime | Delivery subpath requirement / acceptance |
466
+ | --- | --- |
467
+ | Node 20.19+ | Global WebCrypto with standard Ed25519; package engine remains Node >=20. |
468
+ | Node 22.13+ | Same Web API entry point. |
469
+ | Node 24 | Same Web API entry point. |
470
+ | Cloudflare Workers | Standard `Ed25519`, no `nodejs_compat`. Verified end-to-end on a real Worker + private R2 deployment with SDK 0.8.1 (2026-09-10); re-run the smoke when you change the compatibility date. |
471
+
472
+ `ready()` detects missing Ed25519 support as `unsupported_crypto`; there is no
473
+ algorithm downgrade. The matrix is a release target, not proof that every runtime
474
+ was executed by package tests. A real Worker deployment/private R2 smoke is an
475
+ acceptance step. See [Node WebCrypto](https://nodejs.org/api/webcrypto.html) and
476
+ [Workers WebCrypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/).
477
+
478
+ Errors are `DeliveryError` with codes: `invalid_ticket`, `unsupported_algorithm`,
479
+ `unknown_key`, `keys_unavailable`, `ticket_expired`, `ticket_not_yet_valid`,
480
+ `wrong_issuer`, `wrong_audience`, `wrong_product`, `unsupported_crypto`, `replay`,
481
+ `replay_store_error`. SDK errors omit raw tickets, URLs and upstream bodies.
482
+
483
+ Start from the packaged [private R2 Worker template](examples/cloudflare-r2-delivery-gate/README.md)
484
+ or [Node presigned-redirect example](examples/node-delivery-gate.mjs). The Node
485
+ example uses `OPENPAY_PRODUCT_ID`, `AUDIENCE`, and optional `OBJECT_KEYS` (default
486
+ `{ "1": "file-v1.zip" }`), listens on 127.0.0.1:8787 behind HTTPS, and requires you
487
+ to implement the seller storage-SDK presigning stub. Its signature's absolute
488
+ expiry must be <= the ticket's `exp`, even if presigning is slow; a duration alone
489
+ must not extend that deadline. Presigned URLs are separate bearer capabilities.
490
+
491
+ “Product ID only” is an onboarding simplification: audience, a trusted revision
492
+ map, a private bucket binding and deployment compatibility still need configuration.
493
+ Reject unmapped revisions instead of serving the latest file. Authenticate before
494
+ all file/HEAD/Range/conditional paths and before any Cache API access. Close old
495
+ unsigned/public-bucket URLs. Success, error and redirect responses need
496
+ `Cache-Control: private, no-store`, `Referrer-Policy: no-referrer`, and attachment
497
+ disposition. Redact tickets/URLs in seller/CDN logs, Location, JSON and exceptions;
498
+ no-referrer does not erase history or existing logs. Use a stable gate destination,
499
+ not a presigned URL that may be broken by query reserialization.
500
+
377
501
  ## Money guards
378
502
 
379
503
  | Option | Default | Guard |
package/delivery.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ export type DeliveryErrorCode =
2
+ | 'invalid_ticket' | 'unsupported_algorithm' | 'unknown_key' | 'keys_unavailable'
3
+ | 'ticket_expired' | 'ticket_not_yet_valid' | 'wrong_issuer' | 'wrong_audience'
4
+ | 'wrong_product' | 'unsupported_crypto' | 'replay' | 'replay_store_error';
5
+
6
+ export class DeliveryError extends Error {
7
+ constructor(code: DeliveryErrorCode);
8
+ code: DeliveryErrorCode;
9
+ }
10
+
11
+ export interface DeliveryPublicJwk {
12
+ readonly kty: 'OKP';
13
+ readonly crv: 'Ed25519';
14
+ readonly x: string;
15
+ readonly kid: string;
16
+ readonly use: 'sig';
17
+ readonly alg: 'EdDSA';
18
+ }
19
+
20
+ export interface DeliveryReplayStore {
21
+ /** Atomically reserve jti until expSeconds (Unix seconds). Failure must throw. */
22
+ consume(jti: string, expSeconds: number): Promise<boolean>;
23
+ }
24
+
25
+ export interface DeliveryOptions {
26
+ product: string;
27
+ /** Trusted seller HTTPS URL; compared using new URL(audience).origin. */
28
+ audience: string;
29
+ /** Expected issuer; defaults to https://open-pay.jp. Never take it from a ticket. */
30
+ issuer?: string;
31
+ /** Trusted HTTPS key transport origin; defaults to issuer. */
32
+ origin?: string;
33
+ fetch?: typeof globalThis.fetch;
34
+ /** Unix milliseconds; defaults to Date.now. */
35
+ now?: () => number;
36
+ /** Use only these keys, without network access, caching or automatic rotation. */
37
+ keys?: readonly DeliveryPublicJwk[];
38
+ /** Nonnegative future-iat allowance in seconds, default 30; never extends exp. */
39
+ maxSkewSeconds?: number;
40
+ replayStore?: DeliveryReplayStore;
41
+ }
42
+
43
+ export interface DeliveryVerification {
44
+ /** Signed session wallet at issuance, preserved as-is; no presenter identity proof. */
45
+ address: string;
46
+ product: string;
47
+ revision: number;
48
+ basis: 'purchase' | 'holder';
49
+ /** Unix seconds. */
50
+ exp: number;
51
+ /** Unix seconds. */
52
+ iat: number;
53
+ jti: string;
54
+ kid: string;
55
+ }
56
+
57
+ export interface DeliveryGate {
58
+ /** Probe standard Ed25519 and validate supplied keys or prefetch the public JWKS. */
59
+ ready(): Promise<void>;
60
+ verify(ticket: string): Promise<DeliveryVerification>;
61
+ verifyRequest(request: Request): Promise<DeliveryVerification>;
62
+ }
63
+
64
+ export function verifyDeliveryTicket(options: DeliveryOptions & { ticket: string }): Promise<DeliveryVerification>;
65
+ export function ticketFromRequest(request: Request): string | null;
66
+ export function createDeliveryGate(options: DeliveryOptions): DeliveryGate;
67
+ /** RFC 7638 SHA-256 thumbprint of a canonical base64url 32-byte Ed25519 x. */
68
+ export function deliveryKeyThumbprint(x: string): Promise<string>;
@@ -0,0 +1,62 @@
1
+ # Private R2 delivery gate (SDK 0.8.0 initial generation)
2
+
3
+ This template verifies an OpenPay 60-second bearer ticket before accessing R2.
4
+ It uses `openpay-x402-sdk/delivery`, standard WebCrypto `Ed25519`, and no Node
5
+ compatibility flag. Package source/tests are not a real Workers deployment proof.
6
+
7
+ 1. Use the reviewed 0.8.0 package artifact in a seller project; it is not assumed
8
+ published. After publication, the dependency can be installed from the official
9
+ npm registry. Copy `worker.mjs` and `wrangler.toml` together.
10
+ 2. Create a **private** R2 bucket, upload each immutable revision, and replace
11
+ `bucket_name`. Disable public r2.dev access and public bucket custom domains.
12
+ Remove any old unsigned object URL; ordinary product content should contain
13
+ instructions or a safe landing page, not a file bypass.
14
+ 3. Configure the variables below and bind the worker's public HTTPS hostname.
15
+ Set that stable gate URL as the OpenPay product's delivery destination.
16
+ 4. For single-use admission, uncomment both `REPLAY` and its SQLite migration in
17
+ `wrangler.toml`. Deploy through your reviewed release process. The compatibility
18
+ date is pinned to `2026-09-09`; test that date on a real Worker before adoption.
19
+
20
+ | Setting | Meaning |
21
+ | --- | --- |
22
+ | `OPENPAY_PRODUCT_ID` | Exact `h_` + 32 lowercase hex product ID; never request-controlled. |
23
+ | `AUDIENCE` | Worker's public HTTPS origin, e.g. `https://files.example`; must equal the configured destination's origin. |
24
+ | `OBJECT_KEYS` | Optional JSON revision map, e.g. `{ "1": "file-v1.zip" }`. Default is exactly that map. Every unmapped revision is denied, with no latest-version fallback. |
25
+ | `FILES` | R2 binding to the private bucket. |
26
+ | `REPLAY` | Optional private Durable Object binding, one object per product/audience/jti. |
27
+
28
+ `ready()` runs at isolate startup on its first request (network I/O is unavailable
29
+ at module evaluation), before any file access. Failed startup denies and can
30
+ retry. All GET/HEAD/Range/conditional requests authenticate first. This deliberately
31
+ small template ignores Range and conditional headers: GET sends full content with
32
+ 200, HEAD sends metadata only. Add resumable/conditional responses only behind the
33
+ same gate. No Cache API is consulted. All responses use `private, no-store`,
34
+ `no-referrer`, and `Content-Disposition: attachment`; failures are generic 403 JSON.
35
+
36
+ The Durable Object uses `blockConcurrencyWhile` around storage read/put/alarm,
37
+ so simultaneous consumes cannot both succeed; an alarm removes state at expiry.
38
+ Storage or alarm failures deny admission. **Workers KV is NOT equivalent** to
39
+ atomic consume. Omit `REPLAY` only if replay during the ticket's TTL is acceptable.
40
+ A consumed ticket stays consumed after R2 failure. HEAD consumes it too: acquire
41
+ another ticket for GET, retries, ranges or restarts. A stream admitted before
42
+ expiry may finish after expiry; the SDK does not cut it off at 60 seconds.
43
+
44
+ `sub` is the issuance session's wallet, not proof of the presenter's identity.
45
+ The token is readable and bearer-authorized, not DRM. Suppress or redact query
46
+ strings, Authorization, Location and ticket-bearing errors throughout seller/CDN
47
+ logs. `no-referrer` does not remove browser history or already stored logs.
48
+
49
+ Release acceptance must run a private R2 end-to-end download on the pinned Worker:
50
+ valid/expired/wrong-product/unmapped-revision tickets; missing/duplicate/conflicting
51
+ credentials; HEAD/Range/conditional access; simultaneous replay (exactly one
52
+ success); storage/downstream failure; and no unsigned/public/cache bypass. Run a
53
+ cold-JWKS request and staged key rotation too. These are deployment checks, not
54
+ claims established by mocked package tests. See the [SDK delivery documentation](../../README.md#保護配布-delivery-ticket)
55
+ for cache/rotation and runtime boundaries. The [Node example](../node-delivery-gate.mjs)
56
+ uses the same env names and map, but its seller presigning stub must be implemented;
57
+ the storage signature's absolute deadline must be at most the ticket's `exp`.
58
+
59
+ References: [standard WebCrypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/),
60
+ [Durable Object event isolation](https://developers.cloudflare.com/durable-objects/api/state/),
61
+ [alarms](https://developers.cloudflare.com/durable-objects/api/alarms/),
62
+ [Workers KV consistency](https://developers.cloudflare.com/kv/concepts/how-kv-works/).
@@ -0,0 +1,93 @@
1
+ import { createDeliveryGate } from 'openpay-x402-sdk/delivery';
2
+
3
+ const PRIVATE_HEADERS = {
4
+ 'Cache-Control': 'private, no-store',
5
+ 'Referrer-Policy': 'no-referrer',
6
+ };
7
+ // Only successful file responses are downloads. An error response carrying
8
+ // Content-Disposition: attachment makes Chrome show ERR_INVALID_RESPONSE instead of
9
+ // the JSON body (observed on 2026-09-10). The filename comes from the trusted object
10
+ // key, reduced to a safe ASCII token so no header injection is possible.
11
+ function attachment(key) {
12
+ const base = key.split('/').pop() ?? '';
13
+ const name = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '').slice(0, 100) || 'download';
14
+ return `attachment; filename="${name}"`;
15
+ }
16
+ const instances = new WeakMap();
17
+
18
+ async function startup(env) {
19
+ if (!instances.has(env)) {
20
+ const pending = (async () => {
21
+ const objects = JSON.parse(env.OBJECT_KEYS ?? '{"1":"file-v1.zip"}');
22
+ if (!objects || Array.isArray(objects) || typeof objects !== 'object' ||
23
+ Object.entries(objects).some(([rev, key]) => !/^[1-9][0-9]*$/.test(rev) || typeof key !== 'string' || !key)) {
24
+ throw new Error('invalid_object_map');
25
+ }
26
+ const gate = createDeliveryGate({
27
+ product: env.OPENPAY_PRODUCT_ID, audience: env.AUDIENCE,
28
+ replayStore: env.REPLAY ? {
29
+ async consume(jti, expSeconds) {
30
+ const id = env.REPLAY.idFromName(`${env.OPENPAY_PRODUCT_ID}:${new URL(env.AUDIENCE).origin}:${jti}`);
31
+ const response = await env.REPLAY.get(id).fetch('https://replay.internal/consume', {
32
+ method: 'POST', body: JSON.stringify({ expSeconds }),
33
+ });
34
+ if (response.status !== 200) throw new Error('replay_store_error');
35
+ return response.json();
36
+ },
37
+ } : undefined,
38
+ });
39
+ // Workers cannot fetch at module evaluation: initialize on the first event,
40
+ // before any file operation. Failed startup can retry on the next request.
41
+ await gate.ready();
42
+ return { gate, objects };
43
+ })();
44
+ instances.set(env, pending);
45
+ pending.catch(() => instances.delete(env));
46
+ }
47
+ return instances.get(env);
48
+ }
49
+
50
+ const worker = {
51
+ async fetch(request, env) {
52
+ try {
53
+ const { gate, objects } = await startup(env);
54
+ const verified = await gate.verifyRequest(request);
55
+ if (new URL(request.url).origin !== new URL(env.AUDIENCE).origin ||
56
+ !['GET', 'HEAD'].includes(request.method) || !Object.hasOwn(objects, String(verified.revision))) {
57
+ throw new Error('denied');
58
+ }
59
+ // Product is pinned by the gate; revision selects only this trusted map.
60
+ // No Cache API lookup, request-derived object key, or public bucket URL.
61
+ const key = objects[String(verified.revision)];
62
+ const file = await (request.method === 'HEAD' ? env.FILES.head(key) : env.FILES.get(key));
63
+ if (!file || verified.exp * 1000 <= Date.now()) throw new Error('denied');
64
+ // This small template ignores Range/conditional headers and serves a full
65
+ // 200 (HEAD returns metadata). Every such request still requires a ticket.
66
+ return new Response(request.method === 'HEAD' ? null : file.body, {
67
+ headers: { ...PRIVATE_HEADERS, 'Content-Disposition': attachment(key), 'Content-Type': 'application/octet-stream', 'Content-Length': String(file.size) },
68
+ });
69
+ } catch {
70
+ // Do not expose request URLs, tickets, R2 keys or upstream exceptions.
71
+ return Response.json({ error: 'delivery_denied' }, { status: 403, headers: PRIVATE_HEADERS });
72
+ }
73
+ },
74
+ };
75
+
76
+ export default worker;
77
+
78
+ // One private Durable Object per namespaced jti. Only the binding can reach it.
79
+ export class Replay {
80
+ constructor(state) { this.state = state; }
81
+ async fetch(request) {
82
+ const { expSeconds } = await request.json();
83
+ const consumed = await this.state.blockConcurrencyWhile(async () => {
84
+ if (!Number.isSafeInteger(expSeconds) || expSeconds * 1000 <= Date.now() ||
85
+ await this.state.storage.get('consumed')) return false;
86
+ await this.state.storage.put('consumed', true);
87
+ await this.state.storage.setAlarm(expSeconds * 1000);
88
+ return true;
89
+ });
90
+ return Response.json(consumed);
91
+ }
92
+ async alarm() { await this.state.storage.deleteAll(); }
93
+ }
@@ -0,0 +1,23 @@
1
+ name = "openpay-delivery-gate"
2
+ main = "worker.mjs"
3
+ compatibility_date = "2026-09-09"
4
+ # No nodejs_compat flag: delivery uses standard Web APIs and Ed25519.
5
+
6
+ [vars]
7
+ OPENPAY_PRODUCT_ID = "h_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
8
+ AUDIENCE = "https://files.example"
9
+ # Optional; default is revision 1 -> file-v1.zip. Reject every unmapped revision.
10
+ OBJECT_KEYS = '{ "1": "file-v1.zip", "2": "file-v2.zip" }'
11
+
12
+ [[r2_buckets]]
13
+ binding = "FILES"
14
+ bucket_name = "replace-with-your-private-bucket"
15
+
16
+ # Optional single-use tickets: uncomment BOTH sections together.
17
+ # [[durable_objects.bindings]]
18
+ # name = "REPLAY"
19
+ # class_name = "Replay"
20
+ #
21
+ # [[migrations]]
22
+ # tag = "v1"
23
+ # new_sqlite_classes = ["Replay"]
@@ -0,0 +1,57 @@
1
+ import { createServer } from 'node:http';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { createDeliveryGate } from 'openpay-x402-sdk/delivery';
4
+
5
+ // No Content-Disposition here: the redirect target (your presigned URL) decides the
6
+ // download name, and an error response carrying "attachment" makes Chrome show
7
+ // ERR_INVALID_RESPONSE instead of the JSON body.
8
+ const PRIVATE_HEADERS = {
9
+ 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer',
10
+ };
11
+
12
+ async function presignObject({ key, method, expiresAt, expiresInSeconds }) {
13
+ // Implement with YOUR storage SDK and private bucket. Bind key and method (GET/HEAD), and
14
+ // set the signature's absolute expiry <= expiresAt (not "now + 60"). If the
15
+ // SDK only takes a duration, anchor its signing time before this function's
16
+ // async work and cap it to expiresInSeconds. Never log the URL or ticket.
17
+ void key; void method; void expiresAt; void expiresInSeconds;
18
+ throw new Error('Implement seller presigning before starting this example');
19
+ }
20
+
21
+ export function createDeliveryHandler({ gate, audience, objectKeys, presign = presignObject, now = Date.now }) {
22
+ return async (req, res) => {
23
+ try {
24
+ // Preserve duplicate Authorization fields so the SDK rejects ambiguity.
25
+ const headers = new Headers();
26
+ for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
27
+ const request = new Request(new URL(req.url, audience), { method: req.method, headers });
28
+ const verified = await gate.verifyRequest(request);
29
+ if (new URL(request.url).origin !== new URL(audience).origin ||
30
+ !['GET', 'HEAD'].includes(req.method) || !Object.hasOwn(objectKeys, String(verified.revision))) throw new Error('denied');
31
+ const key = objectKeys[String(verified.revision)];
32
+ if (typeof key !== 'string' || !key) throw new Error('denied');
33
+ const expiresAt = verified.exp * 1000;
34
+ const expiresInSeconds = Math.floor((expiresAt - now()) / 1000);
35
+ if (expiresInSeconds <= 0) throw new Error('expired');
36
+ const location = new URL(await presign({ key, method: req.method, expiresAt, expiresInSeconds }));
37
+ if (location.protocol !== 'https:' || location.username || location.password || now() >= expiresAt) throw new Error('denied');
38
+ // HEAD/Range/conditional requests are authorized here too. A presigned URL
39
+ // is a separate bearer capability; its expiry must satisfy the stub above.
40
+ res.writeHead(302, { ...PRIVATE_HEADERS, Location: location.href });
41
+ res.end();
42
+ } catch {
43
+ res.writeHead(403, { ...PRIVATE_HEADERS, 'Content-Type': 'application/json' });
44
+ res.end(JSON.stringify({ error: 'delivery_denied' }));
45
+ }
46
+ };
47
+ }
48
+
49
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
50
+ const audience = process.env.AUDIENCE;
51
+ const gate = createDeliveryGate({ product: process.env.OPENPAY_PRODUCT_ID, audience });
52
+ await gate.ready(); // Fail startup on unsupported Ed25519 or unavailable JWKS.
53
+ const objectKeys = JSON.parse(process.env.OBJECT_KEYS ?? '{"1":"file-v1.zip"}');
54
+ createServer(createDeliveryHandler({ gate, audience, objectKeys })).listen(8787, '127.0.0.1');
55
+ // Place behind HTTPS at AUDIENCE; keep the bucket private. This example permits
56
+ // ticket replay within its TTL; inject an atomic replayStore to make it single-use.
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openpay-x402-sdk",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -9,13 +9,19 @@
9
9
  ".": {
10
10
  "types": "./index.d.ts",
11
11
  "import": "./src/index.mjs"
12
+ },
13
+ "./delivery": {
14
+ "types": "./delivery.d.ts",
15
+ "import": "./src/delivery.mjs"
12
16
  }
13
17
  },
14
18
  "files": [
15
19
  "src",
16
20
  "index.d.ts",
17
21
  "README.md",
18
- "CHANGELOG.md"
22
+ "CHANGELOG.md",
23
+ "delivery.d.ts",
24
+ "examples"
19
25
  ],
20
26
  "engines": {
21
27
  "node": ">=20"
@@ -0,0 +1,307 @@
1
+ // Standalone Web API entry point: keep the Node-only package root out of this graph.
2
+ const DEFAULT_ISSUER = 'https://open-pay.jp';
3
+ const HEADER_FIELDS = ['alg', 'typ', 'kid'];
4
+ const CLAIM_FIELDS = ['v', 'iss', 'aud', 'sub', 'product', 'rev', 'basis', 'iat', 'exp', 'jti'];
5
+ const KEY_FIELDS = ['kty', 'crv', 'x', 'kid', 'use', 'alg'];
6
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
7
+ const encoder = new TextEncoder();
8
+ const caches = new Map();
9
+ const cryptoProbes = new WeakMap();
10
+
11
+ export class DeliveryError extends Error {
12
+ constructor(code) {
13
+ // Never retain a ticket, key response, request URL or upstream exception.
14
+ super(code);
15
+ this.name = 'DeliveryError';
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ function fail(code = 'invalid_ticket') { throw new DeliveryError(code); }
21
+ function object(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
22
+ function exactFields(value, fields) {
23
+ return object(value) && Object.keys(value).length === fields.length && fields.every((key) => Object.hasOwn(value, key));
24
+ }
25
+ function encode(bytes) {
26
+ let result = '';
27
+ for (let i = 0; i < bytes.length; i += 3) {
28
+ const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
29
+ result += ALPHABET[(n >>> 18) & 63] + ALPHABET[(n >>> 12) & 63];
30
+ if (i + 1 < bytes.length) result += ALPHABET[(n >>> 6) & 63];
31
+ if (i + 2 < bytes.length) result += ALPHABET[n & 63];
32
+ }
33
+ return result;
34
+ }
35
+ function decode(raw, max, code = 'invalid_ticket') {
36
+ if (typeof raw !== 'string' || !raw.length || raw.length > max || !/^[A-Za-z0-9_-]+$/.test(raw)) fail(code);
37
+ const bytes = new Uint8Array(Math.floor(raw.length * 6 / 8));
38
+ let bits = 0; let n = 0; let offset = 0;
39
+ for (const char of raw) {
40
+ n = (n << 6) | ALPHABET.indexOf(char);
41
+ bits += 6;
42
+ if (bits >= 8) { bits -= 8; bytes[offset++] = (n >>> bits) & 255; }
43
+ }
44
+ if (encode(bytes) !== raw) fail(code);
45
+ return bytes;
46
+ }
47
+ function parseJson(bytes, flat, code = 'invalid_ticket') {
48
+ try {
49
+ const raw = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
50
+ const value = JSON.parse(raw);
51
+ if (!object(value) || (flat && Object.values(value).some((v) => v !== null && typeof v === 'object'))) fail(code);
52
+ // JSON.parse validates grammar; scan complete string tokens to detect even
53
+ // escaped duplicate member names before accepting its last-member-wins result.
54
+ const stack = [];
55
+ for (const token of raw.matchAll(/"(?:[^"\\]|\\[\s\S])*"|[{}\[\]]/g)) {
56
+ const part = token[0];
57
+ if (part === '{') stack.push(new Set());
58
+ else if (part === '[') stack.push(null);
59
+ else if (part === '}' || part === ']') stack.pop();
60
+ else if (raw.slice(token.index + part.length).trimStart().startsWith(':')) {
61
+ const key = JSON.parse(part);
62
+ const members = stack[stack.length - 1];
63
+ if (members.has(key)) fail(code);
64
+ members.add(key);
65
+ }
66
+ }
67
+ return value;
68
+ } catch { fail(code); }
69
+ }
70
+ function subtleCrypto() {
71
+ const subtle = globalThis.crypto?.subtle;
72
+ if (!subtle || typeof subtle.importKey !== 'function' || typeof subtle.verify !== 'function' ||
73
+ typeof subtle.digest !== 'function') fail('unsupported_crypto');
74
+ return subtle;
75
+ }
76
+ export async function deliveryKeyThumbprint(x) {
77
+ if (decode(x, 43, 'keys_unavailable').length !== 32) fail('keys_unavailable');
78
+ try {
79
+ return encode(new Uint8Array(await subtleCrypto().digest('SHA-256', encoder.encode(JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x })))));
80
+ } catch { fail('unsupported_crypto'); }
81
+ }
82
+ async function importPublicKey(x) {
83
+ try {
84
+ return await subtleCrypto().importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x }, { name: 'Ed25519' }, false, ['verify']);
85
+ } catch (error) {
86
+ if (error?.name === 'NotSupportedError' || error?.code === 'unsupported_crypto') fail('unsupported_crypto');
87
+ fail('keys_unavailable');
88
+ }
89
+ }
90
+ async function probeCrypto() {
91
+ const subtle = subtleCrypto();
92
+ if (!cryptoProbes.has(subtle)) {
93
+ // RFC 8032 test 1: public verification only, no seed or runtime key generation.
94
+ const probe = (async () => {
95
+ try {
96
+ const key = await importPublicKey('11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo');
97
+ const hex = 'e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b';
98
+ const signature = Uint8Array.from(hex.match(/../g), (byte) => Number.parseInt(byte, 16));
99
+ if (!await subtle.verify('Ed25519', key, signature, new Uint8Array())) fail('unsupported_crypto');
100
+ } catch { fail('unsupported_crypto'); }
101
+ })();
102
+ cryptoProbes.set(subtle, probe);
103
+ // Allow a later startup retry after a failed capability probe.
104
+ probe.catch(() => cryptoProbes.delete(subtle));
105
+ }
106
+ await cryptoProbes.get(subtle);
107
+ }
108
+ function normalizedOrigin(value, code) {
109
+ try {
110
+ const url = new URL(value);
111
+ if (url.protocol !== 'https:' || url.username || url.password) fail(code);
112
+ return url.origin;
113
+ } catch { fail(code); }
114
+ }
115
+ function options({ product, audience, issuer = DEFAULT_ISSUER, origin = issuer, fetch: fetchImpl = globalThis.fetch,
116
+ now = Date.now, keys, maxSkewSeconds = 30, replayStore }) {
117
+ if (typeof product !== 'string' || !/^h_[0-9a-f]{32}$/.test(product)) fail('wrong_product');
118
+ if (!Number.isFinite(maxSkewSeconds) || maxSkewSeconds < 0 || typeof now !== 'function') fail();
119
+ if (replayStore !== undefined && typeof replayStore?.consume !== 'function') fail('replay_store_error');
120
+ return { product, audience: normalizedOrigin(audience, 'wrong_audience'), issuer: normalizedOrigin(issuer, 'wrong_issuer'),
121
+ origin: normalizedOrigin(origin, 'keys_unavailable'), fetchImpl, now, keys, maxSkewSeconds, replayStore };
122
+ }
123
+ function timeMs(config) {
124
+ const time = config.now();
125
+ if (!Number.isFinite(time)) fail();
126
+ return time;
127
+ }
128
+ function checkTime(claims, config) {
129
+ const now = timeMs(config) / 1000;
130
+ if (claims.exp <= now) fail('ticket_expired');
131
+ if (claims.iat > now + config.maxSkewSeconds) fail('ticket_not_yet_valid');
132
+ }
133
+ async function validateKeys(keys) {
134
+ if (!Array.isArray(keys) || keys.length === 0 || keys.length > 8) fail('keys_unavailable');
135
+ const result = new Map();
136
+ for (const key of keys) {
137
+ if (!exactFields(key, KEY_FIELDS) || key.kty !== 'OKP' || key.crv !== 'Ed25519' || key.use !== 'sig' || key.alg !== 'EdDSA') fail('keys_unavailable');
138
+ // Copy before awaiting so supplied mutable objects cannot change validated trust.
139
+ const { x, kid } = key;
140
+ if (typeof kid !== 'string' || kid.length !== 43 || kid !== await deliveryKeyThumbprint(x) || result.has(kid)) fail('keys_unavailable');
141
+ result.set(kid, x);
142
+ }
143
+ return result;
144
+ }
145
+ async function readKeysResponse(response, config) {
146
+ if (response.status !== 200 || response.redirected || response.type === 'opaqueredirect' ||
147
+ (response.url && new URL(response.url).origin !== config.origin)) fail('keys_unavailable');
148
+ const length = response.headers.get('content-length');
149
+ if (length !== null && (!/^\d+$/.test(length) || Number(length) > 16_384)) fail('keys_unavailable');
150
+ const ageHeader = response.headers.get('age');
151
+ const age = ageHeader === null ? 0 : Number(ageHeader);
152
+ if (ageHeader !== null && (!/^\d+$/.test(ageHeader) || !Number.isSafeInteger(age))) fail('keys_unavailable');
153
+ if (age >= 300 || !response.body) fail('keys_unavailable');
154
+ const reader = response.body.getReader();
155
+ const chunks = []; let size = 0;
156
+ try {
157
+ while (true) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ size += value.byteLength;
161
+ if (size > 16_384) fail('keys_unavailable');
162
+ chunks.push(value);
163
+ }
164
+ } finally {
165
+ // Do not let cancellation failure mask the bounded-read denial or hang it.
166
+ reader.cancel().catch(() => {});
167
+ }
168
+ const bytes = new Uint8Array(size); let offset = 0;
169
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
170
+ const body = parseJson(bytes, false, 'keys_unavailable');
171
+ if (!exactFields(body, ['keys'])) fail('keys_unavailable');
172
+ return { keys: await validateKeys(body.keys), age };
173
+ }
174
+ function cacheFor(config) {
175
+ // Isolate both the expected issuer and explicitly configured transport origin.
176
+ const id = `${config.issuer}\n${config.origin}`;
177
+ if (!caches.has(id)) caches.set(id, { current: null, flight: null, lastUnknownRefresh: -Infinity });
178
+ return caches.get(id);
179
+ }
180
+ function fresh(set, config) {
181
+ const now = timeMs(config);
182
+ return set && now >= set.fetchedAt && now < set.expiresAt;
183
+ }
184
+ async function refresh(cache, config) {
185
+ if (cache.flight) return cache.flight;
186
+ cache.flight = (async () => {
187
+ const fetchedAt = timeMs(config);
188
+ const controller = new AbortController();
189
+ let timer;
190
+ const timeout = new Promise((_, reject) => {
191
+ timer = setTimeout(() => { controller.abort(); reject(new DeliveryError('keys_unavailable')); }, 8_000);
192
+ });
193
+ try {
194
+ const { keys, age } = await Promise.race([
195
+ // Call with `this` = globalThis: Cloudflare Workers reject a detached `fetch`
196
+ // ("Illegal invocation") — observed on a real Worker on 2026-09-10; Node does not.
197
+ (async () => readKeysResponse(await config.fetchImpl.call(globalThis, `${config.origin}/.well-known/openpay-delivery-keys.json`, {
198
+ method: 'GET', redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' },
199
+ }), config))(), timeout,
200
+ ]);
201
+ const set = { keys, fetchedAt, expiresAt: fetchedAt + (300 - age) * 1000 };
202
+ if (!fresh(set, config)) fail('keys_unavailable');
203
+ cache.current = set;
204
+ return set;
205
+ } catch (error) {
206
+ // Transport/parser failures remain confined to delivery and disclose no body/URL.
207
+ if (error?.code === 'unsupported_crypto') throw error;
208
+ fail('keys_unavailable');
209
+ } finally {
210
+ clearTimeout(timer);
211
+ // Stop unread transport bodies on early status/size/age rejection as well.
212
+ controller.abort();
213
+ }
214
+ })();
215
+ try { return await cache.flight; } finally { cache.flight = null; }
216
+ }
217
+ async function selectKey(kid, config) {
218
+ if (config.keys !== undefined) {
219
+ const keys = await validateKeys(config.keys);
220
+ if (!keys.has(kid)) fail('unknown_key');
221
+ return keys.get(kid);
222
+ }
223
+ const cache = cacheFor(config);
224
+ let set = fresh(cache.current, config) ? cache.current : await refresh(cache, config);
225
+ if (!set.keys.has(kid)) {
226
+ const now = timeMs(config);
227
+ if (cache.flight) set = await cache.flight;
228
+ else if (now - cache.lastUnknownRefresh >= 60_000) {
229
+ cache.lastUnknownRefresh = now;
230
+ set = await refresh(cache, config);
231
+ }
232
+ }
233
+ if (!fresh(set, config)) fail('keys_unavailable');
234
+ if (!set.keys.has(kid)) fail('unknown_key');
235
+ return set.keys.get(kid);
236
+ }
237
+
238
+ export async function verifyDeliveryTicket({ ticket, ...input }) {
239
+ const config = options(input);
240
+ if (typeof ticket !== 'string' || ticket.length > 1024 + 4096 + 88) fail();
241
+ const segments = ticket.split('.');
242
+ if (segments.length !== 3) fail();
243
+ const header = parseJson(decode(segments[0], 1024), true);
244
+ const claims = parseJson(decode(segments[1], 4096), true);
245
+ const signature = decode(segments[2], 86);
246
+ if (!exactFields(header, HEADER_FIELDS)) fail();
247
+ if (header.alg !== 'EdDSA') fail('unsupported_algorithm');
248
+ if (header.typ !== 'openpay-delivery+jwt' || decode(header.kid, 43).length !== 32 || signature.length !== 64) fail();
249
+ if (!exactFields(claims, CLAIM_FIELDS) || claims.v !== 1 ||
250
+ typeof claims.sub !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(claims.sub) ||
251
+ !Number.isSafeInteger(claims.rev) || claims.rev <= 0 || !['purchase', 'holder'].includes(claims.basis) ||
252
+ typeof claims.jti !== 'string' || !/^[0-9a-f]{32}$/.test(claims.jti) ||
253
+ !Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || claims.exp !== claims.iat + 60) fail();
254
+ if (claims.iss !== config.issuer) fail('wrong_issuer');
255
+ if (claims.aud !== config.audience) fail('wrong_audience');
256
+ if (typeof claims.product !== 'string' || !/^h_[0-9a-f]{32}$/.test(claims.product) || claims.product !== config.product) fail('wrong_product');
257
+ const key = await importPublicKey(await selectKey(header.kid, config));
258
+ let verified;
259
+ try { verified = await subtleCrypto().verify('Ed25519', key, signature, encoder.encode(`${segments[0]}.${segments[1]}`)); } catch (error) {
260
+ if (error?.name === 'NotSupportedError' || error?.code === 'unsupported_crypto') fail('unsupported_crypto');
261
+ fail();
262
+ }
263
+ if (!verified) fail();
264
+ checkTime(claims, config);
265
+ if (config.replayStore !== undefined) {
266
+ let consumed;
267
+ try { consumed = await config.replayStore.consume(claims.jti, claims.exp); } catch { fail('replay_store_error'); }
268
+ if (consumed === false) fail('replay');
269
+ if (consumed !== true) fail('replay_store_error');
270
+ }
271
+ checkTime(claims, config);
272
+ // EIP-55 checksum is enforced at issuance; preserve the signed address as-is.
273
+ return { address: claims.sub, product: claims.product, revision: claims.rev, basis: claims.basis,
274
+ exp: claims.exp, iat: claims.iat, jti: claims.jti, kid: header.kid };
275
+ }
276
+
277
+ export function ticketFromRequest(request) {
278
+ try {
279
+ const tickets = new URL(request.url).searchParams.getAll('ticket');
280
+ const auth = request.headers.get('authorization');
281
+ if (tickets.length > 1 || (tickets.length && auth !== null)) fail();
282
+ if (tickets.length) { if (!tickets[0]) fail(); return tickets[0]; }
283
+ if (auth === null) return null;
284
+ // A combined duplicate Authorization field contains a comma and cannot match.
285
+ const match = /^Bearer ([^\s,]+)$/i.exec(auth);
286
+ if (!match) fail();
287
+ return match[1];
288
+ } catch { fail(); }
289
+ }
290
+
291
+ export function createDeliveryGate(input) {
292
+ input = { ...input };
293
+ const config = options(input);
294
+ const verify = (ticket) => verifyDeliveryTicket({ ...input, ticket });
295
+ return {
296
+ async ready() {
297
+ await probeCrypto();
298
+ if (config.keys !== undefined) await validateKeys(config.keys);
299
+ else {
300
+ const cache = cacheFor(config);
301
+ if (!fresh(cache.current, config)) await refresh(cache, config);
302
+ }
303
+ },
304
+ verify,
305
+ async verifyRequest(request) { return verify(ticketFromRequest(request)); },
306
+ };
307
+ }