signet-login 0.9.15 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -29
- package/dist/amber.d.ts +3 -7
- package/dist/amber.js +20 -10
- package/dist/modal.js +259 -36
- package/dist/redirect.d.ts +3 -18
- package/dist/redirect.js +22 -29
- package/dist/signers.d.ts +2 -1
- package/dist/signers.js +13 -4
- package/dist/signet-login.d.ts +33 -4
- package/dist/signet-login.iife.js +56 -57
- package/dist/signet-login.js +76 -25
- package/dist/storage.d.ts +17 -1
- package/dist/storage.js +162 -25
- package/dist/types.d.ts +50 -7
- package/dist/types.js +3 -3
- package/examples/basic.html +113 -0
- package/examples/callback.html +31 -0
- package/examples/headless.html +277 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Signet Access
|
|
2
2
|
|
|
3
3
|
[](https://github.com/sponsors/TheCryptoDonkey)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Published as `signet-login`.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Sign in with Signet** (cross-device QR via NIP-17 gift-wrap)
|
|
9
|
-
- **Paste bunker URI** (NIP-46 remote signer — Heartwood, nsecBunker, Amber)
|
|
7
|
+
**Signet Access** is a drop-in auth and signer-access SDK for Nostr-aware websites. One picker, one session shape, multiple ways to prove identity and, when available, keep a live signer:
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
- **Sign in with Signet** on this device or by cross-device QR
|
|
10
|
+
- **Browser extension** via NIP-07 (bark, Alby, nos2x, Flamingo, ...)
|
|
11
|
+
- **Connect a Nostr signer** via app-initiated NIP-46 / NostrConnect
|
|
12
|
+
- **Paste or scan bunker URI** for Heartwood, nsecBunker, Amber, or compatible signers
|
|
13
|
+
- **Sign in with Amber** via Android NIP-55
|
|
14
|
+
- **Paste private key** as an in-memory, advanced fallback only
|
|
15
|
+
|
|
16
|
+
Returns a unified `SignetSigner` plus a signed kind-21236 auth proof your server can verify before granting privileges.
|
|
12
17
|
|
|
13
18
|
## Install
|
|
14
19
|
|
|
@@ -58,20 +63,41 @@ Show the picker, return a `SignetSession` on success or `null` on cancel/timeout
|
|
|
58
63
|
|
|
59
64
|
```ts
|
|
60
65
|
interface LoginOptions {
|
|
61
|
-
appName: string;
|
|
62
|
-
challenge?: string;
|
|
63
|
-
preferredMethod?:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
appName: string; // shown in modal
|
|
67
|
+
challenge?: string; // 64 hex; auto if omitted
|
|
68
|
+
preferredMethod?: LoginPickerMethod; // skip the picker
|
|
69
|
+
methods?: LoginPickerMethod[]; // picker methods, in order
|
|
70
|
+
advancedMethods?: LoginPickerMethod[]; // grouped behind Advanced; [] = flat list
|
|
71
|
+
relayUrl?: string; // default wss://relay.damus.io
|
|
72
|
+
relayUrls?: string[]; // repeated relay= params for NostrConnect
|
|
73
|
+
nostrConnectPerms?: string[]; // default sign_event + NIP-44
|
|
74
|
+
theme?: 'light' | 'dark' | 'auto'; // default 'auto'
|
|
75
|
+
timeout?: number; // default 120_000ms; clamped to [5k, 600k]
|
|
76
|
+
signetAppOrigin?: string; // default https://mysignet.app
|
|
77
|
+
redirectCallback?: string; // for same-device redirect / Amber return
|
|
78
|
+
mode?: 'relay' | 'redirect'; // Signet delivery mode
|
|
79
|
+
storage?: SignetStorage; // default localStorage
|
|
80
|
+
persist?: boolean; // default true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface SignetStorage {
|
|
84
|
+
getItem(key: string): string | null | Promise<string | null>;
|
|
85
|
+
setItem(key: string, value: string): void | Promise<void>;
|
|
86
|
+
removeItem(key: string): void | Promise<void>;
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
type LoginPickerMethod =
|
|
90
|
+
| 'nip07'
|
|
91
|
+
| 'redirect' // same-device Signet, relay delivery
|
|
92
|
+
| 'qr' // cross-device Signet QR
|
|
93
|
+
| 'bunker' // paste bunker://
|
|
94
|
+
| 'nostrconnect' // show nostrconnect:// QR
|
|
95
|
+
| 'amber' // Android NIP-55
|
|
96
|
+
| 'nsec'; // in-memory private key fallback
|
|
97
|
+
|
|
72
98
|
interface SignetSession {
|
|
73
99
|
pubkey: string; // hex
|
|
74
|
-
method: 'nip07' | 'redirect' | 'bunker';
|
|
100
|
+
method: 'nip07' | 'redirect' | 'bunker' | 'nsec' | 'amber';
|
|
75
101
|
signer: SignetSigner;
|
|
76
102
|
authEvent: SignetAuthEvent; // signed kind-21236 challenge proof
|
|
77
103
|
expiresAt?: number;
|
|
@@ -79,9 +105,91 @@ interface SignetSession {
|
|
|
79
105
|
}
|
|
80
106
|
```
|
|
81
107
|
|
|
108
|
+
By default, the picker shows ordinary user-facing methods first and groups `bunker`, `nostrconnect`, and `nsec` behind **Advanced**. Control the surface per app:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
await Signet.login({
|
|
112
|
+
appName: 'My Game',
|
|
113
|
+
methods: ['redirect', 'qr', 'nip07'],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await Signet.login({
|
|
117
|
+
appName: 'Power User Tool',
|
|
118
|
+
methods: ['nip07', 'bunker', 'nostrconnect', 'nsec'],
|
|
119
|
+
advancedMethods: [], // flat picker
|
|
120
|
+
relayUrls: ['wss://relay.nsec.app', 'wss://relay.damus.io'],
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
When camera APIs are available, the bunker URI screen can scan `bunker://` QR codes directly. Paste remains the fallback.
|
|
125
|
+
|
|
126
|
+
### Headless/custom UI
|
|
127
|
+
|
|
128
|
+
Use the exported signer constructors and proof helpers when your app owns the UI:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import {
|
|
132
|
+
createBunkerSigner,
|
|
133
|
+
createLoginAuthEvent,
|
|
134
|
+
createSessionFromSigner,
|
|
135
|
+
createLocalSignerFromNsec,
|
|
136
|
+
} from 'signet-login';
|
|
137
|
+
|
|
138
|
+
const signer = await createBunkerSigner({
|
|
139
|
+
uri: bunkerUri,
|
|
140
|
+
timeoutMs: 30_000,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const session = await createSessionFromSigner(signer, {
|
|
144
|
+
appName: 'My App',
|
|
145
|
+
challenge: challengeFromServer,
|
|
146
|
+
origin: 'https://my-app.example',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await fetch('/api/login', {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
body: JSON.stringify({ authEvent: session.authEvent }),
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
|
|
156
|
+
The IIFE bundle attaches the same helpers to `window.Signet`.
|
|
157
|
+
|
|
158
|
+
### Custom storage
|
|
159
|
+
|
|
160
|
+
By default, Signet Access stores session state in localStorage under `signet:login.*`. Pass `storage` when you need encrypted, async, IndexedDB, server-backed, or test storage:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
const encryptedStorage = {
|
|
164
|
+
async getItem(key) {
|
|
165
|
+
const value = localStorage.getItem(key);
|
|
166
|
+
return value ? await decrypt(value) : null;
|
|
167
|
+
},
|
|
168
|
+
async setItem(key, value) {
|
|
169
|
+
localStorage.setItem(key, await encrypt(value));
|
|
170
|
+
},
|
|
171
|
+
async removeItem(key) {
|
|
172
|
+
localStorage.removeItem(key);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const session = await Signet.login({
|
|
177
|
+
appName: 'My Game',
|
|
178
|
+
storage: encryptedStorage,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await Signet.restoreSession({ storage: encryptedStorage });
|
|
182
|
+
await Signet.handleRedirectCallback({ storage: encryptedStorage });
|
|
183
|
+
await Signet.logout(session, { storage: encryptedStorage });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Use the same storage adapter for `login`, `restoreSession`, `handleRedirectCallback`, and `logout`.
|
|
187
|
+
|
|
188
|
+
This adapter is deliberately not called "Stash". `@forgesworn/stash` is the separate encrypted cloud-save vault for app data; Signet Access storage is local session/reconnect state needed before a signer is available.
|
|
189
|
+
|
|
82
190
|
### `Signet.restoreSession(opts?)`
|
|
83
191
|
|
|
84
|
-
Restore a session from
|
|
192
|
+
Restore a session from configured storage. For bunker sessions this attempts to reconnect to the stored bunker. Returns `null` if no session is stored, the session is malformed, or reconnection fails.
|
|
85
193
|
|
|
86
194
|
```js
|
|
87
195
|
const session = await Signet.restoreSession();
|
|
@@ -90,7 +198,7 @@ if (session?.signer.capabilities.canSignEvents) {
|
|
|
90
198
|
}
|
|
91
199
|
```
|
|
92
200
|
|
|
93
|
-
### `Signet.logout(currentSession?)`
|
|
201
|
+
### `Signet.logout(currentSession?, opts?)`
|
|
94
202
|
|
|
95
203
|
Clear stored session and close the active signer.
|
|
96
204
|
|
|
@@ -98,14 +206,14 @@ Clear stored session and close the active signer.
|
|
|
98
206
|
|
|
99
207
|
Run on your callback page when using the same-device redirect flow. Parses URL params and posts them to `window.opener` (if popup-opened), then closes the popup.
|
|
100
208
|
|
|
101
|
-
##
|
|
209
|
+
## Signers and capabilities
|
|
102
210
|
|
|
103
|
-
All
|
|
211
|
+
All session signers implement `SignetSigner`:
|
|
104
212
|
|
|
105
213
|
```ts
|
|
106
214
|
interface SignetSigner {
|
|
107
215
|
readonly pubkey: string;
|
|
108
|
-
readonly method: 'nip07' | 'redirect' | 'bunker';
|
|
216
|
+
readonly method: 'nip07' | 'redirect' | 'bunker' | 'nsec' | 'amber';
|
|
109
217
|
readonly capabilities: { canSignEvents: boolean; hasNip44: boolean };
|
|
110
218
|
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
111
219
|
nip44?: { encrypt, decrypt };
|
|
@@ -117,9 +225,10 @@ interface SignetSigner {
|
|
|
117
225
|
|---|---|---|
|
|
118
226
|
| `Nip07Signer` | true | `window.nostr` (any NIP-07 extension) |
|
|
119
227
|
| `BunkerSignerImpl` | true | `nostr-tools` BunkerSigner over NIP-46 relay |
|
|
120
|
-
| `
|
|
228
|
+
| `LocalSigner` | true | In-memory nsec fallback; never persisted |
|
|
229
|
+
| `EphemeralSigner` | **false** | Auth-only Signet redirect / QR / Amber callback |
|
|
121
230
|
|
|
122
|
-
`EphemeralSigner` exists because
|
|
231
|
+
`EphemeralSigner` exists because some redirect-style flows return a signed challenge but no ongoing signing channel. Use `signer.capabilities.canSignEvents` to gate UI:
|
|
123
232
|
|
|
124
233
|
```js
|
|
125
234
|
if (session.signer.capabilities.canSignEvents) {
|
|
@@ -129,7 +238,7 @@ if (session.signer.capabilities.canSignEvents) {
|
|
|
129
238
|
}
|
|
130
239
|
```
|
|
131
240
|
|
|
132
|
-
|
|
241
|
+
When Signet or a signer app returns a `bunker://` handoff, the SDK upgrades the auth-only proof into a live `BunkerSignerImpl` if the handoff connects and matches the authenticated pubkey.
|
|
133
242
|
|
|
134
243
|
## Server-side verification
|
|
135
244
|
|
|
@@ -156,12 +265,12 @@ The verifier checks: schnorr signature, canonical event ID, kind=21236, challeng
|
|
|
156
265
|
|
|
157
266
|
## Storage
|
|
158
267
|
|
|
159
|
-
|
|
268
|
+
By default, session data is stored in localStorage under `signet:login.*`:
|
|
160
269
|
|
|
161
270
|
| Key | Purpose |
|
|
162
271
|
|---|---|
|
|
163
272
|
| `signet:login.pubkey` | Authenticated pubkey |
|
|
164
|
-
| `signet:login.method` | `nip07` / `redirect` / `bunker` |
|
|
273
|
+
| `signet:login.method` | `nip07` / `redirect` / `bunker` / `amber` |
|
|
165
274
|
| `signet:login.authEvent` | Serialised kind-21236 auth event |
|
|
166
275
|
| `signet:login.bunkerUri` | Bunker URI for reconnect (bunker only) |
|
|
167
276
|
| `signet:login.bunkerClientSk` | Client secret key hex (bunker only) |
|
|
@@ -190,11 +299,11 @@ Each SDK manages its own slice of `window.Signet` and `localStorage` namespaces.
|
|
|
190
299
|
|
|
191
300
|
## Bundle size
|
|
192
301
|
|
|
193
|
-
|
|
302
|
+
The ESM entry is approx **5.9 KB gzipped** before bundling dependencies. The standalone IIFE is approx **114.7 KB gzipped** because it includes NIP-46, Signet QR/relay support, and camera QR decoding. A future split-bundle could lazy-load advanced signer paths for smaller first-load pages.
|
|
194
303
|
|
|
195
304
|
## Browser support
|
|
196
305
|
|
|
197
|
-
ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `
|
|
306
|
+
ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `crypto.subtle`, `WebSocket`, and the native `<dialog>` element. Session persistence defaults to `localStorage`, but apps can provide a custom storage adapter.
|
|
198
307
|
|
|
199
308
|
## Development
|
|
200
309
|
|
|
@@ -207,9 +316,10 @@ npm test # vitest in jsdom
|
|
|
207
316
|
|
|
208
317
|
Examples in `examples/`:
|
|
209
318
|
- `basic.html` — full demo with login / sign / logout / restore
|
|
319
|
+
- `headless.html` — custom UI demo using signer constructors and proof helpers
|
|
210
320
|
- `callback.html` — redirect-back receiver page
|
|
211
321
|
|
|
212
|
-
Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html`.
|
|
322
|
+
Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html` or `examples/headless.html`.
|
|
213
323
|
|
|
214
324
|
## Out of scope
|
|
215
325
|
|
package/dist/amber.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* environment. Smoke test on a real Android device with Amber installed
|
|
18
18
|
* before promoting to production.
|
|
19
19
|
*/
|
|
20
|
-
import type { SignetSession } from './types.js';
|
|
20
|
+
import type { SignetStorage, SignetSession } from './types.js';
|
|
21
21
|
/** True when running on a likely-Android browser. Lets the picker hide the
|
|
22
22
|
* Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
|
|
23
23
|
export declare function isAndroid(): boolean;
|
|
@@ -27,6 +27,7 @@ export interface AmberStartOptions {
|
|
|
27
27
|
origin: string;
|
|
28
28
|
/** Optional override for the callback URL. Defaults to current page origin. */
|
|
29
29
|
redirectCallback?: string;
|
|
30
|
+
storage?: SignetStorage;
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
33
|
* Build the `nostrsigner:` URL that Android dispatches to Amber. The auth
|
|
@@ -52,10 +53,5 @@ export type ConsumeAmberResult = {
|
|
|
52
53
|
kind: 'invalid';
|
|
53
54
|
reason: string;
|
|
54
55
|
};
|
|
55
|
-
/**
|
|
56
|
-
* Consume an Amber callback. Detects `?event=<base64-or-json>` (or the
|
|
57
|
-
* `signet_amber=1` flag) on the URL and reconstructs a session. Idempotent:
|
|
58
|
-
* a second call after a successful consume returns 'no-callback' because
|
|
59
|
-
* the params have been stripped.
|
|
60
|
-
*/
|
|
61
56
|
export declare function consumeAmberCallback(): ConsumeAmberResult;
|
|
57
|
+
export declare function consumeAmberCallbackFromStorage(storage?: SignetStorage): Promise<ConsumeAmberResult>;
|
package/dist/amber.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PENDING_REDIRECT_TTL_MS } from './types.js';
|
|
2
|
-
import { clearPendingRedirect, loadPendingRedirect,
|
|
2
|
+
import { clearPendingRedirect, clearPendingRedirectFromStorage, loadPendingRedirect, loadPendingRedirectFromStorage, savePendingRedirectToStorage, } from './storage.js';
|
|
3
3
|
import { EphemeralSigner } from './signers.js';
|
|
4
4
|
/** True when running on a likely-Android browser. Lets the picker hide the
|
|
5
5
|
* Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
|
|
@@ -55,7 +55,7 @@ export function buildAmberSignerUrl(opts) {
|
|
|
55
55
|
* web URL handled by signet-app. The promise never resolves — the page is
|
|
56
56
|
* gone before it can.
|
|
57
57
|
*/
|
|
58
|
-
export function startAmberSignIn(opts) {
|
|
58
|
+
export async function startAmberSignIn(opts) {
|
|
59
59
|
if (typeof window === 'undefined') {
|
|
60
60
|
throw new Error('signet-login: amber mode requires a browser environment');
|
|
61
61
|
}
|
|
@@ -65,7 +65,7 @@ export function startAmberSignIn(opts) {
|
|
|
65
65
|
appName: opts.appName,
|
|
66
66
|
createdAt: Date.now(),
|
|
67
67
|
};
|
|
68
|
-
|
|
68
|
+
await savePendingRedirectToStorage(pending, opts.storage);
|
|
69
69
|
window.location.href = buildAmberSignerUrl(opts);
|
|
70
70
|
return new Promise(() => { });
|
|
71
71
|
}
|
|
@@ -96,22 +96,16 @@ function cleanupAmberCallbackUrl() {
|
|
|
96
96
|
* a second call after a successful consume returns 'no-callback' because
|
|
97
97
|
* the params have been stripped.
|
|
98
98
|
*/
|
|
99
|
-
|
|
99
|
+
function consumeAmberCallbackWithPending(pending, finalize) {
|
|
100
100
|
if (typeof window === 'undefined')
|
|
101
101
|
return { kind: 'no-callback' };
|
|
102
102
|
const params = new URLSearchParams(window.location.search);
|
|
103
103
|
const flagged = params.has('signet_amber') || params.has('event');
|
|
104
104
|
if (!flagged)
|
|
105
105
|
return { kind: 'no-callback' };
|
|
106
|
-
const finalize = (result) => {
|
|
107
|
-
clearPendingRedirect();
|
|
108
|
-
cleanupAmberCallbackUrl();
|
|
109
|
-
return result;
|
|
110
|
-
};
|
|
111
106
|
if (params.get('error') === 'denied') {
|
|
112
107
|
return finalize({ kind: 'denied' });
|
|
113
108
|
}
|
|
114
|
-
const pending = loadPendingRedirect();
|
|
115
109
|
if (!pending) {
|
|
116
110
|
return finalize({ kind: 'invalid', reason: 'no-pending-state' });
|
|
117
111
|
}
|
|
@@ -179,3 +173,19 @@ export function consumeAmberCallback() {
|
|
|
179
173
|
};
|
|
180
174
|
return finalize({ kind: 'session', session });
|
|
181
175
|
}
|
|
176
|
+
export function consumeAmberCallback() {
|
|
177
|
+
const finalize = (result) => {
|
|
178
|
+
clearPendingRedirect();
|
|
179
|
+
cleanupAmberCallbackUrl();
|
|
180
|
+
return result;
|
|
181
|
+
};
|
|
182
|
+
return consumeAmberCallbackWithPending(loadPendingRedirect(), finalize);
|
|
183
|
+
}
|
|
184
|
+
export async function consumeAmberCallbackFromStorage(storage) {
|
|
185
|
+
const finalize = async (result) => {
|
|
186
|
+
await clearPendingRedirectFromStorage(storage);
|
|
187
|
+
cleanupAmberCallbackUrl();
|
|
188
|
+
return result;
|
|
189
|
+
};
|
|
190
|
+
return await consumeAmberCallbackWithPending(await loadPendingRedirectFromStorage(storage), finalize);
|
|
191
|
+
}
|