openpay-x402-sdk 0.7.0 → 0.8.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/CHANGELOG.md +30 -0
- package/README.md +191 -17
- package/delivery.d.ts +68 -0
- package/examples/cloudflare-r2-delivery-gate/README.md +62 -0
- package/examples/cloudflare-r2-delivery-gate/worker.mjs +85 -0
- package/examples/cloudflare-r2-delivery-gate/wrangler.toml +23 -0
- package/examples/node-delivery-gate.mjs +54 -0
- package/index.d.ts +46 -5
- package/package.json +8 -2
- package/src/delivery.mjs +305 -0
- package/src/license.mjs +61 -18
- package/src/licenseCommon.mjs +20 -3
- package/src/licenseGate.mjs +36 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- Add the typed `openpay-x402-sdk/delivery` Web-API-only subpath: strict Ed25519
|
|
6
|
+
delivery-ticket verification, request extraction, startup readiness and RFC 7638
|
|
7
|
+
thumbprints. Preserve all root exports and dependencies.
|
|
8
|
+
- Bound JWKS fetches, enforce complete key-set validation, honor Age in a 300-second
|
|
9
|
+
cache, share concurrent fetches, throttle unknown-kid refresh and reject stale
|
|
10
|
+
trust. Supplied keys never fetch or automatically refresh.
|
|
11
|
+
- Add optional atomic replay consumption with fail-closed errors and final expiry
|
|
12
|
+
rechecks; ship private R2/Durable Object and Node presigned-redirect templates.
|
|
13
|
+
- Cross-check shared fixtures and fresh server signatures, packed subpath imports,
|
|
14
|
+
types, runtime capability failures, and template authorization boundaries.
|
|
15
|
+
- Document bearer/session-wallet semantics and the Node/Workers acceptance matrix.
|
|
16
|
+
No dependencies added. Initial generation only: human review and real private R2
|
|
17
|
+
deployment acceptance remain required before adoption; publication is separate.
|
|
18
|
+
|
|
19
|
+
## 0.7.1
|
|
20
|
+
|
|
21
|
+
- Add `resolveLicense({ product, origin?, fetch? })` for validated v1 product
|
|
22
|
+
descriptors, HTTPS-only discovery, redirect rejection and token derivation checks.
|
|
23
|
+
- Let `hasLicense` and `createLicenseGate` accept a product ID in place of the
|
|
24
|
+
explicit chain/contract/token tuple. Polygon/Amoy RPC remains optional.
|
|
25
|
+
- Discover gate identity at first challenge/verify or `await gate.ready()`, sharing
|
|
26
|
+
concurrent discovery and caching the descriptor for the gate lifetime. Failed
|
|
27
|
+
discovery can retry. Synchronous `check()` throws `not_ready` until initialized.
|
|
28
|
+
- Preserve explicit identity and synchronous session checks. Add `session.origin`
|
|
29
|
+
to bind signatures to your service independently of descriptor discovery.
|
|
30
|
+
- Document integration with `LICENSE_PRODUCT_ID` and `LICENSE_SESSION_SECRET`.
|
|
31
|
+
No new dependencies. This workspace release has not been published.
|
|
32
|
+
|
|
3
33
|
## 0.7.0
|
|
4
34
|
|
|
5
35
|
- Add `hasLicense` for standard ERC-1155 ownership with a required chain/contract/
|
package/README.md
CHANGED
|
@@ -171,20 +171,60 @@ and is never transmitted.
|
|
|
171
171
|
|
|
172
172
|
## 利用ライセンス (License NFT)
|
|
173
173
|
|
|
174
|
-
SDK 0.7.
|
|
174
|
+
SDK 0.7.1 (workspace update; not yet published) resolves the NFT definition from
|
|
175
|
+
one product ID. Set only `LICENSE_PRODUCT_ID` and `LICENSE_SESSION_SECRET` on
|
|
176
|
+
your server. The secret must contain at least 32 random bytes of key material
|
|
177
|
+
(for example 32 random bytes encoded as hex). Replace the service URLs below
|
|
178
|
+
with your own:
|
|
175
179
|
|
|
176
180
|
```js
|
|
177
|
-
|
|
181
|
+
import { createLicenseGate, createJpycGate } from 'openpay-x402-sdk';
|
|
182
|
+
|
|
183
|
+
const entry = createLicenseGate({
|
|
184
|
+
product: process.env.LICENSE_PRODUCT_ID,
|
|
185
|
+
session: {
|
|
186
|
+
secret: process.env.LICENSE_SESSION_SECRET,
|
|
187
|
+
origin: 'https://service.example',
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
await entry.ready();
|
|
178
191
|
const usage = createJpycGate({ resourceUrl: 'https://service.example/api/paid' });
|
|
179
192
|
```
|
|
180
193
|
|
|
181
|
-
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
Polygon (137) and Amoy (80002) use public RPC defaults; `rpcUrl` is optional.
|
|
195
|
+
`origin` defaults to `https://open-pay.jp` for product discovery. `session.origin`
|
|
196
|
+
binds the wallet signature and session to **your service**, independently of the
|
|
197
|
+
OpenPay descriptor origin; this is application configuration, not another secret.
|
|
198
|
+
If omitted, the signing origin remains the top-level `origin` for compatibility.
|
|
199
|
+
|
|
200
|
+
`createLicenseGate({ chainId, contract, tokenId, origin, session, ... })` remains
|
|
201
|
+
supported without discovery. An explicit `tokenId` must be a `bigint` or `0x` hex
|
|
202
|
+
uint256, never a JS number or decimal string. Do not mix `product` and an explicit
|
|
203
|
+
identity. OpenPay derives token IDs as
|
|
204
|
+
`keccak256(UTF8('openpay:license:' + productId))`, including the entire `h_…` ID.
|
|
205
|
+
|
|
206
|
+
### Resolve product metadata
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
import { resolveLicense } from 'openpay-x402-sdk';
|
|
210
|
+
const descriptor = await resolveLicense({ product: process.env.LICENSE_PRODUCT_ID });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`resolveLicense({ product, origin?, fetch? })` calls
|
|
214
|
+
`GET /api/license/products/<id>`. It validates the v1 schema, product echo,
|
|
215
|
+
chain/contract/hex token identity and token derivation, terms, supply and remaining
|
|
216
|
+
stock, sale/registration booleans, canonical product/Verify links and seller role.
|
|
217
|
+
It returns only these public fields. `remaining: null` means unknown stock;
|
|
218
|
+
`remaining` and `saleActive` are cached display information, not reservations or
|
|
219
|
+
proof of ownership. Paused or unregistered products still have descriptors.
|
|
220
|
+
`verifyUrl` includes `product`; append `address` to query wallet rights.
|
|
221
|
+
|
|
222
|
+
Descriptor origins must be bare **HTTPS** origins, including on localhost.
|
|
223
|
+
Redirects are rejected and the injected `fetch` must honor `redirect: 'manual'`
|
|
224
|
+
and the 15-second AbortSignal. Failures throw `LicenseError` (`invalid_response`,
|
|
225
|
+
`redirect`, `http_error`, `network_error`); invalid options throw `TypeError`.
|
|
226
|
+
The selected origin is a trusted source, not a signed attestation. Descriptor
|
|
227
|
+
responses use `public, s-maxage=60, stale-while-revalidate=300`.
|
|
188
228
|
|
|
189
229
|
### Read ownership or purchase rights
|
|
190
230
|
|
|
@@ -201,9 +241,7 @@ if (status.entitled === null) {
|
|
|
201
241
|
try {
|
|
202
242
|
const { holder, balance, blockNumber } = await hasLicense({
|
|
203
243
|
address: walletAddress,
|
|
204
|
-
|
|
205
|
-
contract: status.license.contract,
|
|
206
|
-
tokenId: status.license.tokenId,
|
|
244
|
+
product: productId,
|
|
207
245
|
});
|
|
208
246
|
console.log({ holder, balance, blockNumber });
|
|
209
247
|
} catch (error) {
|
|
@@ -213,7 +251,9 @@ try {
|
|
|
213
251
|
}
|
|
214
252
|
```
|
|
215
253
|
|
|
216
|
-
`hasLicense
|
|
254
|
+
`hasLicense({ address, product, origin?, fetch?, rpcUrl? })` resolves once per call;
|
|
255
|
+
the explicit identity form also supports the same RPC transports. `hasLicense`
|
|
256
|
+
calls standard ERC-1155 `balanceOf(address, tokenId)` at the returned
|
|
217
257
|
`blockNumber`, using the latest block (not a finality guarantee). It checks the
|
|
218
258
|
RPC chain ID and returns `{ holder: boolean, balance: bigint, blockNumber: bigint }`.
|
|
219
259
|
Zero balance is a successful negative result; network errors, a wrong chain,
|
|
@@ -243,13 +283,22 @@ use `redirect`. Invalid caller options throw `TypeError`.
|
|
|
243
283
|
|
|
244
284
|
### Authenticate at entry, charge separately for use
|
|
245
285
|
|
|
246
|
-
Create one `entry` instance on your server using the
|
|
247
|
-
|
|
248
|
-
|
|
286
|
+
Create one `entry` instance on your server using the pattern above. Set
|
|
287
|
+
`session.origin` to **your service's origin** so the signing domain and session
|
|
288
|
+
audience are correct. The explicit identity form can still use top-level `origin`
|
|
289
|
+
for this. `LICENSE_SESSION_SECRET` must be a
|
|
249
290
|
server-only, cryptographically random secret of at least 32 UTF-8 bytes, for
|
|
250
291
|
example a random 32-byte value encoded as hex. All workers must use the same
|
|
251
292
|
configuration and secret.
|
|
252
293
|
|
|
294
|
+
Product gates discover at the first `challenge()` or `verify()`, or explicitly
|
|
295
|
+
with `await entry.ready()` at startup. Concurrent initialization shares one
|
|
296
|
+
request. A successful descriptor is frozen and cached for the gate lifetime;
|
|
297
|
+
`ready()` returns it (or `undefined` for explicit identity). A failed request
|
|
298
|
+
installs no identity and a later call can retry. `check()` remains synchronous:
|
|
299
|
+
it throws `not_ready` before initialization and never performs discovery or RPC.
|
|
300
|
+
Call `ready()` at worker startup when accepting sessions issued by another worker.
|
|
301
|
+
|
|
253
302
|
```js
|
|
254
303
|
// Server challenge endpoint: send this message to the wallet.
|
|
255
304
|
const message = await entry.challenge(walletAddress);
|
|
@@ -311,7 +360,7 @@ spend balance; `createJpycGate` handles separate x402 pay-per-use. SDK spend
|
|
|
311
360
|
defaults remain unchanged.
|
|
312
361
|
|
|
313
362
|
ERC-8217 note: the license remains a standard ERC-1155. The agent-binding format
|
|
314
|
-
will be published later; SDK 0.7.
|
|
363
|
+
will be published later; SDK 0.7.1 does not emit or validate binding metadata.
|
|
315
364
|
|
|
316
365
|
### SDK verification in this repository
|
|
317
366
|
|
|
@@ -325,6 +374,131 @@ and real EOA signatures. Existing buyer/seller regression and tarball tests
|
|
|
325
374
|
remain in the root Vitest suite; `npm run typecheck` also checks license API
|
|
326
375
|
consumer types.
|
|
327
376
|
|
|
377
|
+
## 保護配布 (Delivery ticket)
|
|
378
|
+
|
|
379
|
+
SDK 0.8.0 is an **initial generation** workspace release, not yet published or
|
|
380
|
+
production-adopted. OpenPay signs a 60-second bearer ticket after checking
|
|
381
|
+
entitlement. Sellers verify it with the public JWKS; no secret is shared with
|
|
382
|
+
OpenPay. The token is signed, not encrypted, and its claims are readable.
|
|
383
|
+
Possession authorizes admission during its lifetime; it is access control, not
|
|
384
|
+
copy protection or an allowance/payment balance.
|
|
385
|
+
|
|
386
|
+
Import the dedicated typed subpath. The existing package root remains Node-only
|
|
387
|
+
and does **not** re-export delivery helpers or types.
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
import { createDeliveryGate, DeliveryError } from 'openpay-x402-sdk/delivery';
|
|
391
|
+
|
|
392
|
+
const delivery = createDeliveryGate({
|
|
393
|
+
product: 'h_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
394
|
+
audience: 'https://files.example', // Trusted seller configuration, not Host/header input.
|
|
395
|
+
});
|
|
396
|
+
await delivery.ready(); // Probe standard Ed25519 and prefetch validated JWKS.
|
|
397
|
+
|
|
398
|
+
async function authorize(request) {
|
|
399
|
+
try {
|
|
400
|
+
const { product, revision, exp, address } = await delivery.verifyRequest(request);
|
|
401
|
+
// Resolve (product, revision) using YOUR trusted map, then serve private bytes.
|
|
402
|
+
return { product, revision, exp, address };
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error instanceof DeliveryError) return null; // Deny; never log the request/ticket.
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`verifyDeliveryTicket({ ticket, product, audience, issuer?, origin?, fetch?, now?,
|
|
411
|
+
keys?, maxSkewSeconds?, replayStore? })` returns
|
|
412
|
+
`{ address, product, revision, basis, exp, iat, jti, kid }`.
|
|
413
|
+
`createDeliveryGate` takes the same options except `ticket`, and exposes async
|
|
414
|
+
`ready()`, `verify(ticket)`, and `verifyRequest(request)`.
|
|
415
|
+
`ticketFromRequest(request)` reads exactly one `?ticket=` or
|
|
416
|
+
`Authorization: Bearer <ticket>` and returns `null` if absent. Duplicate/query-plus-
|
|
417
|
+
authorization credentials, empty tickets and malformed authorization are rejected.
|
|
418
|
+
`deliveryKeyThumbprint(x)` computes the RFC 7638 public-key ID.
|
|
419
|
+
|
|
420
|
+
The expected `issuer` defaults to `https://open-pay.jp`; `origin` defaults to
|
|
421
|
+
`issuer` and controls only trusted HTTPS JWKS transport. Configure these yourself,
|
|
422
|
+
never from token headers/claims. Issuer/audience configuration is normalized with
|
|
423
|
+
`new URL(value).origin`; the signed claims must exactly match that normalized
|
|
424
|
+
origin. `now` returns Unix **milliseconds** (default `Date.now`); returned `iat`
|
|
425
|
+
and `exp` are Unix **seconds**. Future `iat` allows `maxSkewSeconds` (default 30);
|
|
426
|
+
expiry is strict, never extended by skew, and rechecked after async verification
|
|
427
|
+
and immediately before returning. `sub`/`address` means the session wallet at
|
|
428
|
+
issuance, **not proof that the presenter controls that wallet**. The SDK checks
|
|
429
|
+
`0x` plus 40 hex characters and preserves spelling; EIP-55 checksum is enforced
|
|
430
|
+
server-side at issuance, without adding a crypto dependency to the subpath.
|
|
431
|
+
|
|
432
|
+
Optional single-use storage must implement:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
consume(jti: string, expSeconds: number): Promise<boolean>;
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Reserve the jti **atomically across instances** until its absolute Unix-second
|
|
439
|
+
expiry: true for the first consume, false for a replay, throw on storage failure.
|
|
440
|
+
Namespace storage per issuer/product/audience. The SDK calls it only after full
|
|
441
|
+
verification, and denies on false (`replay`) or exceptions (`replay_store_error`).
|
|
442
|
+
Other return values also deny. Concurrent calls must yield exactly one true.
|
|
443
|
+
Without a store, reuse within TTL is allowed. After consumption, a downstream
|
|
444
|
+
failure, HEAD request or retry needs a fresh ticket; no consume is rolled back.
|
|
445
|
+
Workers KV get/put is not equivalent to atomic consume; the bundled example uses
|
|
446
|
+
a Durable Object. An admitted stream may finish after expiry; subsequent requests
|
|
447
|
+
(including resume/Range) must authenticate again.
|
|
448
|
+
|
|
449
|
+
Public keys are validated as a whole (at most 8, public Ed25519 only, matching
|
|
450
|
+
thumbprints, no duplicate kids). Fetches use an 8-second deadline, manual redirect
|
|
451
|
+
rejection and a 16 KiB response cap. Cache is scoped by issuer and configured key
|
|
452
|
+
origin for at most 300 seconds, subtracting upstream `Age` and fetch elapsed time.
|
|
453
|
+
Age >= 300 is rejected; expired cache entries are refetched and **never** used on
|
|
454
|
+
failure. Concurrent fetches share a request. Unknown kids trigger at most one
|
|
455
|
+
extra refresh per 60 seconds per scope, including failed attempts. Invalid refreshes
|
|
456
|
+
never replace good keys: still-fresh known cached keys remain usable, while the
|
|
457
|
+
failed refresh request denies. `keys` supplied directly are validated and used
|
|
458
|
+
exclusively, never fetched or automatically refreshed (even if empty/invalid).
|
|
459
|
+
|
|
460
|
+
Rotation must publish `old,new` before signing with new, wait at least 15 minutes,
|
|
461
|
+
switch to `new,old`, and retain old through propagation plus ticket lifetime.
|
|
462
|
+
Emergency removal requires CDN purge and verifier refresh/reconfiguration; leaked
|
|
463
|
+
keys can sign new tickets while cached public keys remain trusted. Turning off
|
|
464
|
+
issuance does not revoke an attacker's signing ability or recall downloaded bytes.
|
|
465
|
+
|
|
466
|
+
| Runtime | Delivery subpath requirement / acceptance |
|
|
467
|
+
| --- | --- |
|
|
468
|
+
| Node 20.19+ | Global WebCrypto with standard Ed25519; package engine remains Node >=20. |
|
|
469
|
+
| Node 22.13+ | Same Web API entry point. |
|
|
470
|
+
| Node 24 | Same Web API entry point. |
|
|
471
|
+
| Cloudflare Workers | Standard `Ed25519`, no `nodejs_compat`; run a real deployment smoke on the template's pinned compatibility date. |
|
|
472
|
+
|
|
473
|
+
`ready()` detects missing Ed25519 support as `unsupported_crypto`; there is no
|
|
474
|
+
algorithm downgrade. The matrix is a release target, not proof that every runtime
|
|
475
|
+
was executed by package tests. A real Worker deployment/private R2 smoke is an
|
|
476
|
+
acceptance step. See [Node WebCrypto](https://nodejs.org/api/webcrypto.html) and
|
|
477
|
+
[Workers WebCrypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/).
|
|
478
|
+
|
|
479
|
+
Errors are `DeliveryError` with codes: `invalid_ticket`, `unsupported_algorithm`,
|
|
480
|
+
`unknown_key`, `keys_unavailable`, `ticket_expired`, `ticket_not_yet_valid`,
|
|
481
|
+
`wrong_issuer`, `wrong_audience`, `wrong_product`, `unsupported_crypto`, `replay`,
|
|
482
|
+
`replay_store_error`. SDK errors omit raw tickets, URLs and upstream bodies.
|
|
483
|
+
|
|
484
|
+
Start from the packaged [private R2 Worker template](examples/cloudflare-r2-delivery-gate/README.md)
|
|
485
|
+
or [Node presigned-redirect example](examples/node-delivery-gate.mjs). The Node
|
|
486
|
+
example uses `OPENPAY_PRODUCT_ID`, `AUDIENCE`, and optional `OBJECT_KEYS` (default
|
|
487
|
+
`{ "1": "file-v1.zip" }`), listens on 127.0.0.1:8787 behind HTTPS, and requires you
|
|
488
|
+
to implement the seller storage-SDK presigning stub. Its signature's absolute
|
|
489
|
+
expiry must be <= the ticket's `exp`, even if presigning is slow; a duration alone
|
|
490
|
+
must not extend that deadline. Presigned URLs are separate bearer capabilities.
|
|
491
|
+
|
|
492
|
+
“Product ID only” is an onboarding simplification: audience, a trusted revision
|
|
493
|
+
map, a private bucket binding and deployment compatibility still need configuration.
|
|
494
|
+
Reject unmapped revisions instead of serving the latest file. Authenticate before
|
|
495
|
+
all file/HEAD/Range/conditional paths and before any Cache API access. Close old
|
|
496
|
+
unsigned/public-bucket URLs. Success, error and redirect responses need
|
|
497
|
+
`Cache-Control: private, no-store`, `Referrer-Policy: no-referrer`, and attachment
|
|
498
|
+
disposition. Redact tickets/URLs in seller/CDN logs, Location, JSON and exceptions;
|
|
499
|
+
no-referrer does not erase history or existing logs. Use a stable gate destination,
|
|
500
|
+
not a presigned URL that may be broken by query reserialization.
|
|
501
|
+
|
|
328
502
|
## Money guards
|
|
329
503
|
|
|
330
504
|
| 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,85 @@
|
|
|
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
|
+
'Content-Disposition': 'attachment',
|
|
7
|
+
};
|
|
8
|
+
const instances = new WeakMap();
|
|
9
|
+
|
|
10
|
+
async function startup(env) {
|
|
11
|
+
if (!instances.has(env)) {
|
|
12
|
+
const pending = (async () => {
|
|
13
|
+
const objects = JSON.parse(env.OBJECT_KEYS ?? '{"1":"file-v1.zip"}');
|
|
14
|
+
if (!objects || Array.isArray(objects) || typeof objects !== 'object' ||
|
|
15
|
+
Object.entries(objects).some(([rev, key]) => !/^[1-9][0-9]*$/.test(rev) || typeof key !== 'string' || !key)) {
|
|
16
|
+
throw new Error('invalid_object_map');
|
|
17
|
+
}
|
|
18
|
+
const gate = createDeliveryGate({
|
|
19
|
+
product: env.OPENPAY_PRODUCT_ID, audience: env.AUDIENCE,
|
|
20
|
+
replayStore: env.REPLAY ? {
|
|
21
|
+
async consume(jti, expSeconds) {
|
|
22
|
+
const id = env.REPLAY.idFromName(`${env.OPENPAY_PRODUCT_ID}:${new URL(env.AUDIENCE).origin}:${jti}`);
|
|
23
|
+
const response = await env.REPLAY.get(id).fetch('https://replay.internal/consume', {
|
|
24
|
+
method: 'POST', body: JSON.stringify({ expSeconds }),
|
|
25
|
+
});
|
|
26
|
+
if (response.status !== 200) throw new Error('replay_store_error');
|
|
27
|
+
return response.json();
|
|
28
|
+
},
|
|
29
|
+
} : undefined,
|
|
30
|
+
});
|
|
31
|
+
// Workers cannot fetch at module evaluation: initialize on the first event,
|
|
32
|
+
// before any file operation. Failed startup can retry on the next request.
|
|
33
|
+
await gate.ready();
|
|
34
|
+
return { gate, objects };
|
|
35
|
+
})();
|
|
36
|
+
instances.set(env, pending);
|
|
37
|
+
pending.catch(() => instances.delete(env));
|
|
38
|
+
}
|
|
39
|
+
return instances.get(env);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const worker = {
|
|
43
|
+
async fetch(request, env) {
|
|
44
|
+
try {
|
|
45
|
+
const { gate, objects } = await startup(env);
|
|
46
|
+
const verified = await gate.verifyRequest(request);
|
|
47
|
+
if (new URL(request.url).origin !== new URL(env.AUDIENCE).origin ||
|
|
48
|
+
!['GET', 'HEAD'].includes(request.method) || !Object.hasOwn(objects, String(verified.revision))) {
|
|
49
|
+
throw new Error('denied');
|
|
50
|
+
}
|
|
51
|
+
// Product is pinned by the gate; revision selects only this trusted map.
|
|
52
|
+
// No Cache API lookup, request-derived object key, or public bucket URL.
|
|
53
|
+
const key = objects[String(verified.revision)];
|
|
54
|
+
const file = await (request.method === 'HEAD' ? env.FILES.head(key) : env.FILES.get(key));
|
|
55
|
+
if (!file || verified.exp * 1000 <= Date.now()) throw new Error('denied');
|
|
56
|
+
// This small template ignores Range/conditional headers and serves a full
|
|
57
|
+
// 200 (HEAD returns metadata). Every such request still requires a ticket.
|
|
58
|
+
return new Response(request.method === 'HEAD' ? null : file.body, {
|
|
59
|
+
headers: { ...PRIVATE_HEADERS, 'Content-Type': 'application/octet-stream', 'Content-Length': String(file.size) },
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
// Do not expose request URLs, tickets, R2 keys or upstream exceptions.
|
|
63
|
+
return Response.json({ error: 'delivery_denied' }, { status: 403, headers: PRIVATE_HEADERS });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default worker;
|
|
69
|
+
|
|
70
|
+
// One private Durable Object per namespaced jti. Only the binding can reach it.
|
|
71
|
+
export class Replay {
|
|
72
|
+
constructor(state) { this.state = state; }
|
|
73
|
+
async fetch(request) {
|
|
74
|
+
const { expSeconds } = await request.json();
|
|
75
|
+
const consumed = await this.state.blockConcurrencyWhile(async () => {
|
|
76
|
+
if (!Number.isSafeInteger(expSeconds) || expSeconds * 1000 <= Date.now() ||
|
|
77
|
+
await this.state.storage.get('consumed')) return false;
|
|
78
|
+
await this.state.storage.put('consumed', true);
|
|
79
|
+
await this.state.storage.setAlarm(expSeconds * 1000);
|
|
80
|
+
return true;
|
|
81
|
+
});
|
|
82
|
+
return Response.json(consumed);
|
|
83
|
+
}
|
|
84
|
+
async alarm() { await this.state.storage.deleteAll(); }
|
|
85
|
+
}
|
|
@@ -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,54 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { createDeliveryGate } from 'openpay-x402-sdk/delivery';
|
|
4
|
+
|
|
5
|
+
const PRIVATE_HEADERS = {
|
|
6
|
+
'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'Content-Disposition': 'attachment',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
async function presignObject({ key, method, expiresAt, expiresInSeconds }) {
|
|
10
|
+
// Implement with YOUR storage SDK and private bucket. Bind key and method (GET/HEAD), and
|
|
11
|
+
// set the signature's absolute expiry <= expiresAt (not "now + 60"). If the
|
|
12
|
+
// SDK only takes a duration, anchor its signing time before this function's
|
|
13
|
+
// async work and cap it to expiresInSeconds. Never log the URL or ticket.
|
|
14
|
+
void key; void method; void expiresAt; void expiresInSeconds;
|
|
15
|
+
throw new Error('Implement seller presigning before starting this example');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createDeliveryHandler({ gate, audience, objectKeys, presign = presignObject, now = Date.now }) {
|
|
19
|
+
return async (req, res) => {
|
|
20
|
+
try {
|
|
21
|
+
// Preserve duplicate Authorization fields so the SDK rejects ambiguity.
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
|
|
24
|
+
const request = new Request(new URL(req.url, audience), { method: req.method, headers });
|
|
25
|
+
const verified = await gate.verifyRequest(request);
|
|
26
|
+
if (new URL(request.url).origin !== new URL(audience).origin ||
|
|
27
|
+
!['GET', 'HEAD'].includes(req.method) || !Object.hasOwn(objectKeys, String(verified.revision))) throw new Error('denied');
|
|
28
|
+
const key = objectKeys[String(verified.revision)];
|
|
29
|
+
if (typeof key !== 'string' || !key) throw new Error('denied');
|
|
30
|
+
const expiresAt = verified.exp * 1000;
|
|
31
|
+
const expiresInSeconds = Math.floor((expiresAt - now()) / 1000);
|
|
32
|
+
if (expiresInSeconds <= 0) throw new Error('expired');
|
|
33
|
+
const location = new URL(await presign({ key, method: req.method, expiresAt, expiresInSeconds }));
|
|
34
|
+
if (location.protocol !== 'https:' || location.username || location.password || now() >= expiresAt) throw new Error('denied');
|
|
35
|
+
// HEAD/Range/conditional requests are authorized here too. A presigned URL
|
|
36
|
+
// is a separate bearer capability; its expiry must satisfy the stub above.
|
|
37
|
+
res.writeHead(302, { ...PRIVATE_HEADERS, Location: location.href });
|
|
38
|
+
res.end();
|
|
39
|
+
} catch {
|
|
40
|
+
res.writeHead(403, { ...PRIVATE_HEADERS, 'Content-Type': 'application/json' });
|
|
41
|
+
res.end(JSON.stringify({ error: 'delivery_denied' }));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
47
|
+
const audience = process.env.AUDIENCE;
|
|
48
|
+
const gate = createDeliveryGate({ product: process.env.OPENPAY_PRODUCT_ID, audience });
|
|
49
|
+
await gate.ready(); // Fail startup on unsupported Ed25519 or unavailable JWKS.
|
|
50
|
+
const objectKeys = JSON.parse(process.env.OBJECT_KEYS ?? '{"1":"file-v1.zip"}');
|
|
51
|
+
createServer(createDeliveryHandler({ gate, audience, objectKeys })).listen(8787, '127.0.0.1');
|
|
52
|
+
// Place behind HTTPS at AUDIENCE; keep the bucket private. This example permits
|
|
53
|
+
// ticket replay within its TTL; inject an atomic replayStore to make it single-use.
|
|
54
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -349,7 +349,43 @@ export type LicenseTransport =
|
|
|
349
349
|
| { rpcUrl?: string; publicClient?: never }
|
|
350
350
|
| { rpcUrl?: never; publicClient: LicensePublicClient };
|
|
351
351
|
|
|
352
|
-
export
|
|
352
|
+
export interface ResolveLicenseOptions {
|
|
353
|
+
/** OpenPay product ID: h_ followed by 32 lowercase hex digits. */
|
|
354
|
+
product: string;
|
|
355
|
+
/** Trusted descriptor authority; HTTPS only, even on localhost. Default https://open-pay.jp. */
|
|
356
|
+
origin?: string;
|
|
357
|
+
/** Must honor redirect: 'manual' and the AbortSignal. */
|
|
358
|
+
fetch?: typeof globalThis.fetch;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface LicenseDescriptor {
|
|
362
|
+
version: 1;
|
|
363
|
+
productId: string;
|
|
364
|
+
chainId: 137 | 80002;
|
|
365
|
+
contract: Address;
|
|
366
|
+
tokenId: Hex;
|
|
367
|
+
transferable: boolean;
|
|
368
|
+
termsUrl: string;
|
|
369
|
+
termsVersion: string;
|
|
370
|
+
supply: number;
|
|
371
|
+
/** Display only; null means stock could not be read. */
|
|
372
|
+
remaining: number | null;
|
|
373
|
+
saleActive: boolean;
|
|
374
|
+
registered: boolean;
|
|
375
|
+
productUrl: string;
|
|
376
|
+
/** Append the wallet address query parameter to check rights. */
|
|
377
|
+
verifyUrl: string;
|
|
378
|
+
sellerRole: 'operator' | 'third_party';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Validates the v1 descriptor, product echo and token derivation; rejects all redirects. */
|
|
382
|
+
export function resolveLicense(options: ResolveLicenseOptions): Promise<LicenseDescriptor>;
|
|
383
|
+
|
|
384
|
+
export type LicenseSelector =
|
|
385
|
+
| (LicenseIdentity & { product?: never })
|
|
386
|
+
| (ResolveLicenseOptions & { chainId?: never; contract?: never; tokenId?: never });
|
|
387
|
+
|
|
388
|
+
export type HasLicenseOptions = LicenseSelector & LicenseTransport & { address: Address };
|
|
353
389
|
|
|
354
390
|
export interface LicenseBalance {
|
|
355
391
|
holder: boolean;
|
|
@@ -362,7 +398,7 @@ export type LicenseErrorCode =
|
|
|
362
398
|
| 'rpc_error' | 'network_error' | 'http_error' | 'redirect' | 'invalid_response'
|
|
363
399
|
| 'nonce_store_error' | 'invalid_challenge' | 'challenge_expired'
|
|
364
400
|
| 'invalid_signature' | 'invalid_nonce' | 'no_license'
|
|
365
|
-
| 'invalid_session' | 'session_expired';
|
|
401
|
+
| 'invalid_session' | 'session_expired' | 'not_ready';
|
|
366
402
|
|
|
367
403
|
export class LicenseError extends Error {
|
|
368
404
|
readonly code: LicenseErrorCode;
|
|
@@ -419,14 +455,17 @@ export interface LicenseNonceStore {
|
|
|
419
455
|
consume(nonce: string): LicenseNonceRecord | null | undefined | Promise<LicenseNonceRecord | null | undefined>;
|
|
420
456
|
}
|
|
421
457
|
|
|
422
|
-
export type LicenseGateOptions =
|
|
458
|
+
export type LicenseGateOptions = LicenseSelector & LicenseTransport & {
|
|
423
459
|
session: {
|
|
424
460
|
/** Server-only random secret, at least 32 UTF-8 bytes. */
|
|
425
461
|
secret: string;
|
|
426
462
|
/** Seconds, 1–86400. Default 300. Ownership is cached for this lifetime. */
|
|
427
463
|
ttlSeconds?: number;
|
|
464
|
+
/** Your service's signing origin/session audience. Defaults to the top-level origin. */
|
|
465
|
+
origin?: string;
|
|
428
466
|
};
|
|
429
|
-
/**
|
|
467
|
+
/** Product form: descriptor authority (HTTPS only). Explicit identity: signing origin.
|
|
468
|
+
* Defaults to https://open-pay.jp. Set session.origin to use a separate signing origin. */
|
|
430
469
|
origin?: string;
|
|
431
470
|
/** Single-line ASCII SIWE statement. */
|
|
432
471
|
statement?: string;
|
|
@@ -444,11 +483,13 @@ export interface LicenseSession {
|
|
|
444
483
|
}
|
|
445
484
|
|
|
446
485
|
export interface LicenseGate {
|
|
486
|
+
/** Resolve and cache the product identity for this gate's lifetime; no IO for explicit identity. */
|
|
487
|
+
ready(): Promise<Readonly<LicenseDescriptor> | undefined>;
|
|
447
488
|
/** An EIP-4361-style message, valid for five minutes. */
|
|
448
489
|
challenge(address: Address): Promise<string>;
|
|
449
490
|
/** EOA signature recovery, atomic nonce consumption, balanceOf, then HMAC session issuance. */
|
|
450
491
|
verify(input: { message: string; signature: Hex }): Promise<string>;
|
|
451
|
-
/** Synchronous signature/scope/expiry validation; no
|
|
492
|
+
/** Synchronous signature/scope/expiry validation; no IO. Throws not_ready before discovery. */
|
|
452
493
|
check(token: string): LicenseSession;
|
|
453
494
|
}
|
|
454
495
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpay-x402-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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"
|
package/src/delivery.mjs
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
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
|
+
(async () => readKeysResponse(await config.fetchImpl(`${config.origin}/.well-known/openpay-delivery-keys.json`, {
|
|
196
|
+
method: 'GET', redirect: 'manual', signal: controller.signal, headers: { accept: 'application/json' },
|
|
197
|
+
}), config))(), timeout,
|
|
198
|
+
]);
|
|
199
|
+
const set = { keys, fetchedAt, expiresAt: fetchedAt + (300 - age) * 1000 };
|
|
200
|
+
if (!fresh(set, config)) fail('keys_unavailable');
|
|
201
|
+
cache.current = set;
|
|
202
|
+
return set;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Transport/parser failures remain confined to delivery and disclose no body/URL.
|
|
205
|
+
if (error?.code === 'unsupported_crypto') throw error;
|
|
206
|
+
fail('keys_unavailable');
|
|
207
|
+
} finally {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
// Stop unread transport bodies on early status/size/age rejection as well.
|
|
210
|
+
controller.abort();
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
try { return await cache.flight; } finally { cache.flight = null; }
|
|
214
|
+
}
|
|
215
|
+
async function selectKey(kid, config) {
|
|
216
|
+
if (config.keys !== undefined) {
|
|
217
|
+
const keys = await validateKeys(config.keys);
|
|
218
|
+
if (!keys.has(kid)) fail('unknown_key');
|
|
219
|
+
return keys.get(kid);
|
|
220
|
+
}
|
|
221
|
+
const cache = cacheFor(config);
|
|
222
|
+
let set = fresh(cache.current, config) ? cache.current : await refresh(cache, config);
|
|
223
|
+
if (!set.keys.has(kid)) {
|
|
224
|
+
const now = timeMs(config);
|
|
225
|
+
if (cache.flight) set = await cache.flight;
|
|
226
|
+
else if (now - cache.lastUnknownRefresh >= 60_000) {
|
|
227
|
+
cache.lastUnknownRefresh = now;
|
|
228
|
+
set = await refresh(cache, config);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!fresh(set, config)) fail('keys_unavailable');
|
|
232
|
+
if (!set.keys.has(kid)) fail('unknown_key');
|
|
233
|
+
return set.keys.get(kid);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function verifyDeliveryTicket({ ticket, ...input }) {
|
|
237
|
+
const config = options(input);
|
|
238
|
+
if (typeof ticket !== 'string' || ticket.length > 1024 + 4096 + 88) fail();
|
|
239
|
+
const segments = ticket.split('.');
|
|
240
|
+
if (segments.length !== 3) fail();
|
|
241
|
+
const header = parseJson(decode(segments[0], 1024), true);
|
|
242
|
+
const claims = parseJson(decode(segments[1], 4096), true);
|
|
243
|
+
const signature = decode(segments[2], 86);
|
|
244
|
+
if (!exactFields(header, HEADER_FIELDS)) fail();
|
|
245
|
+
if (header.alg !== 'EdDSA') fail('unsupported_algorithm');
|
|
246
|
+
if (header.typ !== 'openpay-delivery+jwt' || decode(header.kid, 43).length !== 32 || signature.length !== 64) fail();
|
|
247
|
+
if (!exactFields(claims, CLAIM_FIELDS) || claims.v !== 1 ||
|
|
248
|
+
typeof claims.sub !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(claims.sub) ||
|
|
249
|
+
!Number.isSafeInteger(claims.rev) || claims.rev <= 0 || !['purchase', 'holder'].includes(claims.basis) ||
|
|
250
|
+
typeof claims.jti !== 'string' || !/^[0-9a-f]{32}$/.test(claims.jti) ||
|
|
251
|
+
!Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp) || claims.exp !== claims.iat + 60) fail();
|
|
252
|
+
if (claims.iss !== config.issuer) fail('wrong_issuer');
|
|
253
|
+
if (claims.aud !== config.audience) fail('wrong_audience');
|
|
254
|
+
if (typeof claims.product !== 'string' || !/^h_[0-9a-f]{32}$/.test(claims.product) || claims.product !== config.product) fail('wrong_product');
|
|
255
|
+
const key = await importPublicKey(await selectKey(header.kid, config));
|
|
256
|
+
let verified;
|
|
257
|
+
try { verified = await subtleCrypto().verify('Ed25519', key, signature, encoder.encode(`${segments[0]}.${segments[1]}`)); } catch (error) {
|
|
258
|
+
if (error?.name === 'NotSupportedError' || error?.code === 'unsupported_crypto') fail('unsupported_crypto');
|
|
259
|
+
fail();
|
|
260
|
+
}
|
|
261
|
+
if (!verified) fail();
|
|
262
|
+
checkTime(claims, config);
|
|
263
|
+
if (config.replayStore !== undefined) {
|
|
264
|
+
let consumed;
|
|
265
|
+
try { consumed = await config.replayStore.consume(claims.jti, claims.exp); } catch { fail('replay_store_error'); }
|
|
266
|
+
if (consumed === false) fail('replay');
|
|
267
|
+
if (consumed !== true) fail('replay_store_error');
|
|
268
|
+
}
|
|
269
|
+
checkTime(claims, config);
|
|
270
|
+
// EIP-55 checksum is enforced at issuance; preserve the signed address as-is.
|
|
271
|
+
return { address: claims.sub, product: claims.product, revision: claims.rev, basis: claims.basis,
|
|
272
|
+
exp: claims.exp, iat: claims.iat, jti: claims.jti, kid: header.kid };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function ticketFromRequest(request) {
|
|
276
|
+
try {
|
|
277
|
+
const tickets = new URL(request.url).searchParams.getAll('ticket');
|
|
278
|
+
const auth = request.headers.get('authorization');
|
|
279
|
+
if (tickets.length > 1 || (tickets.length && auth !== null)) fail();
|
|
280
|
+
if (tickets.length) { if (!tickets[0]) fail(); return tickets[0]; }
|
|
281
|
+
if (auth === null) return null;
|
|
282
|
+
// A combined duplicate Authorization field contains a comma and cannot match.
|
|
283
|
+
const match = /^Bearer ([^\s,]+)$/i.exec(auth);
|
|
284
|
+
if (!match) fail();
|
|
285
|
+
return match[1];
|
|
286
|
+
} catch { fail(); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function createDeliveryGate(input) {
|
|
290
|
+
input = { ...input };
|
|
291
|
+
const config = options(input);
|
|
292
|
+
const verify = (ticket) => verifyDeliveryTicket({ ...input, ticket });
|
|
293
|
+
return {
|
|
294
|
+
async ready() {
|
|
295
|
+
await probeCrypto();
|
|
296
|
+
if (config.keys !== undefined) await validateKeys(config.keys);
|
|
297
|
+
else {
|
|
298
|
+
const cache = cacheFor(config);
|
|
299
|
+
if (!fresh(cache.current, config)) await refresh(cache, config);
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
verify,
|
|
303
|
+
async verifyRequest(request) { return verify(ticketFromRequest(request)); },
|
|
304
|
+
};
|
|
305
|
+
}
|
package/src/license.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { createPublicClient, http, keccak256, parseAbi, toBytes } from 'viem';
|
|
|
2
2
|
import { polygon, polygonAmoy } from 'viem/chains';
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_LICENSE_ORIGIN, MAX_LICENSE_UINT256, LicenseError, LicenseRpcError,
|
|
5
|
-
licenseAddress, licenseIdentity, licenseOrigin,
|
|
5
|
+
licenseAddress, licenseIdentity, licenseOrigin, licenseProduct, licenseSelector,
|
|
6
6
|
} from './licenseCommon.mjs';
|
|
7
7
|
|
|
8
8
|
export { LicenseError, LicenseRpcError } from './licenseCommon.mjs';
|
|
@@ -13,26 +13,27 @@ const NFT_STATUSES = new Set([
|
|
|
13
13
|
'retryable', 'needs_repair', 'unknown',
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
-
export async function hasLicense({ address, chainId, contract, tokenId, rpcUrl, publicClient }) {
|
|
16
|
+
export async function hasLicense({ address, product, origin, fetch, chainId, contract, tokenId, rpcUrl, publicClient }) {
|
|
17
17
|
const holderAddress = licenseAddress(address);
|
|
18
|
-
const
|
|
18
|
+
const selected = licenseSelector({ product, chainId, contract, tokenId });
|
|
19
19
|
if (rpcUrl !== undefined && publicClient !== undefined) {
|
|
20
20
|
throw new TypeError('Provide rpcUrl or publicClient, not both');
|
|
21
21
|
}
|
|
22
|
-
const chain = [polygon, polygonAmoy].find((value) => value.id === chainId);
|
|
23
|
-
if (publicClient === undefined && rpcUrl === undefined && !chain) {
|
|
24
|
-
throw new TypeError('rpcUrl or publicClient is required for this chainId');
|
|
25
|
-
}
|
|
26
22
|
if (rpcUrl !== undefined && (typeof rpcUrl !== 'string' || !/^https?:\/\//.test(rpcUrl))) {
|
|
27
23
|
throw new TypeError('rpcUrl must be an HTTP(S) URL');
|
|
28
24
|
}
|
|
25
|
+
const identity = selected ?? licenseIdentity(await resolveLicense({ product, origin, fetch }));
|
|
26
|
+
const chain = [polygon, polygonAmoy].find((value) => value.id === identity.chainId);
|
|
27
|
+
if (publicClient === undefined && rpcUrl === undefined && !chain) {
|
|
28
|
+
throw new TypeError('rpcUrl or publicClient is required for this chainId');
|
|
29
|
+
}
|
|
29
30
|
const client = publicClient ?? createPublicClient({
|
|
30
31
|
chain, transport: http(rpcUrl, { retryCount: 0, timeout: 10_000 }),
|
|
31
32
|
});
|
|
32
33
|
try {
|
|
33
34
|
// Verify the endpoint's chain, then pin balanceOf to the reported block. A wrong
|
|
34
35
|
// network or partial RPC response must never become a false ownership verdict.
|
|
35
|
-
if (await client.getChainId() !== chainId) throw new Error('RPC chainId mismatch');
|
|
36
|
+
if (await client.getChainId() !== identity.chainId) throw new Error('RPC chainId mismatch');
|
|
36
37
|
const blockNumber = await client.getBlockNumber({ cacheTime: 0 });
|
|
37
38
|
if (typeof blockNumber !== 'bigint' || blockNumber < 0n) throw new Error('Invalid RPC block number');
|
|
38
39
|
const balance = await client.readContract({
|
|
@@ -91,13 +92,15 @@ function validateResponse(body, address, product) {
|
|
|
91
92
|
|
|
92
93
|
export async function verifyLicense({ address, product, origin = DEFAULT_LICENSE_ORIGIN, fetch: fetchImpl = globalThis.fetch }) {
|
|
93
94
|
const expectedAddress = licenseAddress(address);
|
|
94
|
-
|
|
95
|
-
throw new TypeError('product must be an OpenPay product ID (h_ plus 32 lowercase hex digits)');
|
|
96
|
-
}
|
|
95
|
+
licenseProduct(product);
|
|
97
96
|
const trustedOrigin = licenseOrigin(origin);
|
|
98
97
|
const url = new URL('/api/license/verify', trustedOrigin);
|
|
99
98
|
url.searchParams.set('address', expectedAddress);
|
|
100
99
|
url.searchParams.set('product', product);
|
|
100
|
+
return validateResponse(await fetchLicenseJson(url, trustedOrigin, fetchImpl), expectedAddress, product);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function fetchLicenseJson(url, trustedOrigin, fetchImpl, label = 'verify') {
|
|
101
104
|
let response;
|
|
102
105
|
try {
|
|
103
106
|
response = await fetchImpl(url.toString(), {
|
|
@@ -105,19 +108,59 @@ export async function verifyLicense({ address, product, origin = DEFAULT_LICENSE
|
|
|
105
108
|
headers: { accept: 'application/json' },
|
|
106
109
|
});
|
|
107
110
|
} catch (cause) {
|
|
108
|
-
throw new LicenseError('network_error',
|
|
111
|
+
throw new LicenseError('network_error', `License ${label} request failed`, { cause });
|
|
109
112
|
}
|
|
110
113
|
// Never send the query to a redirect destination. Also reject an injected fetch
|
|
111
114
|
// that reports following a redirect or returning a different origin's response.
|
|
112
115
|
if (response.redirected || response.type === 'opaqueredirect' ||
|
|
113
116
|
(response.status >= 300 && response.status < 400) ||
|
|
114
117
|
(response.url && new URL(response.url).origin !== trustedOrigin)) {
|
|
115
|
-
throw new LicenseError('redirect',
|
|
118
|
+
throw new LicenseError('redirect', `License ${label} redirects are not allowed`);
|
|
116
119
|
}
|
|
117
|
-
if (!response.ok) throw new LicenseError('http_error', `License
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
throw new LicenseError('invalid_response', 'License verify response must be JSON', { cause });
|
|
120
|
+
if (!response.ok) throw new LicenseError('http_error', `License ${label} failed: HTTP ${response.status}`);
|
|
121
|
+
try { return await response.json(); } catch (cause) {
|
|
122
|
+
throw new LicenseError('invalid_response', `License ${label} response must be JSON`, { cause });
|
|
121
123
|
}
|
|
122
|
-
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function httpsUrl(value) {
|
|
127
|
+
if (typeof value !== 'string') throw new Error('URL must be a string');
|
|
128
|
+
const url = new URL(value);
|
|
129
|
+
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Invalid HTTPS URL');
|
|
130
|
+
return url;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function resolveLicense({ product, origin = DEFAULT_LICENSE_ORIGIN, fetch: fetchImpl = globalThis.fetch }) {
|
|
134
|
+
licenseProduct(product);
|
|
135
|
+
const trustedOrigin = licenseOrigin(origin, { httpsOnly: true });
|
|
136
|
+
const body = await fetchLicenseJson(new URL(`/api/license/products/${product}`, trustedOrigin), trustedOrigin, fetchImpl, 'descriptor');
|
|
137
|
+
try {
|
|
138
|
+
if (!object(body) || body.version !== 1 || body.productId !== product ||
|
|
139
|
+
![137, 80002].includes(body.chainId) || typeof body.tokenId !== 'string' ||
|
|
140
|
+
!/^0x[0-9a-f]{64}$/.test(body.tokenId) ||
|
|
141
|
+
licenseIdentity(body).tokenId !== BigInt(keccak256(toBytes(`openpay:license:${product}`))) ||
|
|
142
|
+
typeof body.transferable !== 'boolean' || typeof body.saleActive !== 'boolean' || typeof body.registered !== 'boolean' ||
|
|
143
|
+
typeof body.termsVersion !== 'string' || !body.termsVersion.trim() || body.termsVersion.length > 128 ||
|
|
144
|
+
typeof body.termsUrl !== 'string' || body.termsUrl.length > 512 ||
|
|
145
|
+
!Number.isSafeInteger(body.supply) || body.supply < 1 || body.supply > 10000 ||
|
|
146
|
+
(body.remaining !== null && (!Number.isSafeInteger(body.remaining) || body.remaining < 0 || body.remaining > body.supply)) ||
|
|
147
|
+
!['operator', 'third_party'].includes(body.sellerRole)) throw new Error('Invalid descriptor fields');
|
|
148
|
+
httpsUrl(body.termsUrl);
|
|
149
|
+
const productUrl = httpsUrl(body.productUrl);
|
|
150
|
+
const verifyUrl = httpsUrl(body.verifyUrl);
|
|
151
|
+
if (productUrl.origin !== DEFAULT_LICENSE_ORIGIN || !/^\/@[^/]+$/.test(productUrl.pathname) || productUrl.hash ||
|
|
152
|
+
productUrl.search !== `?product=${product}` || verifyUrl.origin !== DEFAULT_LICENSE_ORIGIN ||
|
|
153
|
+
verifyUrl.pathname !== '/api/license/verify' || verifyUrl.search !== `?product=${product}` || verifyUrl.hash) {
|
|
154
|
+
throw new Error('Invalid descriptor links');
|
|
155
|
+
}
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
// Untrusted descriptor fields must never become a gate's ownership identity.
|
|
158
|
+
throw new LicenseError('invalid_response', 'Invalid license product descriptor', { cause });
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
version: 1, productId: body.productId, chainId: body.chainId, contract: body.contract, tokenId: body.tokenId,
|
|
162
|
+
transferable: body.transferable, termsUrl: body.termsUrl, termsVersion: body.termsVersion,
|
|
163
|
+
supply: body.supply, remaining: body.remaining, saleActive: body.saleActive, registered: body.registered,
|
|
164
|
+
productUrl: body.productUrl, verifyUrl: body.verifyUrl, sellerRole: body.sellerRole,
|
|
165
|
+
};
|
|
123
166
|
}
|
package/src/licenseCommon.mjs
CHANGED
|
@@ -38,16 +38,33 @@ export function licenseIdentity({ chainId, contract, tokenId }) {
|
|
|
38
38
|
return { chainId, contract: licenseAddress(contract, 'contract'), tokenId: id };
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function
|
|
41
|
+
export function licenseProduct(value) {
|
|
42
|
+
if (typeof value !== 'string' || !/^h_[0-9a-f]{32}$/.test(value)) {
|
|
43
|
+
throw new TypeError('product must be an OpenPay product ID (h_ plus 32 lowercase hex digits)');
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function licenseSelector({ product, chainId, contract, tokenId }) {
|
|
49
|
+
if (product === undefined) return licenseIdentity({ chainId, contract, tokenId });
|
|
50
|
+
licenseProduct(product);
|
|
51
|
+
if (chainId !== undefined || contract !== undefined || tokenId !== undefined) {
|
|
52
|
+
throw new TypeError('Provide product or chainId/contract/tokenId, not both');
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function licenseOrigin(value, { httpsOnly = false } = {}) {
|
|
42
58
|
let url;
|
|
43
59
|
try { url = new URL(value); } catch {
|
|
44
60
|
throw new TypeError('origin must be an HTTPS origin');
|
|
45
61
|
}
|
|
46
62
|
// The status authority and signing domain must not be substituted over plaintext.
|
|
47
63
|
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
48
|
-
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) ||
|
|
64
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && local && !httpsOnly)) ||
|
|
49
65
|
url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
50
|
-
throw new TypeError('origin must use HTTPS
|
|
66
|
+
throw new TypeError(httpsOnly ? 'origin must use HTTPS, without credentials or a path' :
|
|
67
|
+
'origin must use HTTPS (HTTP only for localhost/127.0.0.1), without credentials or a path');
|
|
51
68
|
}
|
|
52
69
|
return url.origin;
|
|
53
70
|
}
|
package/src/licenseGate.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { verifyMessage } from 'viem';
|
|
3
3
|
import { createSiweMessage, parseSiweMessage } from 'viem/siwe';
|
|
4
|
-
import { hasLicense } from './license.mjs';
|
|
4
|
+
import { hasLicense, resolveLicense } from './license.mjs';
|
|
5
5
|
import {
|
|
6
|
-
DEFAULT_LICENSE_ORIGIN, LicenseError, licenseAddress, licenseIdentity, licenseOrigin,
|
|
6
|
+
DEFAULT_LICENSE_ORIGIN, LicenseError, licenseAddress, licenseIdentity, licenseOrigin, licenseSelector,
|
|
7
7
|
} from './licenseCommon.mjs';
|
|
8
8
|
|
|
9
9
|
const CHALLENGE_TTL_MS = 5 * 60_000;
|
|
@@ -28,15 +28,38 @@ function memoryNonceStore(now) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export function createLicenseGate({
|
|
31
|
-
chainId, contract, tokenId, rpcUrl, publicClient,
|
|
31
|
+
product, fetch, chainId, contract, tokenId, rpcUrl, publicClient,
|
|
32
32
|
session, origin = DEFAULT_LICENSE_ORIGIN,
|
|
33
33
|
statement = 'Sign in to use this license.', nonceStore, now = Date.now,
|
|
34
34
|
}) {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
let identity = licenseSelector({ product, chainId, contract, tokenId });
|
|
36
|
+
if (product !== undefined) licenseOrigin(origin, { httpsOnly: true });
|
|
37
|
+
const audience = licenseOrigin(session?.origin ?? origin);
|
|
37
38
|
const url = new URL(audience);
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
let idHex;
|
|
40
|
+
let resource;
|
|
41
|
+
function setIdentity(value) {
|
|
42
|
+
identity = value;
|
|
43
|
+
chainId = identity.chainId;
|
|
44
|
+
idHex = `0x${identity.tokenId.toString(16)}`;
|
|
45
|
+
resource = `urn:openpay:license:${chainId}:${identity.contract.toLowerCase()}:${idHex}`;
|
|
46
|
+
}
|
|
47
|
+
if (identity) setIdentity(identity);
|
|
48
|
+
let descriptor;
|
|
49
|
+
let pending;
|
|
50
|
+
async function ready() {
|
|
51
|
+
if (identity) return descriptor;
|
|
52
|
+
pending ??= resolveLicense({ product, origin, fetch }).then((value) => {
|
|
53
|
+
descriptor = Object.freeze(value);
|
|
54
|
+
setIdentity(licenseIdentity(descriptor));
|
|
55
|
+
}).catch((error) => {
|
|
56
|
+
// A failed discovery must not install a partial identity. A later call can retry.
|
|
57
|
+
pending = undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
await pending;
|
|
61
|
+
return descriptor;
|
|
62
|
+
}
|
|
40
63
|
if (typeof session?.secret !== 'string' || Buffer.byteLength(session.secret, 'utf8') < 32) {
|
|
41
64
|
throw new TypeError('session.secret must contain at least 32 bytes of secret key material');
|
|
42
65
|
}
|
|
@@ -73,14 +96,17 @@ export function createLicenseGate({
|
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
async function challenge(address) {
|
|
99
|
+
const holderAddress = licenseAddress(address);
|
|
100
|
+
await ready();
|
|
76
101
|
const nonce = randomBytes(32).toString('hex');
|
|
77
102
|
const issuedAt = now();
|
|
78
|
-
const message = messageFor(
|
|
103
|
+
const message = messageFor(holderAddress, nonce, issuedAt);
|
|
79
104
|
await storeCall('set', nonce, { message, expiresAt: issuedAt + CHALLENGE_TTL_MS });
|
|
80
105
|
return message;
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
async function verify({ message, signature }) {
|
|
109
|
+
await ready();
|
|
84
110
|
if (typeof message !== 'string' || message.length > 8192) {
|
|
85
111
|
throw new LicenseError('invalid_challenge', 'Invalid license challenge');
|
|
86
112
|
}
|
|
@@ -125,6 +151,7 @@ export function createLicenseGate({
|
|
|
125
151
|
}
|
|
126
152
|
|
|
127
153
|
function check(token) {
|
|
154
|
+
if (!identity) throw new LicenseError('not_ready', 'Call await gate.ready() before checking sessions');
|
|
128
155
|
const invalid = () => new LicenseError('invalid_session', 'Invalid license session');
|
|
129
156
|
if (typeof token !== 'string' || token.length > 4096) throw invalid();
|
|
130
157
|
const match = /^(opl1\.[A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/.exec(token);
|
|
@@ -146,5 +173,5 @@ export function createLicenseGate({
|
|
|
146
173
|
return { address, tokenId: identity.tokenId, exp: payload.exp };
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
return { challenge, verify, check };
|
|
176
|
+
return { ready, challenge, verify, check };
|
|
150
177
|
}
|