openpay-x402-sdk 0.7.0 → 0.7.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1
4
+
5
+ - Add `resolveLicense({ product, origin?, fetch? })` for validated v1 product
6
+ descriptors, HTTPS-only discovery, redirect rejection and token derivation checks.
7
+ - Let `hasLicense` and `createLicenseGate` accept a product ID in place of the
8
+ explicit chain/contract/token tuple. Polygon/Amoy RPC remains optional.
9
+ - Discover gate identity at first challenge/verify or `await gate.ready()`, sharing
10
+ concurrent discovery and caching the descriptor for the gate lifetime. Failed
11
+ discovery can retry. Synchronous `check()` throws `not_ready` until initialized.
12
+ - Preserve explicit identity and synchronous session checks. Add `session.origin`
13
+ to bind signatures to your service independently of descriptor discovery.
14
+ - Document integration with `LICENSE_PRODUCT_ID` and `LICENSE_SESSION_SECRET`.
15
+ No new dependencies. This workspace release has not been published.
16
+
3
17
  ## 0.7.0
4
18
 
5
19
  - 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.0 adds license reads and a server-side entry gate. The two-line pattern is:
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
- const entry = createLicenseGate({ ...licenseIdentity, origin: 'https://service.example', session: { secret: sessionSecret } });
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
- Import these helpers from `openpay-x402-sdk`. `licenseIdentity` is the full
182
- `{ chainId, contract, tokenId }` tuple for your product; obtain it from the product
183
- definition or the trusted Verify API. `tokenId` must be a `bigint` or `0x` hex
184
- string representing a uint256, never a JS number or decimal string. OpenPay
185
- derives it as `keccak256(UTF8('openpay:license:' + productId))`, including the
186
- entire `h_…` product ID. A token ID alone does not identify a license across
187
- chains and contracts.
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
- chainId: status.license.chainId,
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` calls standard ERC-1155 `balanceOf(address, tokenId)` at the returned
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 two-line pattern above.
247
- Set `origin` to **your service's origin** (default `https://open-pay.jp`) so the
248
- signing domain and session audience are correct. `sessionSecret` must be a
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.0 does not emit or validate binding metadata.
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
 
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 type HasLicenseOptions = LicenseIdentity & LicenseTransport & { address: Address };
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 = LicenseIdentity & LicenseTransport & {
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
- /** Your service's signing origin and session audience. Default https://open-pay.jp. */
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 RPC and no ownership refresh. */
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.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Guarded Node.js buyer SDK for OpenPay x402 JPYC resources",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
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 identity = licenseIdentity({ chainId, contract, tokenId });
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
- if (typeof product !== 'string' || !/^h_[0-9a-f]{32}$/.test(product)) {
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', 'License verify request failed', { cause });
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', 'License verify redirects are not allowed');
118
+ throw new LicenseError('redirect', `License ${label} redirects are not allowed`);
116
119
  }
117
- if (!response.ok) throw new LicenseError('http_error', `License verify failed: HTTP ${response.status}`);
118
- let body;
119
- try { body = await response.json(); } catch (cause) {
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
- return validateResponse(body, expectedAddress, product);
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
  }
@@ -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 licenseOrigin(value) {
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 (HTTP only for localhost/127.0.0.1), without credentials or a path');
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
  }
@@ -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
- const identity = licenseIdentity({ chainId, contract, tokenId });
36
- const audience = licenseOrigin(origin);
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
- const idHex = `0x${identity.tokenId.toString(16)}`;
39
- const resource = `urn:openpay:license:${chainId}:${identity.contract.toLowerCase()}:${idHex}`;
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(licenseAddress(address), nonce, issuedAt);
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
  }