signet-verify 0.7.2 → 0.7.4

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4 (2026-10-21)
4
+
5
+ ### Bug Fixes
6
+
7
+ - move the default cross-device relay off nos.lol (#54)
8
+
9
+
10
+
11
+ ## 0.7.3 (2026-10-18)
12
+
13
+ ### Bug Fixes
14
+
15
+ - close the re-audit's medium-severity doc gaps (#51) (docs)
16
+
17
+
18
+
3
19
  ## 0.7.2 (2026-10-16)
4
20
 
5
21
  ### Bug Fixes
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Add privacy-preserving age verification to any website. One script tag, one function call. No personal data collected.
4
4
 
5
+ `verifyAge` is the listening half of a same-browser exchange: it opens a modal and waits for a signed response message from a sender in the same origin. This package does not include that sender, and `verifyAge` does not hand the page the request ID the sender needs. See [What posts the response](#what-posts-the-response).
6
+
5
7
  ## Install
6
8
 
7
9
  ```bash
@@ -10,11 +12,11 @@ npm install signet-verify
10
12
 
11
13
  ## Quick Start
12
14
 
13
- Script tag (the IIFE bundle exposes `window.Signet`; the version is pinned, so change `0.7.1` to upgrade):
15
+ Script tag (the IIFE bundle exposes `window.Signet`; the URL pins a version, so change the version number in it to upgrade):
14
16
 
15
17
  ```html
16
18
  <button id="verify-btn">Verify your age</button>
17
- <script src="https://cdn.jsdelivr.net/npm/signet-verify@0.7.1/dist/signet-verify.iife.js"></script>
19
+ <script src="https://cdn.jsdelivr.net/npm/signet-verify@0.7.2/dist/signet-verify.iife.js"></script>
18
20
  <script>
19
21
  document.getElementById('verify-btn').addEventListener('click', async () => {
20
22
  const result = await Signet.verifyAge('18+');
@@ -37,16 +39,59 @@ const result = await verifyAge('18+');
37
39
  if (!result.verified) console.log('Not verified:', result.error);
38
40
  ```
39
41
 
42
+ Both samples show the call and the result handling. `result.verified` becomes `true` only after a same-origin sender posts a valid `signet-verify-response` message for that call (see [The response message](#the-response-message)). Without one, the modal keeps waiting and the call resolves with `error: 'timeout'` after 120 seconds (the default `timeout`), or `error: 'cancelled'` if the user closes the modal.
43
+
44
+ ### Node, SSR and tests
45
+
46
+ The package is ESM only and has no CommonJS build. Importing it in Node, during server-side rendering or in a test runner is safe: loading it touches no browser global, and it sets `window.Signet` only where a `window` exists. It needs Node 20.19 or later, the floor set by the `engines` fields of its `@noble/curves` and `@noble/hashes` dependencies.
47
+
48
+ - `verifyAge` is browser only. It needs `document`, `window.matchMedia`, `BroadcastChannel` and the `<dialog>` element. In Node it rejects with `ReferenceError: document is not defined` rather than resolving with `result.error`. The one exception is an invalid `requiredAgeRange`, which resolves with `error: 'invalid-age-range'` before touching the DOM. Call it from client-side code only, and stub it or use a real browser in tests.
49
+ - `waitForAuthResponse` needs no DOM, only a global `WebSocket`. Node 22 and later has one. Where there is none (Node 20, for example) the call rejects with `relay-error`. Outside a browser it never reconnects: the first socket error or close rejects with `relay-error` or `relay-closed`.
50
+
40
51
  ## How It Works
41
52
 
42
53
  1. Website calls `Signet.verifyAge('18+')`
43
54
  2. A modal opens. The SDK builds a `signet-verify-request` (request ID, required age range, `relayUrl`, timestamp) but the modal shows only a truncated text placeholder, `signet:verify:` plus the first 40 characters of the base64 payload, not a rendered QR code. Those 40 characters stop before the request ID and the relay URL
44
- 3. The modal listens on a `BroadcastChannel` named `signet-verify-<requestId>`. It opens no WebSocket and never reads from a relay. The proof must arrive as a `signet-verify-response` message carrying that request ID on that channel, so whatever posts it has to run in the same origin and browser profile as the page
45
- 4. The SDK checks the credential's signature, expiry and age range, then asks the verification bot about the verifier's professional registration
55
+ 3. The modal listens on a `BroadcastChannel` named `signet-verify-<requestId>`. It opens no WebSocket and never reads from a relay. The proof must arrive as a `signet-verify-response` message carrying that request ID on that channel, so whatever posts it has to run in the same origin and browser profile as the page. Nothing in this package posts it (see below)
56
+ 4. The SDK checks the credential's signature, expiry and age range, then asks the verification bot about the verifier's professional registration (skipped when `verifierCheckUrl` is `null`)
46
57
  5. Returns the result
47
58
 
48
59
  To receive a response from another device over a relay, use `waitForAuthResponse` (see below); `verifyAge` does not do that.
49
60
 
61
+ ### What posts the response
62
+
63
+ Nothing in this package does. `signet-verify` contains the listener only, and the sender is code you provide, running in the same origin and browser profile as the page that called `verifyAge`.
64
+
65
+ The sender needs the channel name, which contains the request ID. `verifyAge` generates that ID internally and does not expose it: it is not returned, not passed to a callback and not dispatched as an event, and the modal shows only the first 40 characters of the base64 request, which stop before the ID. The package therefore offers no supported way for the page, or for a Signet app on the user's phone, to learn it. Until you have wired up a sender that can, a real user cannot reach `verified: true` through `verifyAge`.
66
+
67
+ ### The response message
68
+
69
+ The listener accepts one object on the channel `signet-verify-<requestId>`, where `<requestId>` is the 32-character hex ID (16 random bytes) generated for that call. The shape is `VerifyResponse` from the `signet-protocol` package, which is the canonical type. Install `signet-protocol` only if you want that type for your sender: `signet-verify` does not depend on it at runtime, and its published type declarations do not reference it.
70
+
71
+ The shape:
72
+
73
+ ```ts
74
+ {
75
+ type: 'signet-verify-response',
76
+ requestId: string, // must equal the ID of the open modal
77
+ credential: { // a signed Nostr event
78
+ id: string, // 64 hex chars: SHA-256 of JSON [0, pubkey, created_at, kind, tags, content]
79
+ kind: 30470, // anything else is rejected
80
+ pubkey: string, // 64 hex chars: the verifier's key, returned as verifierPubkey
81
+ created_at: number, // unix seconds, returned as issuedAt
82
+ tags: string[][], // must include 'tier', 'age-range' and 'entity-type'; optional 'expires' (unix seconds)
83
+ content: string, // at most 65536 characters
84
+ sig: string, // 128 hex chars: BIP-340 Schnorr signature over id by pubkey
85
+ },
86
+ subjectPubkey: string, // part of the protocol type; verifyAge does not read it
87
+ }
88
+ ```
89
+
90
+ How the listener treats a message:
91
+
92
+ - It silently drops anything that is not an object, whose `type` or `requestId` does not match, or whose `credential` is missing or has no `tags` array. The modal keeps waiting.
93
+ - Any other message closes the modal and settles the call, so the first one wins and later messages are ignored. The credential is then checked: a failed structural or signature check (including more than 100 tags, a tag value over 1024 characters, or a missing `tier`, `age-range` or `entity-type` tag) gives `invalid-credential`, a past `expires` gives `credential-expired`, and an `age-range` tag that is not equal to the required range gives `age-range-not-met`. The verifier check then follows, as described under Verifier Trust.
94
+
50
95
  ## Verifier Trust
51
96
 
52
97
  By default, the SDK checks the verifier's professional registration against public registers (GMC, SRA, TRA, etc.) via the Signet verification bot. This prevents fake professional rings from issuing accepted credentials.
@@ -56,19 +101,35 @@ By default, the SDK checks the verifier's professional registration against publ
56
101
  - **Decentralised:** the verification bot is open source — anyone can run their own
57
102
 
58
103
  ```javascript
59
- // Default: safe — only confirmed verifiers pass
60
- const result = await Signet.verifyAge('18+');
104
+ // Default: safe, only confirmed verifiers pass
105
+ const strict = await Signet.verifyAge('18+');
61
106
 
62
107
  // Accept unconfirmed verifiers (less safe, more permissive)
63
- const result = await Signet.verifyAge('18+', { acceptUnconfirmed: true });
108
+ const lenient = await Signet.verifyAge('18+', { acceptUnconfirmed: true });
64
109
 
65
110
  // Use your own verification bot
66
- const result = await Signet.verifyAge('18+', { verifierCheckUrl: 'https://my-bot.example.com' });
111
+ const ownBot = await Signet.verifyAge('18+', { verifierCheckUrl: 'https://my-bot.example.com' });
67
112
 
68
- // Skip verifier checking entirely
69
- const result = await Signet.verifyAge('18+', { verifierCheckUrl: null });
113
+ // Skip the verifier check. On its own this never verifies: the result is
114
+ // `verified: false` with `error: 'verifier-check-unavailable'`.
115
+ // To accept the credential without a check, also set acceptUnconfirmed.
116
+ const unchecked = await Signet.verifyAge('18+', { verifierCheckUrl: null, acceptUnconfirmed: true });
70
117
  ```
71
118
 
119
+ ### What `verified` is when the check is skipped or fails
120
+
121
+ The credential must first be valid, unexpired and in the required age range. The table then shows how the verifier check decides `verified`:
122
+
123
+ | Verifier check | `verifierConfirmed` | Default (`acceptUnconfirmed: false`) | `acceptUnconfirmed: true` |
124
+ |---|---|---|---|
125
+ | Bot says confirmed | `true` | `verified: true` | `verified: true` |
126
+ | Bot says not confirmed | `false` | `verified: false`, `verifier-not-confirmed` | `verified: true`, no error |
127
+ | Skipped or failed | `null` | `verified: false`, `verifier-check-unavailable` | `verified: true`, no error |
128
+
129
+ The check counts as skipped when `verifierCheckUrl` is `null`, and as failed when the URL does not start with `https://`, the bot is unreachable or does not answer within 5 seconds, it returns a non-2xx status, or the body is not JSON. With `acceptUnconfirmed: true` the bot is still queried (unless `verifierCheckUrl` is `null`), so `verifierConfirmed` is still reported.
130
+
131
+ When the check is skipped, or `acceptUnconfirmed` is `true`, `verified: true` means only that a well-formed credential signed by some key meets the age range. It does not show that the signing key belongs to a registered professional.
132
+
72
133
  ## Cross-device Sign-in-with-Signet
73
134
 
74
135
  `verifyAge` resolves with a `SignetVerifyResult` and reports failures in `result.error` rather than rejecting. `waitForAuthResponse` resolves with a `SignetAuthResult` on success and rejects with an `Error` whose `message` and `code` are the failure code (see below). Their `timeout` options also differ: for `verifyAge` it is how long the modal waits before resolving with `error: 'timeout'`; for `waitForAuthResponse` it is an optional, earlier give-up.
@@ -77,10 +138,16 @@ const result = await Signet.verifyAge('18+', { verifierCheckUrl: null });
77
138
  `BroadcastChannel`, never over a relay. For a cross-device sign-in,
78
139
  a consumer minting its own challenge, opening the auth URL or QR on one
79
140
  device, and waiting on a relay for the response published from the user's
80
- phone, use `waitForAuthResponse` directly:
141
+ phone, use `waitForAuthResponse` directly.
142
+
143
+ The example below imports `@noble/curves` and `@noble/hashes` itself. Both are dependencies of `signet-verify`, but the SDK does not re-export them, so install the ones your code imports (version 2 of each: in `@noble/curves` 2 the key generator is `schnorr.utils.randomSecretKey()`, and `randomPrivateKey()` no longer exists):
144
+
145
+ ```bash
146
+ npm install signet-verify @noble/curves@2 @noble/hashes@2
147
+ ```
81
148
 
82
149
  ```ts
83
- import { schnorr } from '@noble/curves/secp256k1.js'; // install @noble/curves yourself; the SDK does not re-export it
150
+ import { schnorr } from '@noble/curves/secp256k1.js';
84
151
  import { bytesToHex } from '@noble/hashes/utils.js';
85
152
  import { waitForAuthResponse, DEFAULT_RELAY_URL } from 'signet-verify';
86
153
 
@@ -96,8 +163,11 @@ const requestId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
96
163
  const issuedAt = Math.floor(Date.now() / 1000);
97
164
 
98
165
  // 4. The auth URL (render it as a QR code or a link). The parameters mirror
99
- // examples/cross-device-login.html; `relay` must match `relayUrl` below.
100
- const signetAppUrl = 'https://signet-app.example'; // placeholder: your Signet app's origin
166
+ // examples/cross-device-login.html; `relay` must match `relayUrl` below. The
167
+ // Signet app README documents them: `origin` is https (or http://localhost),
168
+ // `callback` is on that origin, `t` is within 300 seconds of the phone's
169
+ // clock, and `relay` and `sessionPubkey` go together.
170
+ const signetAppUrl = 'https://mysignet.app'; // the Signet app's production origin (its dev server is https://localhost:5174)
101
171
  const authUrl = signetAppUrl + '/?' + new URLSearchParams({
102
172
  auth: '1',
103
173
  challenge: requestId,
@@ -151,9 +221,9 @@ again. This was a real, 100%-reproducible failure before 0.5.2.
151
221
  | `timeout` | The wait gave up early — an explicit shorter `timeout`, or no `issuedAt` was supplied at all | A retry, anchored to the same `issuedAt` |
152
222
  | `denied` | The user rejected the request | Nothing; respect it |
153
223
  | `aborted` | You aborted via `abortSignal` | Nothing |
154
- | `relay-error` | The socket failed. Rejected only where there is no `document` (a non-browser runtime); in a browser the wait reconnects while the page is visible and gives up with `timeout` or `expired` | A retry, anchored to the same `issuedAt` |
224
+ | `relay-error` | The socket failed. Rejected only where there is no `document` (a non-browser runtime); in a browser the wait reconnects while the page is visible and gives up with `timeout` or `expired`. A runtime with no global `WebSocket` (Node 20, for example) gets it at once; see Node, SSR and tests | A retry, anchored to the same `issuedAt` |
155
225
  | `relay-closed` | The socket closed. Same rule as `relay-error` | A retry, anchored to the same `issuedAt` |
156
- | `relay-refused` | The relay closed the subscription — `err.reason` has its text (e.g. `auth-required`) | A relay that serves gift wraps to an unauthenticated reader. `relay.damus.io` currently does not; `nos.lol` (the default) does |
226
+ | `relay-refused` | The relay closed the subscription — `err.reason` has its text (e.g. `auth-required`) | A relay that serves gift wraps to an unauthenticated reader. `relay.damus.io` currently does not; `relay.primal.net` (the default) does |
157
227
  | `invalid-issued-at` | `issuedAt` looks like the wrong unit (e.g. milliseconds) or is too far in the future | A bug in your code, not a retry; check you're passing unix **seconds** |
158
228
  | `invalid-request-id`, `invalid-session-privkey`, `invalid-relay-url`, `invalid-expected-origin` | The matching option failed validation before any connection was made | A bug in your code, not a retry; see the option table below |
159
229
 
@@ -172,22 +242,23 @@ Returns `Promise<SignetVerifyResult>`. Failures are reported in `result.error`,
172
242
 
173
243
  | Option | Type | Default | Description |
174
244
  |--------|------|---------|-------------|
175
- | `relayUrl` | `string` | `DEFAULT_RELAY_URL` (`wss://nos.lol`) | Written into the `relayUrl` field of the request object `verifyAge` builds. `verifyAge` never connects to it, the response does not travel over it (it arrives on a `BroadcastChannel`), and the text the modal shows does not include it. The kind-1059 gift-wrap requirement under `waitForAuthResponse` therefore does not apply to this option |
245
+ | `relayUrl` | `string` | `DEFAULT_RELAY_URL` (`wss://relay.primal.net`) | Written into the `relayUrl` field of the request object `verifyAge` builds. `verifyAge` never connects to it, the response does not travel over it (it arrives on a `BroadcastChannel`), and the text the modal shows does not include it. The kind-1059 gift-wrap requirement under `waitForAuthResponse` therefore does not apply to this option |
176
246
  | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Modal colour scheme |
177
247
  | `timeout` | `number` | `120000` | Timeout in milliseconds, clamped to 5000 to 600000 |
178
- | `verifierCheckUrl` | `string \| null` | `'https://verify.signet.forgesworn.dev'` | Verification bot URL. Set to `null` to skip. |
179
- | `acceptUnconfirmed` | `boolean` | `false` | Accept credentials from unconfirmed verifiers |
248
+ | `verifierCheckUrl` | `string \| null` | `'https://verify.signet.forgesworn.dev'` | Verification bot URL. Set to `null` to skip the check; unless `acceptUnconfirmed` is also `true`, `verified` is then `false` with `verifier-check-unavailable`. A URL that does not start with `https://` counts as a failed check |
249
+ | `acceptUnconfirmed` | `boolean` | `false` | Accept credentials from unconfirmed verifiers, and when the check is skipped or fails (see What `verified` is when the check is skipped or fails) |
180
250
 
181
251
  **Result:**
182
252
 
183
253
  | Field | Type | Description |
184
254
  |-------|------|-------------|
185
- | `verified` | `boolean` | `true` if credential valid, age range met, AND verifier confirmed (or `acceptUnconfirmed` is true) |
255
+ | `verified` | `boolean` | `true` if credential valid, unexpired, age range met, AND verifier confirmed (or `acceptUnconfirmed` is true). With `verifierCheckUrl: null`, or when the check fails, `verifierConfirmed` is `null` and `verified` is `false` unless `acceptUnconfirmed` is true |
186
256
  | `ageRange` | `string \| null` | Age range from the credential (e.g. `'18+'`) |
187
257
  | `tier` | `number \| null` | Verification tier (1–4) |
188
258
  | `entityType` | `string \| null` | `'natural_person'`, `'persona'`, etc. |
259
+ | `credentialId` | `string \| null` | ID of the credential event (`credential.id`) |
189
260
  | `verifierPubkey` | `string \| null` | Professional keypair of the issuing verifier |
190
- | `verifierConfirmed` | `boolean \| null` | Whether the verifier is confirmed against a public register. `null` if check unavailable. |
261
+ | `verifierConfirmed` | `boolean \| null` | Whether the verifier is confirmed against a public register. `null` if the check was skipped or unavailable. |
191
262
  | `verifierMethod` | `'A' \| 'B' \| 'C' \| 'D' \| null` | How the verifier was confirmed: A=NIP-05 on body domain, B=body-issued, C=cross-verified, D=website |
192
263
  | `issuedAt` | `number \| null` | Unix timestamp of credential issuance |
193
264
  | `expiresAt` | `number \| null` | Unix timestamp of credential expiry |
@@ -204,7 +275,7 @@ Returns `Promise<SignetVerifyResult>`. Failures are reported in `result.error`,
204
275
  | `'credential-expired'` | The credential's `expires` time has passed |
205
276
  | `'age-range-not-met'` | Credential age range doesn't satisfy the requirement |
206
277
  | `'verifier-not-confirmed'` | Verifier is not confirmed against a public register |
207
- | `'verifier-check-unavailable'` | Verification bot unreachable (credential valid but verifier status unknown) |
278
+ | `'verifier-check-unavailable'` | The verifier check was skipped (`verifierCheckUrl: null`) or failed (non-`https://` URL, bot unreachable, non-2xx or non-JSON answer). The credential is valid but the verifier status is unknown, so `verified` is `false` unless `acceptUnconfirmed` is true |
208
279
 
209
280
  ### `waitForAuthResponse(options)` (`Signet.waitForAuthResponse` in the IIFE build)
210
281
 
@@ -228,13 +299,46 @@ Returns `Promise<SignetAuthResult>`. It resolves with the verified result and re
228
299
  | Field | Type | Description |
229
300
  |-------|------|-------------|
230
301
  | `pubkey` | `string` | The user's pubkey (64-character hex); a copy of `authEvent.pubkey` |
231
- | `authEvent` | `SignetAuthEvent` | The verified, signed kind-21236 event: `id`, `pubkey`, `kind`, `created_at`, `tags`, `content`, `sig` |
232
- | `credential` | `unknown` (optional) | Credential included in the response (Signet Login flows), passed through as received |
302
+ | `authEvent` | `SignetAuthEvent` | The signed kind-21236 event, verified in the browser by this package: `id`, `pubkey`, `kind`, `created_at`, `tags`, `content`, `sig` |
303
+ | `credential` | `unknown` (optional) | Credential included in the response (Signet Login flows), passed through as received, not checked by this package |
233
304
  | `displayName` | `string` (optional) | Persona handle the user chose to share. Control and bidi characters are stripped and it is capped at 64 characters; still escape it when rendering |
234
- | `bunkerUri` | `string` (optional) | A `bunker://` URI for upgrading to a live signer. Absent on auth-only flows |
305
+ | `bunkerUri` | `string` (optional) | A `bunker://` URI for upgrading to a live signer. Absent on auth-only flows. Only its `bunker://` prefix is checked |
235
306
  | `createdAt` | `number` | Unix seconds at which the auth event was signed |
236
307
 
237
- **Constants:** `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds) and `DEFAULT_RELAY_URL` (`'wss://nos.lol'`).
308
+ **Constants:** `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds) and `DEFAULT_RELAY_URL` (`'wss://relay.primal.net'`).
309
+
310
+ ### Verify the result on your server
311
+
312
+ `waitForAuthResponse` verifies the response in the browser, and a browser result is not evidence for your server: the user, or script running in the page, can send your server any `pubkey` or `SignetAuthResult` it likes. Re-verify on the server before you create a session. Send the whole `authEvent`, not only `pubkey`, and check all of the following there:
313
+
314
+ - `kind` is `21236` and the event's `id` and `sig` verify (`verifyEvent` from `nostr-tools/pure` recomputes the NIP-01 id and checks the BIP-340 signature).
315
+ - The `challenge` tag equals the challenge your server issued for this attempt. Accept each challenge once, then discard it.
316
+ - The `origin` tag equals your own origin exactly.
317
+ - `created_at` is within `AUTH_FRESHNESS_WINDOW_SEC` of the server clock.
318
+ - Take the user's identity from the verified `authEvent.pubkey`.
319
+
320
+ ```ts
321
+ import { verifyEvent } from 'nostr-tools/pure';
322
+ import { AUTH_FRESHNESS_WINDOW_SEC, type SignetAuthEvent } from 'signet-verify';
323
+
324
+ // `pending` is what your server stored when it issued this sign-in.
325
+ function authEventIsValid(authEvent: SignetAuthEvent, pending: { challenge: string; origin: string }): boolean {
326
+ const tag = (name: string) => authEvent.tags.find((t) => t[0] === name)?.[1];
327
+ return (
328
+ authEvent.kind === 21236 &&
329
+ verifyEvent(authEvent) &&
330
+ tag('challenge')?.toLowerCase() === pending.challenge.toLowerCase() &&
331
+ tag('origin') === pending.origin &&
332
+ Math.abs(Date.now() / 1000 - authEvent.created_at) <= AUTH_FRESHNESS_WINDOW_SEC
333
+ );
334
+ }
335
+ ```
336
+
337
+ Mint the challenge and `issuedAt` on the server and keep them against the pending sign-in. The cross-device example above mints them in the browser for brevity, and a challenge the client chose cannot be tied to an attempt your server started.
338
+
339
+ `credential` is `unknown` and is forwarded exactly as the response carried it. This package does not check its shape, signature or expiry, so validate it yourself or ignore it.
340
+
341
+ Alternatively, run `waitForAuthResponse` on the server (see Node, SSR and tests) and keep `sessionPrivKey` there. The result then never passes through the client.
238
342
 
239
343
  ## Age Range Values
240
344
 
@@ -5,7 +5,7 @@
5
5
  * import { verifyAge, waitForAuthResponse } from 'signet-verify';
6
6
  *
7
7
  * Script-tag / CDN usage (IIFE bundle exposes `window.Signet`):
8
- * <script src="https://cdn.signet.forgesworn.dev/signet-verify.iife.js"></script>
8
+ * <script src="https://cdn.jsdelivr.net/npm/signet-verify@0.7.2/dist/signet-verify.iife.js"></script>
9
9
  * <script>
10
10
  * const result = await Signet.verifyAge('18+');
11
11
  * if (result.verified) { // allow access }
@@ -29,7 +29,7 @@ export interface SignetVerifyResult {
29
29
  export interface SignetVerifyOptions {
30
30
  /** Required age range to verify (e.g., '18+', '13-17') */
31
31
  requiredAgeRange: string;
32
- /** Relay URL for cross-device communication. Default: `DEFAULT_RELAY_URL` (wss://nos.lol). */
32
+ /** Relay URL for cross-device communication. Default: `DEFAULT_RELAY_URL` (wss://relay.primal.net). */
33
33
  relayUrl?: string;
34
34
  /** Callback URL for same-device flow */
35
35
  callbackUrl?: string;
@@ -40,7 +40,8 @@ export interface SignetVerifyOptions {
40
40
  /**
41
41
  * URL of the verification bot that checks verifier credentials against public registers.
42
42
  * Default: 'https://verify.signet.forgesworn.dev'
43
- * Set to null to skip verifier checking (accept any signed credential).
43
+ * Set to null to skip the bot: verifierConfirmed is then null, so the result fails
44
+ * with verifier-check-unavailable unless acceptUnconfirmed is true.
44
45
  * Anyone can run their own bot — the URL is configurable, not a central authority.
45
46
  */
46
47
  verifierCheckUrl?: string | null;
@@ -72,7 +73,7 @@ export declare const AUTH_FRESHNESS_WINDOW_SEC = 300;
72
73
  * relay.damus.io, the previous default, refuses those reads and has AUTH
73
74
  * misconfigured, so nothing can fetch a response from it.
74
75
  */
75
- export declare const DEFAULT_RELAY_URL = "wss://nos.lol";
76
+ export declare const DEFAULT_RELAY_URL = "wss://relay.primal.net";
76
77
  /** Options for waiting on a cross-device Sign-in-with-Signet response. */
77
78
  export interface WaitForAuthOptions {
78
79
  /** The challenge (also the `requestId`) sent in the auth URL. 64-char hex. */
@@ -4,7 +4,7 @@
4
4
  <div id="signet-qr" style="display:flex;justify-content:center;margin-bottom:24px;"></div>
5
5
  <p style="margin:0 0 16px;color:${l};font-size:0.8rem;">Waiting for verification...</p>
6
6
  <button id="signet-cancel" style="background:none;border:1px solid ${l};color:${y};padding:10px 24px;border-radius:8px;cursor:pointer;font-size:0.9rem;">Cancel</button>
7
- `,document.body.appendChild(x),x.showModal();let A=x.querySelector("#signet-qr");if(A){let L=document.createElement("div");L.style.cssText=`width:200px;height:200px;background:${u?"#2a2a3e":"#f0f0f0"};border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:${l};word-break:break-all;padding:12px;`,L.textContent=`signet:verify:${c.slice(0,40)}...`,A.appendChild(L)}let B=new BroadcastChannel("signet-verify-"+o);x.querySelector("#signet-cancel")?.addEventListener("click",()=>{B.close(),x.close(),x.remove(),a.remove(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"cancelled"})}),B.onmessage=async L=>{let D=L.data;if(typeof D!="object"||D===null)return;let P=D;if(P.type!=="signet-verify-response"||P.requestId!==o||!P.credential||typeof P.credential!="object"||!Array.isArray(P.credential.tags))return;let j=P.credential,G=await Ki(j),K=Gt(j.tags,"age-range"),z=Gt(j.tags,"tier"),Y=Gt(j.tags,"entity-type"),$=Gt(j.tags,"expires"),F=K?zi(K,n.requiredAgeRange):!1,M=await Yi(j.pubkey,n.verifierCheckUrl),C=M?.confirmed??null,H=M?.method??null,h=n.acceptUnconfirmed||C===!0;x.close(),x.remove(),a.remove(),B.close();let b=z?parseInt(z,10):null,v=$?parseInt($,10):null,S=Math.floor(Date.now()/1e3),O=v===null||!isNaN(v)&&v>S,p;G?O?F?h||(p=C===!1?"verifier-not-confirmed":"verifier-check-unavailable"):p="age-range-not-met":p="credential-expired":p="invalid-credential",f({verified:G&&O&&F&&h,ageRange:K||null,tier:b!==null&&!isNaN(b)?b:null,entityType:Y||null,credentialId:j.id,verifierPubkey:j.pubkey,verifierConfirmed:C,verifierMethod:H,issuedAt:j.created_at,expiresAt:v!==null&&!isNaN(v)?v:null,error:p})},setTimeout(()=>{x.close(),x.remove(),a.remove(),B.close(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"timeout"})},n.timeout)})}var ot=300,Qo="wss://nos.lol",Xo=60;function Qn(t){let e=JSON.stringify([0,t.pubkey,t.created_at,t.kind,t.tags,t.content]),r=$e(new TextEncoder().encode(e));return Re(r)}async function Wi(t,e){try{let r=Xn(e,t.pubkey),n=Jn(t.content,r),o=JSON.parse(n);if(typeof o!="object"||o===null)return null;let s=o;if(s.kind!==13||typeof s.pubkey!="string"||!/^[0-9a-f]{64}$/i.test(s.pubkey)||typeof s.created_at!="number"||!Array.isArray(s.tags)||typeof s.content!="string"||typeof s.id!="string"||!/^[0-9a-f]{64}$/i.test(s.id)||typeof s.sig!="string"||!/^[0-9a-f]{128}$/i.test(s.sig)||Qn({pubkey:s.pubkey,created_at:s.created_at,kind:13,tags:s.tags,content:s.content})!==s.id.toLowerCase())return null;let c=Ae(s.sig),f=Ae(s.id),a=Ae(s.pubkey);if(!at.verify(c,f,a))return null;let u=Xn(e,s.pubkey),d=Jn(s.content,u),y=JSON.parse(d);if(typeof y!="object"||y===null)return null;let l=y;if(typeof l.pubkey!="string"||l.pubkey!==s.pubkey||typeof l.kind!="number"||typeof l.created_at!="number"||!Array.isArray(l.tags)||typeof l.content!="string")return null;let x=typeof l.id=="string"?l.id:Qn({pubkey:l.pubkey,created_at:l.created_at,kind:l.kind,tags:l.tags,content:l.content});return{pubkey:l.pubkey,id:x,kind:l.kind,created_at:l.created_at,tags:l.tags,content:l.content}}catch{return null}}function Gi(t){return es(t,64)}function es(t,e){if(typeof t!="string")return;let r=t.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g,"").trim().slice(0,e);return r.length>0?r:void 0}async function ts(t){let e=a=>{let u=new Error(a);return u.code=a,u};if(!/^[0-9a-f]{64}$/i.test(t.requestId))throw e("invalid-request-id");if(!(t.sessionPrivKey instanceof Uint8Array)||t.sessionPrivKey.length!==32)throw e("invalid-session-privkey");if(!/^wss:\/\//i.test(t.relayUrl)&&!/^ws:\/\/(localhost|127\.0\.0\.1)([:\/]|$)/i.test(t.relayUrl))throw e("invalid-relay-url");if(typeof t.expectedOrigin!="string"||t.expectedOrigin.length===0)throw e("invalid-expected-origin");if(Number.isFinite(t.issuedAt)&&t.issuedAt>Math.floor(Date.now()/1e3)+ot)throw e("invalid-issued-at");let r=Re(at.getPublicKey(t.sessionPrivKey)),n=Number.isFinite(t.issuedAt)?(Math.floor(t.issuedAt)+ot)*1e3-Date.now():12e4,o=Math.max(5e3,Math.min(t.timeout??n,6e5)),s=t.requestId.toLowerCase(),i=t.expectedOrigin,c=Number.isFinite(t.since)?Math.floor(t.since):Number.isFinite(t.issuedAt)?Math.floor(t.issuedAt)-Xo:Math.floor(Date.now()/1e3)-Xo,f=()=>Number.isFinite(t.issuedAt)&&Date.now()>=(Math.floor(t.issuedAt)+ot)*1e3-1e3;return new Promise((a,u)=>{let d=`sa-${Math.random().toString(36).slice(2,12)}`,y=!1,l=!1,x,A,B=Date.now()+o,L=F=>{if(!y){y=!0,clearTimeout(P),clearTimeout(A),typeof document<"u"&&document.removeEventListener("visibilitychange",G),t.abortSignal?.removeEventListener("abort",$);try{x?.close()}catch{}F()}},D=()=>L(()=>u(e(l||f()?"expired":"timeout"))),P=setTimeout(()=>{D()},o),j=()=>{!y&&x?.readyState===1&&x.send(JSON.stringify(["REQ",d,{kinds:[1059],"#p":[r],since:c}]))},G=()=>{if(!(y||document.visibilityState!=="visible")){if(Date.now()>=B)return D();!x||x.readyState>=2?Y():j()}};typeof document<"u"&&document.addEventListener("visibilitychange",G);let K=async F=>{if(y)return;let M;try{M=JSON.parse(typeof F.data=="string"?F.data:"")}catch{return}if(!Array.isArray(M))return;if(M[0]==="CLOSED"&&M[1]===d){let U=es(M[2],200);L(()=>{let V=e("relay-refused");U&&(V.reason=U),u(V)});return}if(M[0]!=="EVENT"||M[1]!==d)return;let C=M[2];if(typeof C!="object"||C===null)return;let H=C;if(H.kind!==1059||typeof H.pubkey!="string"||typeof H.content!="string")return;let h=await Wi({pubkey:H.pubkey,content:H.content},t.sessionPrivKey);if(!h||h.kind!==29999)return;let b=h.tags.find(U=>U[0]==="session");if(!b||b[1]!==s)return;let v=h.tags.find(U=>U[0]==="status");if(v?.[1]==="rejected"){L(()=>u(e("denied")));return}if(v?.[1]!=="approved")return;if(Math.abs(Date.now()/1e3-h.created_at)>ot){l=!0;return}let O;try{let U=JSON.parse(h.content);if(typeof U!="object"||U===null)return;O=U}catch{return}if(O.type!=="signet-auth-response"||O.requestId!==s||typeof O.authEvent!="object"||O.authEvent===null)return;let p=O.authEvent;if(typeof p.id!="string"||!/^[0-9a-f]{64}$/i.test(p.id)||typeof p.pubkey!="string"||!/^[0-9a-f]{64}$/i.test(p.pubkey)||typeof p.sig!="string"||!/^[0-9a-f]{128}$/i.test(p.sig)||p.kind!==21236||typeof p.created_at!="number"||!Array.isArray(p.tags)||typeof p.content!="string"||p.pubkey.toLowerCase()!==h.pubkey.toLowerCase()||Qn({pubkey:p.pubkey,created_at:p.created_at,kind:21236,tags:p.tags,content:p.content})!==p.id.toLowerCase())return;let w=!1;try{let U=Ae(p.sig),V=Ae(p.id),Z=Ae(p.pubkey);w=at.verify(U,V,Z)}catch{w=!1}if(!w)return;let g=p.tags,E=g.find(U=>Array.isArray(U)&&U[0]==="challenge");if(!E||typeof E[1]!="string"||E[1].toLowerCase()!==s)return;let I=g.find(U=>Array.isArray(U)&&U[0]==="origin");if(!I||I[1]!==i)return;if(Math.abs(Date.now()/1e3-p.created_at)>ot){l=!0;return}let T={id:p.id.toLowerCase(),pubkey:p.pubkey.toLowerCase(),kind:21236,created_at:p.created_at,tags:g,content:p.content,sig:p.sig},k=Gi(O.displayName),q=typeof O.bunkerUri=="string"&&/^bunker:\/\//i.test(O.bunkerUri)?O.bunkerUri:void 0;L(()=>a({pubkey:T.pubkey,authEvent:T,credential:O.credential,...k!==void 0?{displayName:k}:{},...q!==void 0?{bunkerUri:q}:{},createdAt:T.created_at}))},z=F=>{if(!y){if(typeof document>"u")return L(()=>u(e(F)));clearTimeout(A),document.visibilityState==="visible"&&(A=setTimeout(Y,1e3))}};function Y(){if(y)return;if(clearTimeout(A),Date.now()>=B)return D();let F=x;x=void 0;try{F?.close()}catch{}try{let M=new WebSocket(t.relayUrl);x=M,M.onopen=j,M.onmessage=K,M.onerror=()=>{x===M&&z("relay-error")},M.onclose=()=>{x===M&&z("relay-closed")}}catch{z("relay-error")}}let $=()=>L(()=>u(e("aborted")));if(t.abortSignal?.aborted){L(()=>u(e("aborted")));return}t.abortSignal?.addEventListener("abort",$,{once:!0}),Y()})}typeof window<"u"&&(window.Signet={verifyAge:Jo,waitForAuthResponse:ts});return fs(Xi);})();
7
+ `,document.body.appendChild(x),x.showModal();let A=x.querySelector("#signet-qr");if(A){let L=document.createElement("div");L.style.cssText=`width:200px;height:200px;background:${u?"#2a2a3e":"#f0f0f0"};border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:0.75rem;color:${l};word-break:break-all;padding:12px;`,L.textContent=`signet:verify:${c.slice(0,40)}...`,A.appendChild(L)}let B=new BroadcastChannel("signet-verify-"+o);x.querySelector("#signet-cancel")?.addEventListener("click",()=>{B.close(),x.close(),x.remove(),a.remove(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"cancelled"})}),B.onmessage=async L=>{let D=L.data;if(typeof D!="object"||D===null)return;let P=D;if(P.type!=="signet-verify-response"||P.requestId!==o||!P.credential||typeof P.credential!="object"||!Array.isArray(P.credential.tags))return;let j=P.credential,G=await Ki(j),K=Gt(j.tags,"age-range"),z=Gt(j.tags,"tier"),Y=Gt(j.tags,"entity-type"),$=Gt(j.tags,"expires"),F=K?zi(K,n.requiredAgeRange):!1,M=await Yi(j.pubkey,n.verifierCheckUrl),C=M?.confirmed??null,H=M?.method??null,h=n.acceptUnconfirmed||C===!0;x.close(),x.remove(),a.remove(),B.close();let b=z?parseInt(z,10):null,v=$?parseInt($,10):null,S=Math.floor(Date.now()/1e3),O=v===null||!isNaN(v)&&v>S,p;G?O?F?h||(p=C===!1?"verifier-not-confirmed":"verifier-check-unavailable"):p="age-range-not-met":p="credential-expired":p="invalid-credential",f({verified:G&&O&&F&&h,ageRange:K||null,tier:b!==null&&!isNaN(b)?b:null,entityType:Y||null,credentialId:j.id,verifierPubkey:j.pubkey,verifierConfirmed:C,verifierMethod:H,issuedAt:j.created_at,expiresAt:v!==null&&!isNaN(v)?v:null,error:p})},setTimeout(()=>{x.close(),x.remove(),a.remove(),B.close(),f({verified:!1,ageRange:null,tier:null,entityType:null,credentialId:null,verifierPubkey:null,verifierConfirmed:null,verifierMethod:null,issuedAt:null,expiresAt:null,error:"timeout"})},n.timeout)})}var ot=300,Qo="wss://relay.primal.net",Xo=60;function Qn(t){let e=JSON.stringify([0,t.pubkey,t.created_at,t.kind,t.tags,t.content]),r=$e(new TextEncoder().encode(e));return Re(r)}async function Wi(t,e){try{let r=Xn(e,t.pubkey),n=Jn(t.content,r),o=JSON.parse(n);if(typeof o!="object"||o===null)return null;let s=o;if(s.kind!==13||typeof s.pubkey!="string"||!/^[0-9a-f]{64}$/i.test(s.pubkey)||typeof s.created_at!="number"||!Array.isArray(s.tags)||typeof s.content!="string"||typeof s.id!="string"||!/^[0-9a-f]{64}$/i.test(s.id)||typeof s.sig!="string"||!/^[0-9a-f]{128}$/i.test(s.sig)||Qn({pubkey:s.pubkey,created_at:s.created_at,kind:13,tags:s.tags,content:s.content})!==s.id.toLowerCase())return null;let c=Ae(s.sig),f=Ae(s.id),a=Ae(s.pubkey);if(!at.verify(c,f,a))return null;let u=Xn(e,s.pubkey),d=Jn(s.content,u),y=JSON.parse(d);if(typeof y!="object"||y===null)return null;let l=y;if(typeof l.pubkey!="string"||l.pubkey!==s.pubkey||typeof l.kind!="number"||typeof l.created_at!="number"||!Array.isArray(l.tags)||typeof l.content!="string")return null;let x=typeof l.id=="string"?l.id:Qn({pubkey:l.pubkey,created_at:l.created_at,kind:l.kind,tags:l.tags,content:l.content});return{pubkey:l.pubkey,id:x,kind:l.kind,created_at:l.created_at,tags:l.tags,content:l.content}}catch{return null}}function Gi(t){return es(t,64)}function es(t,e){if(typeof t!="string")return;let r=t.replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u2028-\u202e\u2066-\u2069]/g,"").trim().slice(0,e);return r.length>0?r:void 0}async function ts(t){let e=a=>{let u=new Error(a);return u.code=a,u};if(!/^[0-9a-f]{64}$/i.test(t.requestId))throw e("invalid-request-id");if(!(t.sessionPrivKey instanceof Uint8Array)||t.sessionPrivKey.length!==32)throw e("invalid-session-privkey");if(!/^wss:\/\//i.test(t.relayUrl)&&!/^ws:\/\/(localhost|127\.0\.0\.1)([:\/]|$)/i.test(t.relayUrl))throw e("invalid-relay-url");if(typeof t.expectedOrigin!="string"||t.expectedOrigin.length===0)throw e("invalid-expected-origin");if(Number.isFinite(t.issuedAt)&&t.issuedAt>Math.floor(Date.now()/1e3)+ot)throw e("invalid-issued-at");let r=Re(at.getPublicKey(t.sessionPrivKey)),n=Number.isFinite(t.issuedAt)?(Math.floor(t.issuedAt)+ot)*1e3-Date.now():12e4,o=Math.max(5e3,Math.min(t.timeout??n,6e5)),s=t.requestId.toLowerCase(),i=t.expectedOrigin,c=Number.isFinite(t.since)?Math.floor(t.since):Number.isFinite(t.issuedAt)?Math.floor(t.issuedAt)-Xo:Math.floor(Date.now()/1e3)-Xo,f=()=>Number.isFinite(t.issuedAt)&&Date.now()>=(Math.floor(t.issuedAt)+ot)*1e3-1e3;return new Promise((a,u)=>{let d=`sa-${Math.random().toString(36).slice(2,12)}`,y=!1,l=!1,x,A,B=Date.now()+o,L=F=>{if(!y){y=!0,clearTimeout(P),clearTimeout(A),typeof document<"u"&&document.removeEventListener("visibilitychange",G),t.abortSignal?.removeEventListener("abort",$);try{x?.close()}catch{}F()}},D=()=>L(()=>u(e(l||f()?"expired":"timeout"))),P=setTimeout(()=>{D()},o),j=()=>{!y&&x?.readyState===1&&x.send(JSON.stringify(["REQ",d,{kinds:[1059],"#p":[r],since:c}]))},G=()=>{if(!(y||document.visibilityState!=="visible")){if(Date.now()>=B)return D();!x||x.readyState>=2?Y():j()}};typeof document<"u"&&document.addEventListener("visibilitychange",G);let K=async F=>{if(y)return;let M;try{M=JSON.parse(typeof F.data=="string"?F.data:"")}catch{return}if(!Array.isArray(M))return;if(M[0]==="CLOSED"&&M[1]===d){let U=es(M[2],200);L(()=>{let V=e("relay-refused");U&&(V.reason=U),u(V)});return}if(M[0]!=="EVENT"||M[1]!==d)return;let C=M[2];if(typeof C!="object"||C===null)return;let H=C;if(H.kind!==1059||typeof H.pubkey!="string"||typeof H.content!="string")return;let h=await Wi({pubkey:H.pubkey,content:H.content},t.sessionPrivKey);if(!h||h.kind!==29999)return;let b=h.tags.find(U=>U[0]==="session");if(!b||b[1]!==s)return;let v=h.tags.find(U=>U[0]==="status");if(v?.[1]==="rejected"){L(()=>u(e("denied")));return}if(v?.[1]!=="approved")return;if(Math.abs(Date.now()/1e3-h.created_at)>ot){l=!0;return}let O;try{let U=JSON.parse(h.content);if(typeof U!="object"||U===null)return;O=U}catch{return}if(O.type!=="signet-auth-response"||O.requestId!==s||typeof O.authEvent!="object"||O.authEvent===null)return;let p=O.authEvent;if(typeof p.id!="string"||!/^[0-9a-f]{64}$/i.test(p.id)||typeof p.pubkey!="string"||!/^[0-9a-f]{64}$/i.test(p.pubkey)||typeof p.sig!="string"||!/^[0-9a-f]{128}$/i.test(p.sig)||p.kind!==21236||typeof p.created_at!="number"||!Array.isArray(p.tags)||typeof p.content!="string"||p.pubkey.toLowerCase()!==h.pubkey.toLowerCase()||Qn({pubkey:p.pubkey,created_at:p.created_at,kind:21236,tags:p.tags,content:p.content})!==p.id.toLowerCase())return;let w=!1;try{let U=Ae(p.sig),V=Ae(p.id),Z=Ae(p.pubkey);w=at.verify(U,V,Z)}catch{w=!1}if(!w)return;let g=p.tags,E=g.find(U=>Array.isArray(U)&&U[0]==="challenge");if(!E||typeof E[1]!="string"||E[1].toLowerCase()!==s)return;let I=g.find(U=>Array.isArray(U)&&U[0]==="origin");if(!I||I[1]!==i)return;if(Math.abs(Date.now()/1e3-p.created_at)>ot){l=!0;return}let T={id:p.id.toLowerCase(),pubkey:p.pubkey.toLowerCase(),kind:21236,created_at:p.created_at,tags:g,content:p.content,sig:p.sig},k=Gi(O.displayName),q=typeof O.bunkerUri=="string"&&/^bunker:\/\//i.test(O.bunkerUri)?O.bunkerUri:void 0;L(()=>a({pubkey:T.pubkey,authEvent:T,credential:O.credential,...k!==void 0?{displayName:k}:{},...q!==void 0?{bunkerUri:q}:{},createdAt:T.created_at}))},z=F=>{if(!y){if(typeof document>"u")return L(()=>u(e(F)));clearTimeout(A),document.visibilityState==="visible"&&(A=setTimeout(Y,1e3))}};function Y(){if(y)return;if(clearTimeout(A),Date.now()>=B)return D();let F=x;x=void 0;try{F?.close()}catch{}try{let M=new WebSocket(t.relayUrl);x=M,M.onopen=j,M.onmessage=K,M.onerror=()=>{x===M&&z("relay-error")},M.onclose=()=>{x===M&&z("relay-closed")}}catch{z("relay-error")}}let $=()=>L(()=>u(e("aborted")));if(t.abortSignal?.aborted){L(()=>u(e("aborted")));return}t.abortSignal?.addEventListener("abort",$,{once:!0}),Y()})}typeof window<"u"&&(window.Signet={verifyAge:Jo,waitForAuthResponse:ts});return fs(Xi);})();
8
8
  /*! Bundled license information:
9
9
 
10
10
  @noble/curves/utils.js:
@@ -5,7 +5,7 @@
5
5
  * import { verifyAge, waitForAuthResponse } from 'signet-verify';
6
6
  *
7
7
  * Script-tag / CDN usage (IIFE bundle exposes `window.Signet`):
8
- * <script src="https://cdn.signet.forgesworn.dev/signet-verify.iife.js"></script>
8
+ * <script src="https://cdn.jsdelivr.net/npm/signet-verify@0.7.2/dist/signet-verify.iife.js"></script>
9
9
  * <script>
10
10
  * const result = await Signet.verifyAge('18+');
11
11
  * if (result.verified) { // allow access }
@@ -299,7 +299,7 @@ export const AUTH_FRESHNESS_WINDOW_SEC = 300;
299
299
  * relay.damus.io, the previous default, refuses those reads and has AUTH
300
300
  * misconfigured, so nothing can fetch a response from it.
301
301
  */
302
- export const DEFAULT_RELAY_URL = 'wss://nos.lol';
302
+ export const DEFAULT_RELAY_URL = 'wss://relay.primal.net';
303
303
  /**
304
304
  * Slack subtracted from `issuedAt` when deriving the relay query window, to
305
305
  * absorb clock skew between the consumer and the signer device. Widening the
package/llms.txt CHANGED
@@ -9,7 +9,10 @@ For websites that need to gate content by age range (adult content, age
9
9
  brackets for minors) without running their own verification flow. A single
10
10
  function call opens a modal, waits for a signed response on a same-origin
11
11
  `BroadcastChannel` (no relay connection), and checks both the credential and
12
- the issuing verifier's professional registration.
12
+ the issuing verifier's professional registration. The package contains the
13
+ listener only: nothing in it posts that response, and `verifyAge` does not
14
+ expose the request ID a sender needs, so a real user cannot reach
15
+ `verified: true` until you wire up a same-origin sender of your own.
13
16
 
14
17
  ## Getting Started
15
18
 
@@ -26,9 +29,14 @@ if (result.verified) {
26
29
  }
27
30
  ```
28
31
 
32
+ This resolves with `verified: true` only after a same-origin sender posts a
33
+ valid `signet-verify-response` message for the call. Otherwise the modal keeps
34
+ waiting and the call resolves with `error: 'timeout'` after 120 seconds, or
35
+ `error: 'cancelled'` if the user closes it.
36
+
29
37
  For a script tag / CDN build, use the IIFE export (`signet-verify/iife`),
30
38
  which exposes `window.Signet`, for example
31
- `https://cdn.jsdelivr.net/npm/signet-verify@0.7.1/dist/signet-verify.iife.js`.
39
+ `https://cdn.jsdelivr.net/npm/signet-verify@0.7.2/dist/signet-verify.iife.js`.
32
40
 
33
41
  ## Key Concepts
34
42
 
@@ -38,12 +46,24 @@ which exposes `window.Signet`, for example
38
46
  when the issuing verifier is checked against a public professional
39
47
  register (GMC, SRA, TRA, etc.) via a verification bot; this can be relaxed
40
48
  with `acceptUnconfirmed` or pointed at a different bot with
41
- `verifierCheckUrl`.
49
+ `verifierCheckUrl`. Setting `verifierCheckUrl: null` skips the check but
50
+ does not accept the credential on its own: `verifierConfirmed` is then
51
+ `null` and the result is `verified: false` with
52
+ `error: 'verifier-check-unavailable'` (as it is when the bot is
53
+ unreachable or the URL is not `https://`) unless `acceptUnconfirmed` is
54
+ also `true`.
42
55
  - **Same-browser vs cross-device**: `verifyAge` covers the modal flow and
43
56
  receives its response on a `BroadcastChannel` named
44
57
  `signet-verify-<requestId>`; it makes one network request, to the
45
58
  verification bot, and never connects to a relay (its `relayUrl` option is
46
- only written into the request object). `waitForAuthResponse` is the
59
+ only written into the request object). The message it accepts is
60
+ `{ type: 'signet-verify-response', requestId, credential, subjectPubkey }`
61
+ (the `VerifyResponse` type from `signet-protocol`; `subjectPubkey` is not
62
+ read), where `requestId` is the 32-character hex ID generated for that call
63
+ and `credential` is a signed kind-30470 Nostr event whose tags include
64
+ `tier`, `age-range` and `entity-type` (and optionally `expires`). The README
65
+ has the full field list and how each failure maps to an error code.
66
+ `waitForAuthResponse` is the
47
67
  lower-level cross-device primitive for a consumer that mints its own
48
68
  challenge and waits on a relay for the signed response; only it needs a
49
69
  relay that serves kind-1059 gift wraps to an unauthenticated reader.
@@ -52,6 +72,21 @@ which exposes `window.Signet`, for example
52
72
  call for the same sign-in, including retries after the page was
53
73
  backgrounded, or the relay query anchor is wrong.
54
74
 
75
+ - **Node, SSR and tests**: the package is ESM only and loads safely in Node
76
+ (20.19 or later, the floor set by its `@noble/curves` and `@noble/hashes`
77
+ dependencies) because it touches no browser global at import time.
78
+ `verifyAge` is browser only: in Node it rejects with a `ReferenceError`
79
+ (except for an invalid age range, which resolves with
80
+ `invalid-age-range`). `waitForAuthResponse` needs only a global
81
+ `WebSocket` (Node 22 and later); without one it rejects with
82
+ `relay-error`.
83
+ - **Verify on your server**: `waitForAuthResponse` checks the response in the
84
+ browser only. Before creating a session, re-verify `authEvent` on your
85
+ server (event id and signature, `challenge` tag equal to the challenge your
86
+ server issued and accepted once, `origin` tag, `created_at` within
87
+ `AUTH_FRESHNESS_WINDOW_SEC`). `credential` is passed through unchecked.
88
+ The README section "Verify the result on your server" has a worked check.
89
+
55
90
  ## API Surface
56
91
 
57
92
  - `verifyAge(requiredAgeRange, options?) => Promise<SignetVerifyResult>`:
@@ -66,9 +101,11 @@ which exposes `window.Signet`, for example
66
101
  `Uint8Array`) and `expectedOrigin` are required; `issuedAt` (unix
67
102
  seconds), `timeout` (milliseconds), `since` (unix seconds) and
68
103
  `abortSignal` are optional. The README has the full option and result
69
- tables.
104
+ tables, and its cross-device example needs `@noble/curves@2` and
105
+ `@noble/hashes@2` installed (it imports them directly) and uses
106
+ `https://mysignet.app`, the Signet app's production origin, in the auth URL.
70
107
  - `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds), `DEFAULT_RELAY_URL`
71
- (`'wss://nos.lol'`): constants used to compute and check the sign-in
108
+ (`'wss://relay.primal.net'`): constants used to compute and check the sign-in
72
109
  deadline.
73
110
  - Result and option types: `SignetVerifyResult`, `SignetVerifyOptions`,
74
111
  `WaitForAuthOptions`, `SignetAuthResult`, `SignetAuthEvent`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signet-verify",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Signet SDK — website age verification and cross-device Sign-in-with-Signet",
5
5
  "type": "module",
6
6
  "main": "./dist/signet-verify.js",
@@ -35,6 +35,9 @@
35
35
  "sdk",
36
36
  "browser"
37
37
  ],
38
+ "engines": {
39
+ "node": ">=20.19.0"
40
+ },
38
41
  "publishConfig": {
39
42
  "provenance": true
40
43
  },