openpay-x402-sdk 0.6.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 +32 -0
- package/README.md +205 -0
- package/index.d.ts +163 -1
- package/package.json +4 -1
- package/src/index.mjs +2 -0
- package/src/license.mjs +166 -0
- package/src/licenseCommon.mjs +70 -0
- package/src/licenseGate.mjs +177 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.7.0
|
|
18
|
+
|
|
19
|
+
- Add `hasLicense` for standard ERC-1155 ownership with a required chain/contract/
|
|
20
|
+
uint256 token identity, block-pinned reads, and typed RPC errors. Token IDs
|
|
21
|
+
accept bigint or hex, never JS numbers.
|
|
22
|
+
- Add `verifyLicense` for the trusted HTTPS v1 status API, with schema and
|
|
23
|
+
address/product identity validation. Preserve `entitled: null` as unknown and
|
|
24
|
+
reject redirects; allow HTTP only on localhost/127.0.0.1.
|
|
25
|
+
- Add `createLicenseGate`: five-minute EIP-4361-style EOA challenges, atomic
|
|
26
|
+
single-use nonces, on-chain ownership, and HMAC sessions bound to the service
|
|
27
|
+
origin and full license identity. Default sessions last five minutes; support
|
|
28
|
+
an injectable nonce store without adding other persistence.
|
|
29
|
+
- Add TypeScript declarations, mocked RPC/fetch and real-signature unit tests,
|
|
30
|
+
and the entry-license + x402 pay-per-use README pattern. Keep existing exports'
|
|
31
|
+
behavior and spend defaults unchanged; add no dependencies.
|
|
32
|
+
- Keep licenses standard ERC-1155; the ERC-8217 agent-binding format will be
|
|
33
|
+
published later. This release does not implement binding metadata.
|
|
34
|
+
|
|
3
35
|
## 0.6.0
|
|
4
36
|
|
|
5
37
|
- Add `createDualGate` — a dual-rail seller gate that serves both JPYC (Polygon,
|
package/README.md
CHANGED
|
@@ -169,6 +169,211 @@ and `deactivate(id)` complete the lifecycle; `update` without `usdc` removes the
|
|
|
169
169
|
USDC face, so pass the previous value to keep it. The private key signs locally
|
|
170
170
|
and is never transmitted.
|
|
171
171
|
|
|
172
|
+
## 利用ライセンス (License NFT)
|
|
173
|
+
|
|
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:
|
|
179
|
+
|
|
180
|
+
```js
|
|
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();
|
|
191
|
+
const usage = createJpycGate({ resourceUrl: 'https://service.example/api/paid' });
|
|
192
|
+
```
|
|
193
|
+
|
|
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`.
|
|
228
|
+
|
|
229
|
+
### Read ownership or purchase rights
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { hasLicense, verifyLicense, LicenseRpcError } from 'openpay-x402-sdk';
|
|
233
|
+
|
|
234
|
+
const status = await verifyLicense({ address: walletAddress, product: productId });
|
|
235
|
+
if (status.entitled === null) {
|
|
236
|
+
// UNKNOWN: retry later; do not treat this as non-ownership or ask for repurchase.
|
|
237
|
+
} else if (status.entitled === true) {
|
|
238
|
+
// The trusted API reports rights. Authenticate the wallet separately.
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const { holder, balance, blockNumber } = await hasLicense({
|
|
243
|
+
address: walletAddress,
|
|
244
|
+
product: productId,
|
|
245
|
+
});
|
|
246
|
+
console.log({ holder, balance, blockNumber });
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof LicenseRpcError) {
|
|
249
|
+
// Ownership is unconfirmed. Report/retry the failure; do not convert it to false.
|
|
250
|
+
} else throw error;
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
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
|
|
257
|
+
`blockNumber`, using the latest block (not a finality guarantee). It checks the
|
|
258
|
+
RPC chain ID and returns `{ holder: boolean, balance: bigint, blockNumber: bigint }`.
|
|
259
|
+
Zero balance is a successful negative result; network errors, a wrong chain,
|
|
260
|
+
reverts and malformed RPC results throw `LicenseRpcError` (`code: 'rpc_error'`).
|
|
261
|
+
Pass either `rpcUrl` or a viem `publicClient` implementing `getChainId`,
|
|
262
|
+
`getBlockNumber` and `readContract`. Polygon (137) and Amoy (80002) use viem's
|
|
263
|
+
public RPC defaults if neither is supplied; other chains need an explicit
|
|
264
|
+
transport. Treat the chosen RPC/client as a trusted read source.
|
|
265
|
+
|
|
266
|
+
`verifyLicense({ address, product, origin?, fetch? })` calls
|
|
267
|
+
`GET /api/license/verify?address=…&product=…`. It validates version `1`, the
|
|
268
|
+
address/product echoes, the full license identity and token derivation,
|
|
269
|
+
`entitled`, `basis`, NFT status, optional mint transaction/observed block, and
|
|
270
|
+
`checkedAt`. The typed response keeps `entitled: boolean | null`; **null means
|
|
271
|
+
unknown**. `basis` is `purchase`, `holder` or `null`. NFT status is
|
|
272
|
+
`awaiting_finality`, `pending`, `submitted`, `minted`, `registered`, `retryable`,
|
|
273
|
+
`needs_repair` or `unknown`.
|
|
274
|
+
|
|
275
|
+
This is a trusted HTTPS status API, not wallet authentication or portable signed
|
|
276
|
+
proof. The default origin is `https://open-pay.jp`; an override must be a bare
|
|
277
|
+
HTTPS origin without credentials, path, query or fragment. HTTP is allowed only
|
|
278
|
+
for `localhost` and `127.0.0.1`. All redirects, including same-origin redirects,
|
|
279
|
+
are rejected. Injected `fetch` must honor `redirect: 'manual'` and the 15-second
|
|
280
|
+
AbortSignal. Invalid schemas, HTTP failures and transport failures throw
|
|
281
|
+
`LicenseError` with `invalid_response`, `http_error` or `network_error`; redirects
|
|
282
|
+
use `redirect`. Invalid caller options throw `TypeError`.
|
|
283
|
+
|
|
284
|
+
### Authenticate at entry, charge separately for use
|
|
285
|
+
|
|
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
|
|
290
|
+
server-only, cryptographically random secret of at least 32 UTF-8 bytes, for
|
|
291
|
+
example a random 32-byte value encoded as hex. All workers must use the same
|
|
292
|
+
configuration and secret.
|
|
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
|
+
|
|
302
|
+
```js
|
|
303
|
+
// Server challenge endpoint: send this message to the wallet.
|
|
304
|
+
const message = await entry.challenge(walletAddress);
|
|
305
|
+
// Wallet: sign the exact message with signMessage({ message }).
|
|
306
|
+
// Server verify endpoint: receive the message and signature from the wallet.
|
|
307
|
+
const token = await entry.verify({ message, signature });
|
|
308
|
+
// On protected requests, extract the token from your cookie or Authorization header.
|
|
309
|
+
const { address, tokenId, exp } = entry.check(token);
|
|
310
|
+
// After entry.check succeeds, charge each paid call with the existing usage gate.
|
|
311
|
+
const payment = await usage.handle(request);
|
|
312
|
+
if (payment instanceof Response) return payment;
|
|
313
|
+
// Return the paid content with payment.paymentResponseHeader as X-PAYMENT-RESPONSE.
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`challenge` returns a five-minute [EIP-4361-style message](https://eips.ethereum.org/EIPS/eip-4361)
|
|
317
|
+
with a random nonce and the full license identity in its Resources field.
|
|
318
|
+
`verify` uses viem's EOA `verifyMessage` recovery, checks the exact issued message,
|
|
319
|
+
domain, URI, identity and expiry, atomically consumes its nonce, reads ownership,
|
|
320
|
+
then returns an HMAC-SHA256 session token. This version supports EOA signatures;
|
|
321
|
+
contract-wallet ERC-1271 verification is not implemented. The optional
|
|
322
|
+
`statement` must be single-line ASCII.
|
|
323
|
+
|
|
324
|
+
`check` is synchronous and returns `{ address, tokenId: bigint, exp }`, with `exp`
|
|
325
|
+
in Unix seconds. It authenticates the token and checks its audience, full license
|
|
326
|
+
identity and expiry. It performs no RPC: a transfer/burn after entry remains
|
|
327
|
+
effective in an existing session until expiry. `session.ttlSeconds` defaults to
|
|
328
|
+
300 (allowed 1–86400); use a short lifetime or call `hasLicense` again when fresh
|
|
329
|
+
ownership is required. The helper does not set cookies or expose HTTP endpoints;
|
|
330
|
+
your framework handles token transport, secure cookies and endpoint rate limits.
|
|
331
|
+
|
|
332
|
+
Authentication failures throw `LicenseError`: `invalid_challenge`,
|
|
333
|
+
`challenge_expired`, `invalid_signature`, `invalid_nonce`, `no_license`,
|
|
334
|
+
`invalid_session` or `session_expired`. RPC failures remain `LicenseRpcError`.
|
|
335
|
+
Once a valid signature consumes a nonce, even an RPC failure or zero balance
|
|
336
|
+
requires a fresh challenge. No session is issued on a nonce-store failure
|
|
337
|
+
(`nonce_store_error`).
|
|
338
|
+
|
|
339
|
+
The default nonce store is in memory per gate instance, prunes expired entries
|
|
340
|
+
on challenge creation and caps pending entries at 10,000. For multiple workers
|
|
341
|
+
or restarts, inject only a `nonceStore` with:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
set(nonce: string, record: { message: string; expiresAt: number }): void | Promise<void>;
|
|
345
|
+
consume(nonce: string): LicenseNonceRecord | null | undefined | Promise<LicenseNonceRecord | null | undefined>;
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`expiresAt` is Unix milliseconds. `consume` must return and delete the record
|
|
349
|
+
**atomically** across workers (for example with a Redis GETDEL); a separate
|
|
350
|
+
get/delete pair is unsafe. Expire records at `expiresAt` and throw on storage
|
|
351
|
+
failure. The SDK has no other persistence. Session tokens are bearer credentials;
|
|
352
|
+
keep the HMAC secret on the server and use HTTPS to carry the token.
|
|
353
|
+
|
|
354
|
+
`hasLicense` and `createLicenseGate` check NFT ownership only. OpenPay purchase
|
|
355
|
+
rights can exist before minting, and non-transferable licenses retain purchase
|
|
356
|
+
rights after burn; use the Verify API after separately authenticating the wallet
|
|
357
|
+
if your service needs that purchase-rights policy. For transferable licenses,
|
|
358
|
+
post-mint rights follow the current holder. Licenses carry no usage allowance or
|
|
359
|
+
spend balance; `createJpycGate` handles separate x402 pay-per-use. SDK spend
|
|
360
|
+
defaults remain unchanged.
|
|
361
|
+
|
|
362
|
+
ERC-8217 note: the license remains a standard ERC-1155. The agent-binding format
|
|
363
|
+
will be published later; SDK 0.7.1 does not emit or validate binding metadata.
|
|
364
|
+
|
|
365
|
+
### SDK verification in this repository
|
|
366
|
+
|
|
367
|
+
```bash
|
|
368
|
+
npm test --prefix packages/x402-sdk
|
|
369
|
+
npx vitest run tests/packages/x402-sdk-*.test.ts
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The package's unit tests use Node's built-in test runner with mocked RPC/fetch
|
|
373
|
+
and real EOA signatures. Existing buyer/seller regression and tarball tests
|
|
374
|
+
remain in the root Vitest suite; `npm run typecheck` also checks license API
|
|
375
|
+
consumer types.
|
|
376
|
+
|
|
172
377
|
## Money guards
|
|
173
378
|
|
|
174
379
|
| Option | Default | Guard |
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Address, Hex } from 'viem';
|
|
1
|
+
import type { Address, Hex, PublicClient } from 'viem';
|
|
2
2
|
|
|
3
3
|
export type JpycAmount = string | number;
|
|
4
4
|
|
|
@@ -333,6 +333,168 @@ export interface JpycGate {
|
|
|
333
333
|
|
|
334
334
|
export function createJpycGate(options: JpycGateOptions): JpycGate;
|
|
335
335
|
|
|
336
|
+
/** uint256 identity; JS numbers and decimal strings are intentionally unsupported. */
|
|
337
|
+
export type LicenseTokenId = bigint | Hex;
|
|
338
|
+
|
|
339
|
+
export interface LicenseIdentity {
|
|
340
|
+
chainId: number;
|
|
341
|
+
contract: Address;
|
|
342
|
+
tokenId: LicenseTokenId;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export type LicensePublicClient = Pick<PublicClient, 'getChainId' | 'getBlockNumber' | 'readContract'>;
|
|
346
|
+
|
|
347
|
+
/** Polygon/Amoy public RPC defaults; other chains require an explicit transport. */
|
|
348
|
+
export type LicenseTransport =
|
|
349
|
+
| { rpcUrl?: string; publicClient?: never }
|
|
350
|
+
| { rpcUrl?: never; publicClient: LicensePublicClient };
|
|
351
|
+
|
|
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 };
|
|
389
|
+
|
|
390
|
+
export interface LicenseBalance {
|
|
391
|
+
holder: boolean;
|
|
392
|
+
balance: bigint;
|
|
393
|
+
/** The block used for balanceOf, fetched without the viem block-number cache. */
|
|
394
|
+
blockNumber: bigint;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export type LicenseErrorCode =
|
|
398
|
+
| 'rpc_error' | 'network_error' | 'http_error' | 'redirect' | 'invalid_response'
|
|
399
|
+
| 'nonce_store_error' | 'invalid_challenge' | 'challenge_expired'
|
|
400
|
+
| 'invalid_signature' | 'invalid_nonce' | 'no_license'
|
|
401
|
+
| 'invalid_session' | 'session_expired' | 'not_ready';
|
|
402
|
+
|
|
403
|
+
export class LicenseError extends Error {
|
|
404
|
+
readonly code: LicenseErrorCode;
|
|
405
|
+
constructor(code: LicenseErrorCode, message: string, options?: { cause?: unknown });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export class LicenseRpcError extends LicenseError {
|
|
409
|
+
readonly code: 'rpc_error';
|
|
410
|
+
constructor(message?: string, options?: { cause?: unknown });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** RPC failures (including a wrong chain or malformed result) throw LicenseRpcError. */
|
|
414
|
+
export function hasLicense(options: HasLicenseOptions): Promise<LicenseBalance>;
|
|
415
|
+
|
|
416
|
+
export type LicenseNftStatus =
|
|
417
|
+
| 'awaiting_finality' | 'pending' | 'submitted' | 'minted' | 'registered'
|
|
418
|
+
| 'retryable' | 'needs_repair' | 'unknown';
|
|
419
|
+
|
|
420
|
+
export interface VerifyLicenseOptions {
|
|
421
|
+
address: Address;
|
|
422
|
+
/** OpenPay hosted product ID: h_ followed by 32 lowercase hex digits. */
|
|
423
|
+
product: string;
|
|
424
|
+
/** Trusted HTTPS authority. Default: https://open-pay.jp. HTTP only on localhost/127.0.0.1. */
|
|
425
|
+
origin?: string;
|
|
426
|
+
/** Must honor redirect: 'manual' and the AbortSignal. */
|
|
427
|
+
fetch?: typeof globalThis.fetch;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface LicenseVerification {
|
|
431
|
+
version: 1;
|
|
432
|
+
address: Address;
|
|
433
|
+
license: { chainId: number; contract: Address; tokenId: Hex; productId: string };
|
|
434
|
+
/** null means UNKNOWN, never false. This status response is not authentication. */
|
|
435
|
+
entitled: boolean | null;
|
|
436
|
+
basis: 'purchase' | 'holder' | null;
|
|
437
|
+
nft: { status: LicenseNftStatus; mintTxHash?: Hex };
|
|
438
|
+
observedBlock?: string;
|
|
439
|
+
checkedAt: string;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Validates the v1 response and address/product identity. Rejects all redirects. */
|
|
443
|
+
export function verifyLicense(options: VerifyLicenseOptions): Promise<LicenseVerification>;
|
|
444
|
+
|
|
445
|
+
export interface LicenseNonceRecord {
|
|
446
|
+
message: string;
|
|
447
|
+
/** Unix milliseconds. */
|
|
448
|
+
expiresAt: number;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface LicenseNonceStore {
|
|
452
|
+
/** Persist until expiresAt. Throw on failure. */
|
|
453
|
+
set(nonce: string, record: LicenseNonceRecord): void | Promise<void>;
|
|
454
|
+
/** Atomically return AND delete once across workers. Never implement as separate get/delete. */
|
|
455
|
+
consume(nonce: string): LicenseNonceRecord | null | undefined | Promise<LicenseNonceRecord | null | undefined>;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export type LicenseGateOptions = LicenseSelector & LicenseTransport & {
|
|
459
|
+
session: {
|
|
460
|
+
/** Server-only random secret, at least 32 UTF-8 bytes. */
|
|
461
|
+
secret: string;
|
|
462
|
+
/** Seconds, 1–86400. Default 300. Ownership is cached for this lifetime. */
|
|
463
|
+
ttlSeconds?: number;
|
|
464
|
+
/** Your service's signing origin/session audience. Defaults to the top-level origin. */
|
|
465
|
+
origin?: string;
|
|
466
|
+
};
|
|
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. */
|
|
469
|
+
origin?: string;
|
|
470
|
+
/** Single-line ASCII SIWE statement. */
|
|
471
|
+
statement?: string;
|
|
472
|
+
/** Default: a bounded in-memory store for this gate instance. */
|
|
473
|
+
nonceStore?: LicenseNonceStore;
|
|
474
|
+
/** Clock in Unix milliseconds. Default Date.now. */
|
|
475
|
+
now?: () => number;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
export interface LicenseSession {
|
|
479
|
+
address: Address;
|
|
480
|
+
tokenId: bigint;
|
|
481
|
+
/** Unix seconds. */
|
|
482
|
+
exp: number;
|
|
483
|
+
}
|
|
484
|
+
|
|
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>;
|
|
488
|
+
/** An EIP-4361-style message, valid for five minutes. */
|
|
489
|
+
challenge(address: Address): Promise<string>;
|
|
490
|
+
/** EOA signature recovery, atomic nonce consumption, balanceOf, then HMAC session issuance. */
|
|
491
|
+
verify(input: { message: string; signature: Hex }): Promise<string>;
|
|
492
|
+
/** Synchronous signature/scope/expiry validation; no IO. Throws not_ready before discovery. */
|
|
493
|
+
check(token: string): LicenseSession;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function createLicenseGate(options: LicenseGateOptions): LicenseGate;
|
|
497
|
+
|
|
336
498
|
export interface DualGateOptions extends JpycGateOptions {
|
|
337
499
|
/** OpenPay listing id (MY_RESOURCE_ID in the generated snippet). Enables the USDC (Base) rail. */
|
|
338
500
|
resourceId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openpay-x402-sdk",
|
|
3
|
-
"version": "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",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"engines": {
|
|
21
21
|
"node": ">=20"
|
|
22
22
|
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test tests/*.test.mjs"
|
|
25
|
+
},
|
|
23
26
|
"dependencies": {
|
|
24
27
|
"viem": "^2.45.0"
|
|
25
28
|
}
|
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,8 @@ export * from './dualGate.mjs';
|
|
|
4
4
|
export * from './listing.mjs';
|
|
5
5
|
export * from './executor.mjs';
|
|
6
6
|
export * from './gate.mjs';
|
|
7
|
+
export * from './license.mjs';
|
|
8
|
+
export * from './licenseGate.mjs';
|
|
7
9
|
export * from './guards.mjs';
|
|
8
10
|
export * from './network.mjs';
|
|
9
11
|
export * from './payment.mjs';
|
package/src/license.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createPublicClient, http, keccak256, parseAbi, toBytes } from 'viem';
|
|
2
|
+
import { polygon, polygonAmoy } from 'viem/chains';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LICENSE_ORIGIN, MAX_LICENSE_UINT256, LicenseError, LicenseRpcError,
|
|
5
|
+
licenseAddress, licenseIdentity, licenseOrigin, licenseProduct, licenseSelector,
|
|
6
|
+
} from './licenseCommon.mjs';
|
|
7
|
+
|
|
8
|
+
export { LicenseError, LicenseRpcError } from './licenseCommon.mjs';
|
|
9
|
+
|
|
10
|
+
const BALANCE_ABI = parseAbi(['function balanceOf(address account, uint256 id) view returns (uint256)']);
|
|
11
|
+
const NFT_STATUSES = new Set([
|
|
12
|
+
'awaiting_finality', 'pending', 'submitted', 'minted', 'registered',
|
|
13
|
+
'retryable', 'needs_repair', 'unknown',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export async function hasLicense({ address, product, origin, fetch, chainId, contract, tokenId, rpcUrl, publicClient }) {
|
|
17
|
+
const holderAddress = licenseAddress(address);
|
|
18
|
+
const selected = licenseSelector({ product, chainId, contract, tokenId });
|
|
19
|
+
if (rpcUrl !== undefined && publicClient !== undefined) {
|
|
20
|
+
throw new TypeError('Provide rpcUrl or publicClient, not both');
|
|
21
|
+
}
|
|
22
|
+
if (rpcUrl !== undefined && (typeof rpcUrl !== 'string' || !/^https?:\/\//.test(rpcUrl))) {
|
|
23
|
+
throw new TypeError('rpcUrl must be an HTTP(S) URL');
|
|
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
|
+
}
|
|
30
|
+
const client = publicClient ?? createPublicClient({
|
|
31
|
+
chain, transport: http(rpcUrl, { retryCount: 0, timeout: 10_000 }),
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
// Verify the endpoint's chain, then pin balanceOf to the reported block. A wrong
|
|
35
|
+
// network or partial RPC response must never become a false ownership verdict.
|
|
36
|
+
if (await client.getChainId() !== identity.chainId) throw new Error('RPC chainId mismatch');
|
|
37
|
+
const blockNumber = await client.getBlockNumber({ cacheTime: 0 });
|
|
38
|
+
if (typeof blockNumber !== 'bigint' || blockNumber < 0n) throw new Error('Invalid RPC block number');
|
|
39
|
+
const balance = await client.readContract({
|
|
40
|
+
address: identity.contract, abi: BALANCE_ABI, functionName: 'balanceOf',
|
|
41
|
+
args: [holderAddress, identity.tokenId], blockNumber,
|
|
42
|
+
});
|
|
43
|
+
if (typeof balance !== 'bigint' || balance < 0n || balance > MAX_LICENSE_UINT256) {
|
|
44
|
+
throw new Error('Invalid RPC balance');
|
|
45
|
+
}
|
|
46
|
+
return { holder: balance > 0n, balance, blockNumber };
|
|
47
|
+
} catch (cause) {
|
|
48
|
+
throw new LicenseRpcError('Unable to read license ownership on the requested chain', { cause });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function object(value) {
|
|
53
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateResponse(body, address, product) {
|
|
57
|
+
try {
|
|
58
|
+
if (!object(body) || body.version !== 1 || licenseAddress(body.address) !== address ||
|
|
59
|
+
!object(body.license) || body.license.productId !== product ||
|
|
60
|
+
typeof body.license.tokenId !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(body.license.tokenId)) {
|
|
61
|
+
throw new Error('Invalid version, address echo or license identity');
|
|
62
|
+
}
|
|
63
|
+
const identity = licenseIdentity(body.license);
|
|
64
|
+
if (identity.tokenId !== BigInt(keccak256(toBytes(`openpay:license:${product}`))) ||
|
|
65
|
+
(typeof body.entitled !== 'boolean' && body.entitled !== null) ||
|
|
66
|
+
![null, 'purchase', 'holder'].includes(body.basis) ||
|
|
67
|
+
(body.entitled === null && body.basis !== null) ||
|
|
68
|
+
(body.entitled === true && body.basis === null) ||
|
|
69
|
+
!object(body.nft) || !NFT_STATUSES.has(body.nft.status) ||
|
|
70
|
+
(body.nft.mintTxHash !== undefined &&
|
|
71
|
+
(typeof body.nft.mintTxHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(body.nft.mintTxHash))) ||
|
|
72
|
+
(body.observedBlock !== undefined &&
|
|
73
|
+
(typeof body.observedBlock !== 'string' || !/^(0|[1-9][0-9]*)$/.test(body.observedBlock))) ||
|
|
74
|
+
typeof body.checkedAt !== 'string' || !Number.isFinite(Date.parse(body.checkedAt))) {
|
|
75
|
+
throw new Error('Invalid license status');
|
|
76
|
+
}
|
|
77
|
+
} catch (cause) {
|
|
78
|
+
// Reject untrusted/malformed state rather than granting or denying access from it.
|
|
79
|
+
throw new LicenseError('invalid_response', 'Invalid license verify response', { cause });
|
|
80
|
+
}
|
|
81
|
+
// Project only the validated v1 fields; null remains the API's unknown state.
|
|
82
|
+
return {
|
|
83
|
+
version: 1, address: body.address,
|
|
84
|
+
license: { chainId: body.license.chainId, contract: body.license.contract,
|
|
85
|
+
tokenId: body.license.tokenId, productId: body.license.productId },
|
|
86
|
+
entitled: body.entitled, basis: body.basis,
|
|
87
|
+
nft: { status: body.nft.status, ...(body.nft.mintTxHash !== undefined ? { mintTxHash: body.nft.mintTxHash } : {}) },
|
|
88
|
+
...(body.observedBlock !== undefined ? { observedBlock: body.observedBlock } : {}),
|
|
89
|
+
checkedAt: body.checkedAt,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function verifyLicense({ address, product, origin = DEFAULT_LICENSE_ORIGIN, fetch: fetchImpl = globalThis.fetch }) {
|
|
94
|
+
const expectedAddress = licenseAddress(address);
|
|
95
|
+
licenseProduct(product);
|
|
96
|
+
const trustedOrigin = licenseOrigin(origin);
|
|
97
|
+
const url = new URL('/api/license/verify', trustedOrigin);
|
|
98
|
+
url.searchParams.set('address', expectedAddress);
|
|
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') {
|
|
104
|
+
let response;
|
|
105
|
+
try {
|
|
106
|
+
response = await fetchImpl(url.toString(), {
|
|
107
|
+
method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(15_000),
|
|
108
|
+
headers: { accept: 'application/json' },
|
|
109
|
+
});
|
|
110
|
+
} catch (cause) {
|
|
111
|
+
throw new LicenseError('network_error', `License ${label} request failed`, { cause });
|
|
112
|
+
}
|
|
113
|
+
// Never send the query to a redirect destination. Also reject an injected fetch
|
|
114
|
+
// that reports following a redirect or returning a different origin's response.
|
|
115
|
+
if (response.redirected || response.type === 'opaqueredirect' ||
|
|
116
|
+
(response.status >= 300 && response.status < 400) ||
|
|
117
|
+
(response.url && new URL(response.url).origin !== trustedOrigin)) {
|
|
118
|
+
throw new LicenseError('redirect', `License ${label} redirects are not allowed`);
|
|
119
|
+
}
|
|
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 });
|
|
123
|
+
}
|
|
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
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getAddress, isAddress, zeroAddress } from 'viem';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LICENSE_ORIGIN = 'https://open-pay.jp';
|
|
4
|
+
export const MAX_LICENSE_UINT256 = (1n << 256n) - 1n;
|
|
5
|
+
|
|
6
|
+
export class LicenseError extends Error {
|
|
7
|
+
constructor(code, message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = 'LicenseError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class LicenseRpcError extends LicenseError {
|
|
15
|
+
constructor(message = 'License RPC failed', options) {
|
|
16
|
+
super('rpc_error', message, options);
|
|
17
|
+
this.name = 'LicenseRpcError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function licenseAddress(value, label = 'address') {
|
|
22
|
+
if (typeof value !== 'string' || !isAddress(value) || value.toLowerCase() === zeroAddress) {
|
|
23
|
+
throw new TypeError(`${label} must be a non-zero EVM address`);
|
|
24
|
+
}
|
|
25
|
+
return getAddress(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function licenseIdentity({ chainId, contract, tokenId }) {
|
|
29
|
+
if (!Number.isSafeInteger(chainId) || chainId <= 0) {
|
|
30
|
+
throw new TypeError('chainId must be a positive safe integer');
|
|
31
|
+
}
|
|
32
|
+
if (typeof tokenId !== 'bigint' &&
|
|
33
|
+
!(typeof tokenId === 'string' && /^0x[0-9a-fA-F]{1,64}$/.test(tokenId))) {
|
|
34
|
+
throw new TypeError('tokenId must be a uint256 bigint or 0x-hex string, never a number');
|
|
35
|
+
}
|
|
36
|
+
const id = BigInt(tokenId);
|
|
37
|
+
if (id < 0n || id > MAX_LICENSE_UINT256) throw new TypeError('tokenId must fit uint256');
|
|
38
|
+
return { chainId, contract: licenseAddress(contract, 'contract'), tokenId: id };
|
|
39
|
+
}
|
|
40
|
+
|
|
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 } = {}) {
|
|
58
|
+
let url;
|
|
59
|
+
try { url = new URL(value); } catch {
|
|
60
|
+
throw new TypeError('origin must be an HTTPS origin');
|
|
61
|
+
}
|
|
62
|
+
// The status authority and signing domain must not be substituted over plaintext.
|
|
63
|
+
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
64
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && local && !httpsOnly)) ||
|
|
65
|
+
url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
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');
|
|
68
|
+
}
|
|
69
|
+
return url.origin;
|
|
70
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { verifyMessage } from 'viem';
|
|
3
|
+
import { createSiweMessage, parseSiweMessage } from 'viem/siwe';
|
|
4
|
+
import { hasLicense, resolveLicense } from './license.mjs';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_LICENSE_ORIGIN, LicenseError, licenseAddress, licenseIdentity, licenseOrigin, licenseSelector,
|
|
7
|
+
} from './licenseCommon.mjs';
|
|
8
|
+
|
|
9
|
+
const CHALLENGE_TTL_MS = 5 * 60_000;
|
|
10
|
+
const MAX_PENDING_NONCES = 10_000;
|
|
11
|
+
|
|
12
|
+
function memoryNonceStore(now) {
|
|
13
|
+
const entries = new Map();
|
|
14
|
+
return {
|
|
15
|
+
set(nonce, record) {
|
|
16
|
+
for (const [key, value] of entries) {
|
|
17
|
+
if (value.expiresAt <= now()) entries.delete(key);
|
|
18
|
+
}
|
|
19
|
+
if (entries.size >= MAX_PENDING_NONCES) throw new Error('Too many pending license challenges');
|
|
20
|
+
entries.set(nonce, record);
|
|
21
|
+
},
|
|
22
|
+
consume(nonce) {
|
|
23
|
+
const record = entries.get(nonce);
|
|
24
|
+
entries.delete(nonce);
|
|
25
|
+
return record;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createLicenseGate({
|
|
31
|
+
product, fetch, chainId, contract, tokenId, rpcUrl, publicClient,
|
|
32
|
+
session, origin = DEFAULT_LICENSE_ORIGIN,
|
|
33
|
+
statement = 'Sign in to use this license.', nonceStore, now = Date.now,
|
|
34
|
+
}) {
|
|
35
|
+
let identity = licenseSelector({ product, chainId, contract, tokenId });
|
|
36
|
+
if (product !== undefined) licenseOrigin(origin, { httpsOnly: true });
|
|
37
|
+
const audience = licenseOrigin(session?.origin ?? origin);
|
|
38
|
+
const url = new URL(audience);
|
|
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
|
+
}
|
|
63
|
+
if (typeof session?.secret !== 'string' || Buffer.byteLength(session.secret, 'utf8') < 32) {
|
|
64
|
+
throw new TypeError('session.secret must contain at least 32 bytes of secret key material');
|
|
65
|
+
}
|
|
66
|
+
const secret = session.secret;
|
|
67
|
+
const ttlSeconds = session.ttlSeconds ?? 300;
|
|
68
|
+
if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0 || ttlSeconds > 86_400) {
|
|
69
|
+
throw new TypeError('session.ttlSeconds must be an integer between 1 and 86400');
|
|
70
|
+
}
|
|
71
|
+
if (typeof statement !== 'string' || !/^[\x20-\x7e]*$/.test(statement)) {
|
|
72
|
+
throw new TypeError('statement must be a single-line ASCII string');
|
|
73
|
+
}
|
|
74
|
+
const store = nonceStore ?? memoryNonceStore(now);
|
|
75
|
+
if (typeof store.set !== 'function' || typeof store.consume !== 'function') {
|
|
76
|
+
throw new TypeError('nonceStore must implement set and atomic consume');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function messageFor(address, nonce, issuedAt) {
|
|
80
|
+
return createSiweMessage({
|
|
81
|
+
address, chainId, domain: url.host, scheme: url.protocol.slice(0, -1),
|
|
82
|
+
uri: audience, version: '1', statement, nonce, issuedAt: new Date(issuedAt),
|
|
83
|
+
expirationTime: new Date(issuedAt + CHALLENGE_TTL_MS), resources: [resource],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function storeCall(method, ...args) {
|
|
88
|
+
try { return await store[method](...args); } catch (cause) {
|
|
89
|
+
// A failed nonce write/consume must not produce a usable authentication session.
|
|
90
|
+
throw new LicenseError('nonce_store_error', 'License nonce store failed', { cause });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mac(value) {
|
|
95
|
+
return createHmac('sha256', secret).update(value).digest();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function challenge(address) {
|
|
99
|
+
const holderAddress = licenseAddress(address);
|
|
100
|
+
await ready();
|
|
101
|
+
const nonce = randomBytes(32).toString('hex');
|
|
102
|
+
const issuedAt = now();
|
|
103
|
+
const message = messageFor(holderAddress, nonce, issuedAt);
|
|
104
|
+
await storeCall('set', nonce, { message, expiresAt: issuedAt + CHALLENGE_TTL_MS });
|
|
105
|
+
return message;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function verify({ message, signature }) {
|
|
109
|
+
await ready();
|
|
110
|
+
if (typeof message !== 'string' || message.length > 8192) {
|
|
111
|
+
throw new LicenseError('invalid_challenge', 'Invalid license challenge');
|
|
112
|
+
}
|
|
113
|
+
const parsed = parseSiweMessage(message);
|
|
114
|
+
const issuedAt = parsed.issuedAt?.getTime();
|
|
115
|
+
const expiresAt = parsed.expirationTime?.getTime();
|
|
116
|
+
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > now()) {
|
|
117
|
+
throw new LicenseError('invalid_challenge', 'Invalid license challenge time');
|
|
118
|
+
}
|
|
119
|
+
if (expiresAt <= now()) throw new LicenseError('challenge_expired', 'License challenge expired');
|
|
120
|
+
let expected;
|
|
121
|
+
try { expected = messageFor(licenseAddress(parsed.address), parsed.nonce, issuedAt); } catch {
|
|
122
|
+
throw new LicenseError('invalid_challenge', 'Invalid license challenge fields');
|
|
123
|
+
}
|
|
124
|
+
// Exact reconstruction binds domain, URI, chain, contract, token, statement and
|
|
125
|
+
// expiry, including fields a permissive SIWE parser would otherwise ignore.
|
|
126
|
+
if (message !== expected) throw new LicenseError('invalid_challenge', 'License challenge does not match this gate');
|
|
127
|
+
let valid = false;
|
|
128
|
+
try {
|
|
129
|
+
valid = typeof signature === 'string' && /^0x[0-9a-fA-F]+$/.test(signature) &&
|
|
130
|
+
await verifyMessage({ address: parsed.address, message, signature });
|
|
131
|
+
} catch {
|
|
132
|
+
// Malformed signatures and signatures from a different EOA both deny entry.
|
|
133
|
+
throw new LicenseError('invalid_signature', 'Invalid license signature');
|
|
134
|
+
}
|
|
135
|
+
if (!valid) throw new LicenseError('invalid_signature', 'Invalid license signature');
|
|
136
|
+
// consume must be atomic across callers; a get/delete pair permits concurrent replay.
|
|
137
|
+
const record = await storeCall('consume', parsed.nonce);
|
|
138
|
+
if (!record || record.message !== message || record.expiresAt !== expiresAt) {
|
|
139
|
+
throw new LicenseError('invalid_nonce', 'License nonce is missing, used or mismatched');
|
|
140
|
+
}
|
|
141
|
+
if (expiresAt <= now()) throw new LicenseError('challenge_expired', 'License challenge expired');
|
|
142
|
+
const address = licenseAddress(parsed.address);
|
|
143
|
+
const ownership = await hasLicense({ ...identity, address, rpcUrl, publicClient });
|
|
144
|
+
if (expiresAt <= now()) throw new LicenseError('challenge_expired', 'License challenge expired');
|
|
145
|
+
if (!ownership.holder) throw new LicenseError('no_license', 'Wallet does not hold this license');
|
|
146
|
+
const iat = Math.floor(now() / 1000);
|
|
147
|
+
const payload = { version: 1, aud: audience, chainId, contract: identity.contract,
|
|
148
|
+
tokenId: idHex, address, iat, exp: iat + ttlSeconds };
|
|
149
|
+
const value = `opl1.${Buffer.from(JSON.stringify(payload)).toString('base64url')}`;
|
|
150
|
+
return `${value}.${mac(value).toString('base64url')}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function check(token) {
|
|
154
|
+
if (!identity) throw new LicenseError('not_ready', 'Call await gate.ready() before checking sessions');
|
|
155
|
+
const invalid = () => new LicenseError('invalid_session', 'Invalid license session');
|
|
156
|
+
if (typeof token !== 'string' || token.length > 4096) throw invalid();
|
|
157
|
+
const match = /^(opl1\.[A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/.exec(token);
|
|
158
|
+
if (!match) throw invalid();
|
|
159
|
+
const tag = Buffer.from(match[2], 'base64url');
|
|
160
|
+
if (tag.toString('base64url') !== match[2] || !timingSafeEqual(tag, mac(match[1]))) throw invalid();
|
|
161
|
+
let payload;
|
|
162
|
+
try { payload = JSON.parse(Buffer.from(match[1].slice(5), 'base64url').toString('utf8')); } catch {
|
|
163
|
+
throw invalid();
|
|
164
|
+
}
|
|
165
|
+
if (!payload || payload.version !== 1 || payload.aud !== audience ||
|
|
166
|
+
payload.chainId !== chainId || payload.contract !== identity.contract || payload.tokenId !== idHex ||
|
|
167
|
+
!Number.isSafeInteger(payload.iat) || !Number.isSafeInteger(payload.exp) ||
|
|
168
|
+
payload.iat < 0 || payload.iat > Math.floor(now() / 1000) ||
|
|
169
|
+
payload.exp <= payload.iat || payload.exp - payload.iat > ttlSeconds) throw invalid();
|
|
170
|
+
let address;
|
|
171
|
+
try { address = licenseAddress(payload.address); } catch { throw invalid(); }
|
|
172
|
+
if (payload.exp <= Math.floor(now() / 1000)) throw new LicenseError('session_expired', 'License session expired');
|
|
173
|
+
return { address, tokenId: identity.tokenId, exp: payload.exp };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return { ready, challenge, verify, check };
|
|
177
|
+
}
|