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 +16 -0
- package/README.md +130 -26
- package/dist/signet-verify.d.ts +5 -4
- package/dist/signet-verify.iife.js +1 -1
- package/dist/signet-verify.js +2 -2
- package/llms.txt +43 -6
- package/package.json +4 -1
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
|
|
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.
|
|
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
|
|
60
|
-
const
|
|
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
|
|
108
|
+
const lenient = await Signet.verifyAge('18+', { acceptUnconfirmed: true });
|
|
64
109
|
|
|
65
110
|
// Use your own verification bot
|
|
66
|
-
const
|
|
111
|
+
const ownBot = await Signet.verifyAge('18+', { verifierCheckUrl: 'https://my-bot.example.com' });
|
|
67
112
|
|
|
68
|
-
// Skip verifier
|
|
69
|
-
|
|
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';
|
|
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
|
-
|
|
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; `
|
|
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://
|
|
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'` |
|
|
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
|
|
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://
|
|
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
|
|
package/dist/signet-verify.d.ts
CHANGED
|
@@ -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.
|
|
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://
|
|
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
|
|
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://
|
|
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://
|
|
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:
|
package/dist/signet-verify.js
CHANGED
|
@@ -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.
|
|
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://
|
|
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.
|
|
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).
|
|
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://
|
|
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.
|
|
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
|
},
|