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 CHANGED
@@ -1,14 +1,19 @@
1
- # signet-login
1
+ # Signet Access
2
2
 
3
3
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/TheCryptoDonkey?logo=githubsponsors&color=ea4aaa&label=Sponsor)](https://github.com/sponsors/TheCryptoDonkey)
4
4
 
5
- **Sign in with Signet** for Nostr-aware websites. One picker, three backends:
5
+ Published as `signet-login`.
6
6
 
7
- - **Browser extension** (NIP-07 bark, Alby, nos2x, Flamingo, …)
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
- Returns a unified `SignetSigner` your code can use to sign Nostr events going forward.
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; // shown in modal
62
- challenge?: string; // 64 hex; auto if omitted
63
- preferredMethod?: 'nip07' | 'redirect' | 'bunker'; // skip the picker
64
- relayUrl?: string; // default wss://relay.damus.io
65
- theme?: 'light' | 'dark' | 'auto'; // default 'auto'
66
- timeout?: number; // default 120_000ms; clamped to [5k, 600k]
67
- signetAppOrigin?: string; // default https://mysignet.app
68
- redirectCallback?: string; // for same-device redirect (future)
69
- persist?: boolean; // default true (localStorage)
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 localStorage. 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.
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
- ## The three signers
209
+ ## Signers and capabilities
102
210
 
103
- All three implement `SignetSigner`:
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
- | `EphemeralSigner` | **false** | Auth-only redirect returned only `authEvent` |
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 the v0.1 redirect flow returns a single signed challenge but no ongoing-signing channel. Use `signer.capabilities.canSignEvents` to gate UI:
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
- A future Option-B upgrade to signet-app will spawn a session-bunker per origin during the redirect approval, at which point redirect sessions will be full signers transparently. The SDK API does not change.
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
- Session data is stored in localStorage under `signet:login.*`:
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
- Approx **48.5 KB gzipped** (135 KB unminified). The bulk is `nostr-tools` `BunkerSigner` for NIP-46 + `signet-verify` for the cross-device QR primitive. A future split-bundle could lazy-load the bunker path to halve the initial size.
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 `localStorage`, `crypto.subtle`, `WebSocket`, and the native `<dialog>` element.
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, savePendingRedirect, } from './storage.js';
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
- savePendingRedirect(pending);
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
- export function consumeAmberCallback() {
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
+ }