signet-verify 0.7.1 → 0.7.3

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 ADDED
@@ -0,0 +1,119 @@
1
+ # Changelog
2
+
3
+ ## 0.7.3 (2026-10-18)
4
+
5
+ ### Bug Fixes
6
+
7
+ - close the re-audit's medium-severity doc gaps (#51) (docs)
8
+
9
+
10
+
11
+ ## 0.7.2 (2026-10-16)
12
+
13
+ ### Bug Fixes
14
+
15
+ - correct the published docs against the source (#49) (docs)
16
+
17
+
18
+
19
+ ## 0.7.1 (2026-09-25)
20
+
21
+ ### Bug Fixes
22
+
23
+ - default cross-device relay to nos.lol (#46)
24
+
25
+
26
+
27
+ ## 0.7.0 (2026-09-11)
28
+
29
+ ### Features
30
+
31
+ - default to the Signet relay and export it as DEFAULT_RELAY_URL (#44) (verify)
32
+
33
+
34
+
35
+ ## 0.6.1 (2026-09-11)
36
+
37
+ ### Bug Fixes
38
+
39
+ - fail with relay-refused when the relay closes the subscription (#43) (auth)
40
+
41
+
42
+
43
+ ## 0.6.0 (2026-09-11)
44
+
45
+ ### Features
46
+
47
+ - one anchor for the sign-in deadline, and tell the user about it (#42) (auth)
48
+
49
+
50
+
51
+ ## 0.5.2 (2026-09-10)
52
+
53
+ ### Bug Fixes
54
+
55
+ - let a restarted wait keep its original subscription window (#41) (auth)
56
+
57
+
58
+
59
+ ## 0.5.1 (2026-09-08)
60
+
61
+ ### Bug Fixes
62
+
63
+ - replay auth responses after mobile app switching (#39)
64
+
65
+
66
+
67
+ ## 0.5.0 (2026-06-05)
68
+
69
+ ### Features
70
+
71
+ - surface bunkerUri from the auth response (cross-device signer upgrade)
72
+
73
+
74
+
75
+ ## 0.3.1 (2026-05-03)
76
+
77
+ ### Bug Fixes
78
+
79
+ - bump to publish 0.3.0 displayName feature to npm (#11) (release)
80
+
81
+
82
+
83
+ ## 0.3.0 (2026-05-03)
84
+
85
+ ### Features
86
+
87
+ - surface displayName from AuthResponse, sanitised (#9) (auth)
88
+
89
+
90
+
91
+ ## 0.2.2 (2026-05-03)
92
+
93
+ ### Features
94
+
95
+ - `SignetAuthResult.displayName?: string` — surfaces the user's persona handle when they opted to share it on the approval screen. Mirrors the `display_name` URL param delivered by the redirect-back flow. Sanitised on receipt: control + bidi characters stripped, capped at 64 chars, empty values dropped.
96
+
97
+
98
+
99
+ ## 0.2.1 (2026-04-18)
100
+
101
+ ### Bug Fixes
102
+
103
+ - use published signet-protocol@^1.6.0, not file:../signet (deps)
104
+
105
+
106
+
107
+ ## 0.2.0 (2026-04-18)
108
+
109
+ ### Features
110
+
111
+ - reshape signet-verify as a proper ESM npm package
112
+ - waitForAuthResponse SDK helper for cross-device login
113
+ - initial release of signet-verify — drop-in age verification SDK
114
+
115
+ ### Bug Fixes
116
+
117
+ - bump esbuild to ^0.25.12 (GHSA-67mh-4wv8-2f99) (deps)
118
+
119
+
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,8 +12,11 @@ npm install signet-verify
10
12
 
11
13
  ## Quick Start
12
14
 
15
+ Script tag (the IIFE bundle exposes `window.Signet`; the URL pins a version, so change the version number in it to upgrade):
16
+
13
17
  ```html
14
- <script src="https://cdn.signet.forgesworn.dev/signet-verify.js"></script>
18
+ <button id="verify-btn">Verify your age</button>
19
+ <script src="https://cdn.jsdelivr.net/npm/signet-verify@0.7.2/dist/signet-verify.iife.js"></script>
15
20
  <script>
16
21
  document.getElementById('verify-btn').addEventListener('click', async () => {
17
22
  const result = await Signet.verifyAge('18+');
@@ -23,17 +28,69 @@ npm install signet-verify
23
28
  }
24
29
  });
25
30
  </script>
26
- <button id="verify-btn">Verify your age</button>
27
31
  ```
28
32
 
33
+ ESM (bundler, browser only, since `verifyAge` opens a modal):
34
+
35
+ ```ts
36
+ import { verifyAge } from 'signet-verify';
37
+
38
+ const result = await verifyAge('18+');
39
+ if (!result.verified) console.log('Not verified:', result.error);
40
+ ```
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
+
29
51
  ## How It Works
30
52
 
31
53
  1. Website calls `Signet.verifyAge('18+')`
32
- 2. A modal appears with a QR code
33
- 3. User scans with their Signet app and approves
34
- 4. The app sends a cryptographic proof (no PII)
35
- 5. The SDK verifies the proof AND checks the verifier's professional registration
36
- 6. Returns the result — the user's app can be closed
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
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`)
57
+ 5. Returns the result
58
+
59
+ To receive a response from another device over a relay, use `waitForAuthResponse` (see below); `verifyAge` does not do that.
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.
37
94
 
38
95
  ## Verifier Trust
39
96
 
@@ -44,71 +101,119 @@ By default, the SDK checks the verifier's professional registration against publ
44
101
  - **Decentralised:** the verification bot is open source — anyone can run their own
45
102
 
46
103
  ```javascript
47
- // Default: safe — only confirmed verifiers pass
48
- const result = await Signet.verifyAge('18+');
104
+ // Default: safe, only confirmed verifiers pass
105
+ const strict = await Signet.verifyAge('18+');
49
106
 
50
107
  // Accept unconfirmed verifiers (less safe, more permissive)
51
- const result = await Signet.verifyAge('18+', { acceptUnconfirmed: true });
108
+ const lenient = await Signet.verifyAge('18+', { acceptUnconfirmed: true });
52
109
 
53
110
  // Use your own verification bot
54
- 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' });
55
112
 
56
- // Skip verifier checking entirely
57
- 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 });
58
117
  ```
59
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
+
60
133
  ## Cross-device Sign-in-with-Signet
61
134
 
62
- `verifyAge` wraps the same-device QR/modal flow. For a cross-device sign-in —
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.
136
+
137
+ `verifyAge` is the same-browser modal flow: its response arrives on a
138
+ `BroadcastChannel`, never over a relay. For a cross-device sign-in,
63
139
  a consumer minting its own challenge, opening the auth URL or QR on one
64
140
  device, and waiting on a relay for the response published from the user's
65
- 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
+ ```
66
148
 
67
149
  ```ts
68
- import { waitForAuthResponse } from 'signet-verify';
150
+ import { schnorr } from '@noble/curves/secp256k1.js';
151
+ import { bytesToHex } from '@noble/hashes/utils.js';
152
+ import { waitForAuthResponse, DEFAULT_RELAY_URL } from 'signet-verify';
153
+
154
+ // 1. Ephemeral session keypair. The private key must be a 32-byte Uint8Array;
155
+ // a hex string is rejected with `invalid-session-privkey`.
156
+ const sessionPrivKey = schnorr.utils.randomSecretKey();
157
+ const sessionPubkey = bytesToHex(schnorr.getPublicKey(sessionPrivKey));
69
158
 
70
- // Stamp this ONCE, when you mint the challenge and open the auth URL / show the QR.
159
+ // 2. The challenge: 32 random bytes as 64-character hex. This is the `requestId`.
160
+ const requestId = bytesToHex(crypto.getRandomValues(new Uint8Array(32)));
161
+
162
+ // 3. Stamp this ONCE, when you mint the challenge and open the auth URL / show the QR.
71
163
  const issuedAt = Math.floor(Date.now() / 1000);
72
164
 
165
+ // 4. The auth URL (render it as a QR code or a link). The parameters mirror
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)
171
+ const authUrl = signetAppUrl + '/?' + new URLSearchParams({
172
+ auth: '1',
173
+ challenge: requestId,
174
+ origin: location.origin,
175
+ name: 'My Site',
176
+ callback: location.origin + '/callback', // unused in relay mode, still required
177
+ t: String(issuedAt),
178
+ relay: DEFAULT_RELAY_URL,
179
+ sessionPubkey,
180
+ }).toString();
181
+
182
+ // 5. Wait for the response. Call controller.abort() to cancel.
183
+ const controller = new AbortController();
73
184
  const result = await waitForAuthResponse({
74
- requestId, // the challenge you put in the auth URL / QR
75
- relayUrl,
76
- sessionPrivKey, // your ephemeral session keypair's private key
185
+ requestId,
186
+ relayUrl: DEFAULT_RELAY_URL,
187
+ sessionPrivKey,
77
188
  expectedOrigin: location.origin,
78
- issuedAt, // ← pass on EVERY call for this sign-in, including retries
189
+ issuedAt, // pass on EVERY call for this sign-in, including retries
190
+ abortSignal: controller.signal,
79
191
  });
80
192
  ```
81
193
 
82
194
  ### The sign-in deadline
83
195
 
84
196
  A cross-device sign-in has exactly one deadline: `issuedAt + AUTH_FRESHNESS_WINDOW_SEC`.
85
- Past that point, this library stops listening and reports `expired` — show a countdown
86
- to it.
197
+ `AUTH_FRESHNESS_WINDOW_SEC` is `300` seconds (5 minutes). Past that point, this library
198
+ stops listening and reports `expired`, so show a countdown to it.
87
199
 
88
200
  ```ts
89
- import { waitForAuthResponse, AUTH_FRESHNESS_WINDOW_SEC, DEFAULT_RELAY_URL } from 'signet-verify';
90
-
91
- // Stamp this ONCE, when you mint the challenge and open the auth URL / show the QR.
92
- const issuedAt = Math.floor(Date.now() / 1000);
93
- const expiresAt = issuedAt + AUTH_FRESHNESS_WINDOW_SEC; // show a countdown to this
201
+ import { AUTH_FRESHNESS_WINDOW_SEC } from 'signet-verify';
94
202
 
95
- const result = await waitForAuthResponse({
96
- requestId: challenge,
97
- relayUrl: DEFAULT_RELAY_URL, // the Signet relay — must match the relay= in your auth URL
98
- sessionPrivKey,
99
- expectedOrigin: location.origin,
100
- issuedAt, // ← pass on EVERY call for this sign-in, retries included
101
- abortSignal, // ← optional; cancels the wait and closes the subscription
102
- });
203
+ const expiresAt = issuedAt + AUTH_FRESHNESS_WINDOW_SEC; // unix seconds; count down to this
204
+ const secondsLeft = () => Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
103
205
  ```
104
206
 
207
+ The `timeout` option is in milliseconds. Leave it out when you pass `issuedAt`: the
208
+ wait then lasts until the deadline above.
209
+
105
210
  **Pass `issuedAt` on every call, including a restart.** On mobile the page is
106
211
  suspended the moment it hands off to the signer app. A wait restarted without the
107
212
  anchor asks the relay only for responses newer than the restart — which silently
108
213
  excludes the one published while you were backgrounded, and it is never asked for
109
214
  again. This was a real, 100%-reproducible failure before 0.5.2.
110
215
 
111
- **Distinguish the failures.** The rejection message is:
216
+ **Distinguish the failures.** The rejection is an `Error` whose message is one of:
112
217
 
113
218
  | Code | Meaning | What to offer |
114
219
  |---|---|---|
@@ -116,9 +221,11 @@ again. This was a real, 100%-reproducible failure before 0.5.2.
116
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` |
117
222
  | `denied` | The user rejected the request | Nothing; respect it |
118
223
  | `aborted` | You aborted via `abortSignal` | Nothing |
119
- | `relay-error` | The socket failed | 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` |
225
+ | `relay-closed` | The socket closed. Same rule as `relay-error` | A retry, anchored to the same `issuedAt` |
120
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; `nos.lol` (the default) does |
121
- | `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** |
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** |
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 |
122
229
 
123
230
  The code is on both `err.message` and `err.code` — branch on either.
124
231
 
@@ -127,30 +234,31 @@ precedence over `issuedAt`. Prefer `issuedAt`, which derives it correctly.
127
234
 
128
235
  ## API
129
236
 
130
- ### `Signet.verifyAge(requiredAgeRange, options?)`
237
+ ### `verifyAge(requiredAgeRange, options?)` (`Signet.verifyAge` in the IIFE build)
131
238
 
132
- Returns `Promise<SignetVerifyResult>`
239
+ Returns `Promise<SignetVerifyResult>`. Failures are reported in `result.error`, not as a rejection.
133
240
 
134
241
  **Options:**
135
242
 
136
243
  | Option | Type | Default | Description |
137
244
  |--------|------|---------|-------------|
138
- | `relayUrl` | `string` | `DEFAULT_RELAY_URL` (`wss://nos.lol`) | Relay URL for cross-device communication. Must serve kind-1059 gift wraps to an unauthenticated reader |
245
+ | `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 |
139
246
  | `theme` | `'light' \| 'dark' \| 'auto'` | `'auto'` | Modal colour scheme |
140
- | `timeout` | `number` | `120000` | Timeout in milliseconds |
141
- | `verifierCheckUrl` | `string \| null` | `'https://verify.signet.forgesworn.dev'` | Verification bot URL. Set to `null` to skip. |
142
- | `acceptUnconfirmed` | `boolean` | `false` | Accept credentials from unconfirmed verifiers |
247
+ | `timeout` | `number` | `120000` | Timeout in milliseconds, clamped to 5000 to 600000 |
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) |
143
250
 
144
251
  **Result:**
145
252
 
146
253
  | Field | Type | Description |
147
254
  |-------|------|-------------|
148
- | `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 |
149
256
  | `ageRange` | `string \| null` | Age range from the credential (e.g. `'18+'`) |
150
257
  | `tier` | `number \| null` | Verification tier (1–4) |
151
258
  | `entityType` | `string \| null` | `'natural_person'`, `'persona'`, etc. |
259
+ | `credentialId` | `string \| null` | ID of the credential event (`credential.id`) |
152
260
  | `verifierPubkey` | `string \| null` | Professional keypair of the issuing verifier |
153
- | `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. |
154
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 |
155
263
  | `issuedAt` | `number \| null` | Unix timestamp of credential issuance |
156
264
  | `expiresAt` | `number \| null` | Unix timestamp of credential expiry |
@@ -162,10 +270,75 @@ Returns `Promise<SignetVerifyResult>`
162
270
  |-------|---------|
163
271
  | `'cancelled'` | User cancelled the verification |
164
272
  | `'timeout'` | Verification timed out (default: 2 minutes) |
273
+ | `'invalid-age-range'` | `requiredAgeRange` is not one of the five values under Age Range Values; no modal is shown |
165
274
  | `'invalid-credential'` | Credential signature failed verification |
275
+ | `'credential-expired'` | The credential's `expires` time has passed |
166
276
  | `'age-range-not-met'` | Credential age range doesn't satisfy the requirement |
167
277
  | `'verifier-not-confirmed'` | Verifier is not confirmed against a public register |
168
- | `'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 |
279
+
280
+ ### `waitForAuthResponse(options)` (`Signet.waitForAuthResponse` in the IIFE build)
281
+
282
+ Returns `Promise<SignetAuthResult>`. It resolves with the verified result and rejects with an `Error` on failure; `err.code` is one of the codes in the failure table under Cross-device Sign-in-with-Signet.
283
+
284
+ **Options (`WaitForAuthOptions`):**
285
+
286
+ | Option | Type | Required | Default | Description |
287
+ |--------|------|----------|---------|-------------|
288
+ | `requestId` | `string` | yes | none | The challenge sent in the auth URL: 64-character hex (32 bytes) |
289
+ | `relayUrl` | `string` | yes | none | The relay you subscribed on. `wss://`, or `ws://` for `localhost` and `127.0.0.1` only |
290
+ | `sessionPrivKey` | `Uint8Array` | yes | none | Ephemeral session private key, 32 bytes. Used to unwrap the NIP-17 response |
291
+ | `expectedOrigin` | `string` | yes | none | Your origin (scheme, host and optional port), verbatim. Must match the `origin` tag of the signed auth event |
292
+ | `issuedAt` | `number` | no, but pass it | none | Unix **seconds** when you minted the challenge. Anchors the relay query and the deadline. More than 300 seconds in the future rejects with `invalid-issued-at` |
293
+ | `timeout` | `number` | no | Remaining validity (`issuedAt + 300` seconds minus now) when `issuedAt` is set, otherwise `120000` | Milliseconds, clamped to 5000 to 600000. Use it only to give up earlier than the default |
294
+ | `since` | `number` | no | `issuedAt - 60` when `issuedAt` is set, otherwise now minus 60 | Raw relay query anchor in unix seconds. Takes precedence over `issuedAt` |
295
+ | `abortSignal` | `AbortSignal` | no | none | Aborts the wait and closes the subscription; rejects with `aborted` |
296
+
297
+ **Result (`SignetAuthResult`):**
298
+
299
+ | Field | Type | Description |
300
+ |-------|------|-------------|
301
+ | `pubkey` | `string` | The user's pubkey (64-character hex); a copy of `authEvent.pubkey` |
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 |
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 |
305
+ | `bunkerUri` | `string` (optional) | A `bunker://` URI for upgrading to a live signer. Absent on auth-only flows. Only its `bunker://` prefix is checked |
306
+ | `createdAt` | `number` | Unix seconds at which the auth event was signed |
307
+
308
+ **Constants:** `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds) and `DEFAULT_RELAY_URL` (`'wss://nos.lol'`).
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.
169
342
 
170
343
  ## Age Range Values
171
344
 
@@ -177,11 +350,12 @@ Returns `Promise<SignetVerifyResult>`
177
350
  | `'13-17'` | Ages 13–17 |
178
351
  | `'18+'` | Adult (18 and over) |
179
352
 
353
+ Matching is exact: the credential's age range must equal `requiredAgeRange`. An `'18+'` credential does not satisfy `'13-17'`, and a `'13-17'` credential does not satisfy `'18+'`. Any other `requiredAgeRange` is rejected: `verifyAge` resolves at once with `error: 'invalid-age-range'` and shows no modal.
354
+
180
355
  ## Privacy
181
356
 
182
357
  - No personal data is transmitted — only a cryptographic proof of age range
183
358
  - No account creation required on the website
184
359
  - No cookies beyond session management
185
- - The proof is verified entirely in the browser — no server-side API calls needed
186
- - Verifier check is a single GET request returning confirmed/not — no user data sent
187
- - Compliant with GDPR, COPPA, UK Online Safety Act, France SREN law
360
+ - Proof verification runs in the browser. `verifyAge` makes one network request, to the verification bot, unless `verifierCheckUrl` is `null`; it opens no relay connection, and the proof reaches the modal over a same-origin `BroadcastChannel`, not the network. `waitForAuthResponse` connects only to the relay you give it
361
+ - The verifier check is a single GET request that sends only the verifier's public key and returns confirmed or not
@@ -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 }
@@ -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;
@@ -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 }
package/llms.txt CHANGED
@@ -7,9 +7,12 @@
7
7
 
8
8
  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
- function call opens a QR/modal, waits for the user's Signet app to respond,
11
- and checks both the credential and the issuing verifier's professional
12
- registration.
10
+ function call opens a modal, waits for a signed response on a same-origin
11
+ `BroadcastChannel` (no relay connection), and checks both the credential and
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,8 +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
- which exposes `window.Signet`.
38
+ which exposes `window.Signet`, for example
39
+ `https://cdn.jsdelivr.net/npm/signet-verify@0.7.2/dist/signet-verify.iife.js`.
31
40
 
32
41
  ## Key Concepts
33
42
 
@@ -37,31 +46,72 @@ which exposes `window.Signet`.
37
46
  when the issuing verifier is checked against a public professional
38
47
  register (GMC, SRA, TRA, etc.) via a verification bot; this can be relaxed
39
48
  with `acceptUnconfirmed` or pointed at a different bot with
40
- `verifierCheckUrl`.
41
- - **Same-device vs cross-device**: `verifyAge` covers the common same-device
42
- QR/modal flow; `waitForAuthResponse` is the lower-level cross-device
43
- primitive for a consumer that mints its own challenge and waits on a relay
44
- for the signed response.
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`.
55
+ - **Same-browser vs cross-device**: `verifyAge` covers the modal flow and
56
+ receives its response on a `BroadcastChannel` named
57
+ `signet-verify-<requestId>`; it makes one network request, to the
58
+ verification bot, and never connects to a relay (its `relayUrl` option is
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
67
+ lower-level cross-device primitive for a consumer that mints its own
68
+ challenge and waits on a relay for the signed response; only it needs a
69
+ relay that serves kind-1059 gift wraps to an unauthenticated reader.
45
70
  - **The sign-in deadline**: a cross-device wait has one deadline,
46
71
  `issuedAt + AUTH_FRESHNESS_WINDOW_SEC`. `issuedAt` must be passed on every
47
72
  call for the same sign-in, including retries after the page was
48
73
  backgrounded, or the relay query anchor is wrong.
49
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
+
50
90
  ## API Surface
51
91
 
52
92
  - `verifyAge(requiredAgeRange, options?) => Promise<SignetVerifyResult>`:
53
- the same-device verification flow.
93
+ the same-browser modal flow. `requiredAgeRange` must be one of
94
+ `'0-3'`, `'4-7'`, `'8-12'`, `'13-17'`, `'18+'` and is matched exactly.
95
+ Failures are reported in `result.error`, not as a rejection.
54
96
  - `waitForAuthResponse(options) => Promise<SignetAuthResult>`: the
55
- cross-device primitive; see the README for the full option set
56
- (`requestId`, `relayUrl`, `sessionPrivKey`, `expectedOrigin`, `issuedAt`,
57
- `abortSignal`).
58
- - `AUTH_FRESHNESS_WINDOW_SEC`, `DEFAULT_RELAY_URL`: constants used to
59
- compute and check the sign-in deadline.
97
+ cross-device primitive. It resolves on success and rejects with an `Error`
98
+ whose `message` and `code` are the failure code (`expired`, `timeout`,
99
+ `denied`, `aborted`, `relay-refused`, and others listed in the README).
100
+ Options: `requestId`, `relayUrl`, `sessionPrivKey` (a 32-byte
101
+ `Uint8Array`) and `expectedOrigin` are required; `issuedAt` (unix
102
+ seconds), `timeout` (milliseconds), `since` (unix seconds) and
103
+ `abortSignal` are optional. The README has the full option and result
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.
107
+ - `AUTH_FRESHNESS_WINDOW_SEC` (`300`, seconds), `DEFAULT_RELAY_URL`
108
+ (`'wss://nos.lol'`): constants used to compute and check the sign-in
109
+ deadline.
60
110
  - Result and option types: `SignetVerifyResult`, `SignetVerifyOptions`,
61
111
  `WaitForAuthOptions`, `SignetAuthResult`, `SignetAuthEvent`.
62
112
 
63
113
  ## Optional
64
114
 
65
115
  - Full API reference, error codes and age range values: [README](README.md)
66
- - Cross-device example: [examples/cross-device-login.html](examples/cross-device-login.html)
116
+ - Cross-device example: [examples/cross-device-login.html](https://github.com/forgesworn/signet-verify/blob/main/examples/cross-device-login.html)
67
117
  - Release history: [CHANGELOG.md](CHANGELOG.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signet-verify",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "llms.txt"
18
+ "llms.txt",
19
+ "CHANGELOG.md"
19
20
  ],
20
21
  "homepage": "https://github.com/forgesworn/signet-verify#readme",
21
22
  "keywords": [
@@ -34,6 +35,9 @@
34
35
  "sdk",
35
36
  "browser"
36
37
  ],
38
+ "engines": {
39
+ "node": ">=20.19.0"
40
+ },
37
41
  "publishConfig": {
38
42
  "provenance": true
39
43
  },