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 +14 -0
- package/README.md +66 -17
- package/index.d.ts +46 -5
- package/package.json +1 -1
- 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,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.
|
|
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
|
|
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
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
|
}
|