signet-login 0.9.15 → 0.10.0
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 +98 -25
- package/dist/modal.js +246 -29
- package/dist/signers.d.ts +2 -1
- package/dist/signers.js +13 -4
- package/dist/signet-login.d.ts +23 -3
- package/dist/signet-login.iife.js +56 -57
- package/dist/signet-login.js +52 -2
- package/dist/types.d.ts +32 -6
- package/dist/types.js +3 -3
- package/docs/competitive-audit.md +164 -0
- package/examples/basic.html +113 -0
- package/examples/callback.html +31 -0
- package/examples/headless.html +277 -0
- package/package.json +11 -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,34 @@ 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
|
+
persist?: boolean; // default true (localStorage)
|
|
70
80
|
}
|
|
71
81
|
|
|
82
|
+
type LoginPickerMethod =
|
|
83
|
+
| 'nip07'
|
|
84
|
+
| 'redirect' // same-device Signet, relay delivery
|
|
85
|
+
| 'qr' // cross-device Signet QR
|
|
86
|
+
| 'bunker' // paste bunker://
|
|
87
|
+
| 'nostrconnect' // show nostrconnect:// QR
|
|
88
|
+
| 'amber' // Android NIP-55
|
|
89
|
+
| 'nsec'; // in-memory private key fallback
|
|
90
|
+
|
|
72
91
|
interface SignetSession {
|
|
73
92
|
pubkey: string; // hex
|
|
74
|
-
method: 'nip07' | 'redirect' | 'bunker';
|
|
93
|
+
method: 'nip07' | 'redirect' | 'bunker' | 'nsec' | 'amber';
|
|
75
94
|
signer: SignetSigner;
|
|
76
95
|
authEvent: SignetAuthEvent; // signed kind-21236 challenge proof
|
|
77
96
|
expiresAt?: number;
|
|
@@ -79,6 +98,56 @@ interface SignetSession {
|
|
|
79
98
|
}
|
|
80
99
|
```
|
|
81
100
|
|
|
101
|
+
By default, the picker shows ordinary user-facing methods first and groups `bunker`, `nostrconnect`, and `nsec` behind **Advanced**. Control the surface per app:
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
await Signet.login({
|
|
105
|
+
appName: 'My Game',
|
|
106
|
+
methods: ['redirect', 'qr', 'nip07'],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await Signet.login({
|
|
110
|
+
appName: 'Power User Tool',
|
|
111
|
+
methods: ['nip07', 'bunker', 'nostrconnect', 'nsec'],
|
|
112
|
+
advancedMethods: [], // flat picker
|
|
113
|
+
relayUrls: ['wss://relay.nsec.app', 'wss://relay.damus.io'],
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
When camera APIs are available, the bunker URI screen can scan `bunker://` QR codes directly. Paste remains the fallback.
|
|
118
|
+
|
|
119
|
+
### Headless/custom UI
|
|
120
|
+
|
|
121
|
+
Use the exported signer constructors and proof helpers when your app owns the UI:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import {
|
|
125
|
+
createBunkerSigner,
|
|
126
|
+
createLoginAuthEvent,
|
|
127
|
+
createSessionFromSigner,
|
|
128
|
+
createLocalSignerFromNsec,
|
|
129
|
+
} from 'signet-login';
|
|
130
|
+
|
|
131
|
+
const signer = await createBunkerSigner({
|
|
132
|
+
uri: bunkerUri,
|
|
133
|
+
timeoutMs: 30_000,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const session = await createSessionFromSigner(signer, {
|
|
137
|
+
appName: 'My App',
|
|
138
|
+
challenge: challengeFromServer,
|
|
139
|
+
origin: 'https://my-app.example',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await fetch('/api/login', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
body: JSON.stringify({ authEvent: session.authEvent }),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
|
|
149
|
+
The IIFE bundle attaches the same helpers to `window.Signet`.
|
|
150
|
+
|
|
82
151
|
### `Signet.restoreSession(opts?)`
|
|
83
152
|
|
|
84
153
|
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.
|
|
@@ -98,14 +167,14 @@ Clear stored session and close the active signer.
|
|
|
98
167
|
|
|
99
168
|
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
169
|
|
|
101
|
-
##
|
|
170
|
+
## Signers and capabilities
|
|
102
171
|
|
|
103
|
-
All
|
|
172
|
+
All session signers implement `SignetSigner`:
|
|
104
173
|
|
|
105
174
|
```ts
|
|
106
175
|
interface SignetSigner {
|
|
107
176
|
readonly pubkey: string;
|
|
108
|
-
readonly method: 'nip07' | 'redirect' | 'bunker';
|
|
177
|
+
readonly method: 'nip07' | 'redirect' | 'bunker' | 'nsec' | 'amber';
|
|
109
178
|
readonly capabilities: { canSignEvents: boolean; hasNip44: boolean };
|
|
110
179
|
signEvent(template: EventTemplate): Promise<NostrEvent>;
|
|
111
180
|
nip44?: { encrypt, decrypt };
|
|
@@ -117,9 +186,10 @@ interface SignetSigner {
|
|
|
117
186
|
|---|---|---|
|
|
118
187
|
| `Nip07Signer` | true | `window.nostr` (any NIP-07 extension) |
|
|
119
188
|
| `BunkerSignerImpl` | true | `nostr-tools` BunkerSigner over NIP-46 relay |
|
|
120
|
-
| `
|
|
189
|
+
| `LocalSigner` | true | In-memory nsec fallback; never persisted |
|
|
190
|
+
| `EphemeralSigner` | **false** | Auth-only Signet redirect / QR / Amber callback |
|
|
121
191
|
|
|
122
|
-
`EphemeralSigner` exists because
|
|
192
|
+
`EphemeralSigner` exists because some redirect-style flows return a signed challenge but no ongoing signing channel. Use `signer.capabilities.canSignEvents` to gate UI:
|
|
123
193
|
|
|
124
194
|
```js
|
|
125
195
|
if (session.signer.capabilities.canSignEvents) {
|
|
@@ -129,7 +199,7 @@ if (session.signer.capabilities.canSignEvents) {
|
|
|
129
199
|
}
|
|
130
200
|
```
|
|
131
201
|
|
|
132
|
-
|
|
202
|
+
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
203
|
|
|
134
204
|
## Server-side verification
|
|
135
205
|
|
|
@@ -161,7 +231,7 @@ Session data is stored in localStorage under `signet:login.*`:
|
|
|
161
231
|
| Key | Purpose |
|
|
162
232
|
|---|---|
|
|
163
233
|
| `signet:login.pubkey` | Authenticated pubkey |
|
|
164
|
-
| `signet:login.method` | `nip07` / `redirect` / `bunker` |
|
|
234
|
+
| `signet:login.method` | `nip07` / `redirect` / `bunker` / `amber` |
|
|
165
235
|
| `signet:login.authEvent` | Serialised kind-21236 auth event |
|
|
166
236
|
| `signet:login.bunkerUri` | Bunker URI for reconnect (bunker only) |
|
|
167
237
|
| `signet:login.bunkerClientSk` | Client secret key hex (bunker only) |
|
|
@@ -190,7 +260,7 @@ Each SDK manages its own slice of `window.Signet` and `localStorage` namespaces.
|
|
|
190
260
|
|
|
191
261
|
## Bundle size
|
|
192
262
|
|
|
193
|
-
|
|
263
|
+
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
264
|
|
|
195
265
|
## Browser support
|
|
196
266
|
|
|
@@ -207,9 +277,12 @@ npm test # vitest in jsdom
|
|
|
207
277
|
|
|
208
278
|
Examples in `examples/`:
|
|
209
279
|
- `basic.html` — full demo with login / sign / logout / restore
|
|
280
|
+
- `headless.html` — custom UI demo using signer constructors and proof helpers
|
|
210
281
|
- `callback.html` — redirect-back receiver page
|
|
211
282
|
|
|
212
|
-
Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html`.
|
|
283
|
+
Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html` or `examples/headless.html`.
|
|
284
|
+
|
|
285
|
+
See [docs/competitive-audit.md](docs/competitive-audit.md) for the current competitor comparison and roadmap priorities.
|
|
213
286
|
|
|
214
287
|
## Out of scope
|
|
215
288
|
|
package/dist/modal.js
CHANGED
|
@@ -12,7 +12,11 @@ import { waitForAuthResponse } from 'signet-verify';
|
|
|
12
12
|
import { schnorr } from '@noble/curves/secp256k1';
|
|
13
13
|
import { bytesToHex } from '@noble/hashes/utils';
|
|
14
14
|
import QRCode from 'qrcode';
|
|
15
|
+
import jsQR from 'jsqr';
|
|
15
16
|
const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
|
|
17
|
+
const DEFAULT_PICKER_METHODS = ['nip07', 'amber', 'redirect', 'qr', 'bunker', 'nostrconnect', 'nsec'];
|
|
18
|
+
const DEFAULT_ADVANCED_METHODS = ['bunker', 'nostrconnect', 'nsec'];
|
|
19
|
+
const DEFAULT_NOSTR_CONNECT_PERMS = ['sign_event', 'nip44_encrypt', 'nip44_decrypt'];
|
|
16
20
|
function escapeHtml(str) {
|
|
17
21
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
22
|
}
|
|
@@ -144,33 +148,176 @@ function buttonStyle(dark, primary = false) {
|
|
|
144
148
|
const fg = dark ? '#e0e0e0' : '#1a1a2e';
|
|
145
149
|
return `background:transparent;color:${fg};border:1px solid ${border};padding:12px 16px;border-radius:8px;cursor:pointer;font-size:0.95rem;width:100%;margin-bottom:8px;text-align:left;display:flex;align-items:center;gap:12px;`;
|
|
146
150
|
}
|
|
151
|
+
function canUseCameraQrScanner() {
|
|
152
|
+
return typeof navigator !== 'undefined'
|
|
153
|
+
&& !!navigator.mediaDevices
|
|
154
|
+
&& typeof navigator.mediaDevices.getUserMedia === 'function'
|
|
155
|
+
&& typeof document !== 'undefined';
|
|
156
|
+
}
|
|
157
|
+
function isAcceptedPairingQr(value, acceptedPrefixes) {
|
|
158
|
+
const lower = value.trim().toLowerCase();
|
|
159
|
+
return acceptedPrefixes.some(prefix => lower.startsWith(prefix));
|
|
160
|
+
}
|
|
161
|
+
async function startCameraQrScanner(input) {
|
|
162
|
+
const { container, status, acceptedPrefixes, onValue } = input;
|
|
163
|
+
if (!canUseCameraQrScanner())
|
|
164
|
+
throw new Error('camera-unavailable');
|
|
165
|
+
let stopped = false;
|
|
166
|
+
let frame = 0;
|
|
167
|
+
let stream = null;
|
|
168
|
+
const video = document.createElement('video');
|
|
169
|
+
const canvas = document.createElement('canvas');
|
|
170
|
+
const stopBtn = document.createElement('button');
|
|
171
|
+
video.muted = true;
|
|
172
|
+
video.playsInline = true;
|
|
173
|
+
video.style.cssText = 'display:block;width:100%;max-height:240px;object-fit:cover;border-radius:8px;background:#000;margin:0 0 8px;';
|
|
174
|
+
canvas.style.display = 'none';
|
|
175
|
+
stopBtn.type = 'button';
|
|
176
|
+
stopBtn.dataset.action = 'stop-scan';
|
|
177
|
+
stopBtn.textContent = 'Stop scan';
|
|
178
|
+
stopBtn.style.cssText = 'display:block;margin:0 auto 8px;background:transparent;border:1px solid currentColor;border-radius:8px;padding:8px 12px;cursor:pointer;color:inherit;';
|
|
179
|
+
const stop = () => {
|
|
180
|
+
if (stopped)
|
|
181
|
+
return;
|
|
182
|
+
stopped = true;
|
|
183
|
+
if (frame)
|
|
184
|
+
cancelAnimationFrame(frame);
|
|
185
|
+
if (stream) {
|
|
186
|
+
for (const track of stream.getTracks())
|
|
187
|
+
track.stop();
|
|
188
|
+
}
|
|
189
|
+
container.hidden = true;
|
|
190
|
+
container.replaceChildren();
|
|
191
|
+
};
|
|
192
|
+
stopBtn.addEventListener('click', () => {
|
|
193
|
+
stop();
|
|
194
|
+
if (status) {
|
|
195
|
+
status.textContent = 'QR scan stopped.';
|
|
196
|
+
status.style.color = '';
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
container.hidden = false;
|
|
200
|
+
container.replaceChildren(video, canvas, stopBtn);
|
|
201
|
+
if (status) {
|
|
202
|
+
status.textContent = 'Point your camera at a QR code...';
|
|
203
|
+
status.style.color = '';
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
207
|
+
audio: false,
|
|
208
|
+
video: { facingMode: { ideal: 'environment' } },
|
|
209
|
+
});
|
|
210
|
+
video.srcObject = stream;
|
|
211
|
+
await video.play();
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
stop();
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
218
|
+
if (!ctx) {
|
|
219
|
+
stop();
|
|
220
|
+
throw new Error('canvas-unavailable');
|
|
221
|
+
}
|
|
222
|
+
const tick = () => {
|
|
223
|
+
if (stopped)
|
|
224
|
+
return;
|
|
225
|
+
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0 && video.videoHeight > 0) {
|
|
226
|
+
canvas.width = video.videoWidth;
|
|
227
|
+
canvas.height = video.videoHeight;
|
|
228
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
229
|
+
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
230
|
+
const code = jsQR(image.data, image.width, image.height, { inversionAttempts: 'attemptBoth' });
|
|
231
|
+
const scanned = code?.data?.trim();
|
|
232
|
+
if (scanned) {
|
|
233
|
+
if (isAcceptedPairingQr(scanned, acceptedPrefixes)) {
|
|
234
|
+
onValue(scanned);
|
|
235
|
+
if (status) {
|
|
236
|
+
status.textContent = 'QR code scanned.';
|
|
237
|
+
status.style.color = '';
|
|
238
|
+
}
|
|
239
|
+
stop();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (status) {
|
|
243
|
+
status.textContent = 'That QR is not a supported pairing URI.';
|
|
244
|
+
status.style.color = '#d04848';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
frame = requestAnimationFrame(tick);
|
|
249
|
+
};
|
|
250
|
+
frame = requestAnimationFrame(tick);
|
|
251
|
+
return { stop };
|
|
252
|
+
}
|
|
147
253
|
// ── Picker ────────────────────────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
|
|
254
|
+
const METHOD_META = {
|
|
255
|
+
nip07: { icon: '🌐', title: 'Browser extension', hint: 'bark, Alby, nos2x' },
|
|
256
|
+
amber: { icon: '🤖', title: 'Sign in with Amber', hint: 'Android signer (NIP-55)' },
|
|
257
|
+
redirect: { icon: '🪪', title: 'Sign in with Signet', hint: 'Open Signet on this device' },
|
|
258
|
+
qr: { icon: '📱', title: 'Signet on another device', hint: 'Scan QR with your phone' },
|
|
259
|
+
bunker: { icon: '🔑', title: 'Paste bunker URI', hint: 'For NIP-46 power users' },
|
|
260
|
+
nostrconnect: { icon: '📡', title: 'Connect a Nostr signer', hint: 'Scan with nsec.app, Amber, Keychat...' },
|
|
261
|
+
nsec: { icon: '⚠️', title: 'Paste private key', hint: 'In-memory only - risky, last resort' },
|
|
262
|
+
};
|
|
263
|
+
function isMethodAvailable(method) {
|
|
264
|
+
if (method === 'nip07')
|
|
265
|
+
return hasNip07();
|
|
266
|
+
if (method === 'amber')
|
|
267
|
+
return isAndroid();
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
function methodButtonHtml(method, dark, muted, primary) {
|
|
271
|
+
const meta = METHOD_META[method];
|
|
272
|
+
return `<button data-choice="${method}" style="${buttonStyle(dark, primary)}"><span style="font-size:1.2rem;">${meta.icon}</span><span><strong>${meta.title}</strong><br><span style="font-size:0.8rem;color:${primary ? 'rgba(255,255,255,0.8)' : muted};">${meta.hint}</span></span></button>`;
|
|
273
|
+
}
|
|
274
|
+
function renderPicker(refs, opts) {
|
|
275
|
+
const dark = isDarkMode(opts.theme);
|
|
150
276
|
const muted = dark ? '#888' : '#666';
|
|
151
|
-
const showNip07 = hasNip07();
|
|
152
|
-
const showAmber = isAndroid();
|
|
153
|
-
refs.dialog.innerHTML = `
|
|
154
|
-
<h2 style="margin:0 0 8px;font-size:1.3rem;">Sign in to ${escapeHtml(appName)}</h2>
|
|
155
|
-
<p style="margin:0 0 24px;color:${muted};font-size:0.9rem;">Choose how you want to sign in. Your keys never leave your control.</p>
|
|
156
|
-
<div style="display:flex;flex-direction:column;">
|
|
157
|
-
${showNip07 ? `<button data-choice="nip07" style="${buttonStyle(dark, true)}"><span style="font-size:1.2rem;">🌐</span><span><strong>Browser extension</strong><br><span style="font-size:0.8rem;opacity:0.8;">bark, Alby, nos2x</span></span></button>` : ''}
|
|
158
|
-
${showAmber ? `<button data-choice="amber" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🤖</span><span><strong>Sign in with Amber</strong><br><span style="font-size:0.8rem;color:${muted};">Android signer (NIP-55)</span></span></button>` : ''}
|
|
159
|
-
<button data-choice="redirect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🪪</span><span><strong>Sign in with Signet</strong><br><span style="font-size:0.8rem;color:${muted};">Open Signet on this device</span></span></button>
|
|
160
|
-
<button data-choice="qr" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">📱</span><span><strong>Signet on another device</strong><br><span style="font-size:0.8rem;color:${muted};">Scan QR with your phone</span></span></button>
|
|
161
|
-
<button data-choice="bunker" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🔑</span><span><strong>Paste bunker URI</strong><br><span style="font-size:0.8rem;color:${muted};">For NIP-46 power users</span></span></button>
|
|
162
|
-
<button data-choice="nostrconnect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">📡</span><span><strong>Connect a Nostr signer</strong><br><span style="font-size:0.8rem;color:${muted};">Scan with nsec.app, Amber, Keychat…</span></span></button>
|
|
163
|
-
<button data-choice="nsec" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">⚠️</span><span><strong>Paste private key</strong><br><span style="font-size:0.8rem;color:${muted};">In-memory only — risky, last resort</span></span></button>
|
|
164
|
-
</div>
|
|
165
|
-
<button data-choice="cancel" style="background:transparent;color:${dark ? '#e0e0e0' : '#1a1a2e'};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:12px;cursor:pointer;font-size:0.95rem;width:100%;margin-top:12px;text-align:center;">Cancel</button>
|
|
166
|
-
`;
|
|
167
277
|
return new Promise(resolve => {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
278
|
+
let advancedOpen = false;
|
|
279
|
+
const availableMethods = opts.methods.filter(isMethodAvailable);
|
|
280
|
+
const advancedSet = new Set(opts.advancedMethods);
|
|
281
|
+
const primaryMethods = availableMethods.filter(method => !advancedSet.has(method));
|
|
282
|
+
const advancedMethods = availableMethods.filter(method => advancedSet.has(method));
|
|
283
|
+
const attachChoiceHandlers = () => {
|
|
284
|
+
refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
|
|
285
|
+
btn.addEventListener('click', () => {
|
|
286
|
+
const choice = btn.dataset.choice;
|
|
287
|
+
resolve(choice);
|
|
288
|
+
});
|
|
172
289
|
});
|
|
173
|
-
|
|
290
|
+
refs.dialog.querySelector('[data-action="advanced"]')?.addEventListener('click', () => {
|
|
291
|
+
advancedOpen = true;
|
|
292
|
+
paint();
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
const paint = () => {
|
|
296
|
+
const showAdvanced = advancedOpen || primaryMethods.length === 0;
|
|
297
|
+
const primaryHtml = primaryMethods.map((method, index) => methodButtonHtml(method, dark, muted, index === 0)).join('');
|
|
298
|
+
const advancedHtml = showAdvanced
|
|
299
|
+
? advancedMethods.map((method, index) => methodButtonHtml(method, dark, muted, primaryMethods.length === 0 && index === 0)).join('')
|
|
300
|
+
: '';
|
|
301
|
+
const advancedToggle = advancedMethods.length > 0 && !showAdvanced
|
|
302
|
+
? `<button data-action="advanced" style="${buttonStyle(dark)}justify-content:center;text-align:center;">Advanced</button>`
|
|
303
|
+
: '';
|
|
304
|
+
const empty = availableMethods.length === 0
|
|
305
|
+
? `<p style="margin:0 0 12px;color:${muted};font-size:0.85rem;">No configured sign-in methods are available on this device.</p>`
|
|
306
|
+
: '';
|
|
307
|
+
refs.dialog.innerHTML = `
|
|
308
|
+
<h2 style="margin:0 0 8px;font-size:1.3rem;">Sign in to ${escapeHtml(opts.appName)}</h2>
|
|
309
|
+
<p style="margin:0 0 24px;color:${muted};font-size:0.9rem;">Choose how you want to sign in. Your keys never leave your control.</p>
|
|
310
|
+
<div style="display:flex;flex-direction:column;">
|
|
311
|
+
${empty}
|
|
312
|
+
${primaryHtml}
|
|
313
|
+
${advancedToggle}
|
|
314
|
+
${advancedHtml}
|
|
315
|
+
</div>
|
|
316
|
+
<button data-choice="cancel" style="background:transparent;color:${dark ? '#e0e0e0' : '#1a1a2e'};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:12px;cursor:pointer;font-size:0.95rem;width:100%;margin-top:12px;text-align:center;">Cancel</button>
|
|
317
|
+
`;
|
|
318
|
+
attachChoiceHandlers();
|
|
319
|
+
};
|
|
320
|
+
paint();
|
|
174
321
|
});
|
|
175
322
|
}
|
|
176
323
|
/**
|
|
@@ -424,30 +571,71 @@ async function runBunkerFlow(refs, opts) {
|
|
|
424
571
|
const muted = dark ? '#888' : '#666';
|
|
425
572
|
const inputBg = dark ? '#0f0f1f' : '#f5f5f8';
|
|
426
573
|
const inputFg = dark ? '#e0e0e0' : '#1a1a2e';
|
|
574
|
+
const scanButton = canUseCameraQrScanner()
|
|
575
|
+
? `<button data-action="scan" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Scan QR</button>`
|
|
576
|
+
: '';
|
|
427
577
|
refs.dialog.innerHTML = `
|
|
428
578
|
<h2 style="margin:0 0 8px;font-size:1.2rem;">Paste bunker URI</h2>
|
|
429
|
-
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Connect to your NIP-46 bunker (Heartwood, nsecBunker, or any compatible signer).</p>
|
|
579
|
+
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Connect to your NIP-46 bunker (Heartwood, nsecBunker, Amber, or any compatible signer).</p>
|
|
430
580
|
<textarea id="signet-login-bunker-input" placeholder="bunker://..." rows="3" style="width:100%;background:${inputBg};color:${inputFg};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:10px;font-size:0.85rem;font-family:ui-monospace,monospace;box-sizing:border-box;resize:vertical;margin-bottom:12px;"></textarea>
|
|
581
|
+
<div id="signet-login-bunker-scan" hidden style="margin:0 0 12px;"></div>
|
|
431
582
|
<p id="signet-login-bunker-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;min-height:1.2em;"></p>
|
|
432
583
|
<div style="display:flex;gap:8px;justify-content:space-between;">
|
|
433
584
|
<button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
|
|
585
|
+
${scanButton}
|
|
434
586
|
<button data-action="connect" style="${buttonStyle(dark, true)}width:auto;flex:1;padding:8px 16px;text-align:center;">Connect</button>
|
|
435
587
|
</div>
|
|
436
588
|
`;
|
|
437
589
|
return new Promise(resolve => {
|
|
438
590
|
let settled = false;
|
|
591
|
+
let scanGeneration = 0;
|
|
439
592
|
const settle = (v) => {
|
|
440
593
|
if (settled)
|
|
441
594
|
return;
|
|
442
595
|
settled = true;
|
|
596
|
+
scanGeneration++;
|
|
597
|
+
scanner?.stop();
|
|
443
598
|
resolve(v);
|
|
444
599
|
};
|
|
445
600
|
const input = refs.dialog.querySelector('#signet-login-bunker-input');
|
|
446
601
|
const status = refs.dialog.querySelector('#signet-login-bunker-status');
|
|
602
|
+
const scanContainer = refs.dialog.querySelector('#signet-login-bunker-scan');
|
|
447
603
|
const connectBtn = refs.dialog.querySelector('[data-action="connect"]');
|
|
604
|
+
const scanBtn = refs.dialog.querySelector('[data-action="scan"]');
|
|
605
|
+
let scanner = null;
|
|
448
606
|
refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
|
|
607
|
+
scanner?.stop();
|
|
449
608
|
settle(null);
|
|
450
609
|
});
|
|
610
|
+
scanBtn?.addEventListener('click', () => {
|
|
611
|
+
if (!input || !scanContainer)
|
|
612
|
+
return;
|
|
613
|
+
scanner?.stop();
|
|
614
|
+
scanner = null;
|
|
615
|
+
const generation = ++scanGeneration;
|
|
616
|
+
void startCameraQrScanner({
|
|
617
|
+
container: scanContainer,
|
|
618
|
+
status,
|
|
619
|
+
acceptedPrefixes: ['bunker://'],
|
|
620
|
+
onValue: value => {
|
|
621
|
+
input.value = value;
|
|
622
|
+
input.focus();
|
|
623
|
+
},
|
|
624
|
+
}).then(handle => {
|
|
625
|
+
if (settled || generation !== scanGeneration) {
|
|
626
|
+
handle.stop();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
scanner = handle;
|
|
630
|
+
}).catch(err => {
|
|
631
|
+
if (settled || generation !== scanGeneration)
|
|
632
|
+
return;
|
|
633
|
+
if (status) {
|
|
634
|
+
status.textContent = `✗ ${err instanceof Error ? err.message : String(err)}`;
|
|
635
|
+
status.style.color = '#d04848';
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
});
|
|
451
639
|
connectBtn?.addEventListener('click', async () => {
|
|
452
640
|
const uri = input?.value.trim() ?? '';
|
|
453
641
|
if (!uri) {
|
|
@@ -492,15 +680,15 @@ async function runNostrConnectFlow(refs, opts) {
|
|
|
492
680
|
const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
|
|
493
681
|
const uri = buildNostrConnectUri({
|
|
494
682
|
clientPubkeyHex: clientPubkey,
|
|
495
|
-
|
|
683
|
+
relayUrls: opts.relayUrls,
|
|
496
684
|
secret,
|
|
497
|
-
perms:
|
|
685
|
+
perms: opts.nostrConnectPerms,
|
|
498
686
|
appName: opts.appName,
|
|
499
687
|
appUrl: opts.origin,
|
|
500
688
|
});
|
|
501
689
|
refs.dialog.innerHTML = `
|
|
502
690
|
<h2 style="margin:0 0 8px;font-size:1.2rem;">Connect a Nostr signer</h2>
|
|
503
|
-
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Scan or paste this into your signer (nsec.app, Amber, Keychat
|
|
691
|
+
<p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Scan or paste this into your signer (nsec.app, Amber, Keychat...). The connection happens over your configured relay${opts.relayUrls.length > 1 ? 's' : ''}.</p>
|
|
504
692
|
<div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
|
|
505
693
|
<canvas id="signet-login-nc-qr" width="200" height="200" style="display:block;width:200px;height:200px;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
|
|
506
694
|
<button data-action="copy" style="${buttonStyle(dark)}width:auto;font-size:0.75rem;padding:6px 10px;margin:0 auto;display:block;">Copy URI</button>
|
|
@@ -613,17 +801,46 @@ async function runNsecFlow(refs, opts) {
|
|
|
613
801
|
});
|
|
614
802
|
});
|
|
615
803
|
}
|
|
804
|
+
function uniquePickerMethods(input, fallback) {
|
|
805
|
+
const source = input ?? fallback;
|
|
806
|
+
const allowed = new Set(DEFAULT_PICKER_METHODS);
|
|
807
|
+
const out = [];
|
|
808
|
+
for (const method of source) {
|
|
809
|
+
if (!allowed.has(method))
|
|
810
|
+
continue;
|
|
811
|
+
if (!out.includes(method))
|
|
812
|
+
out.push(method);
|
|
813
|
+
}
|
|
814
|
+
return input === undefined && out.length === 0 ? [...fallback] : out;
|
|
815
|
+
}
|
|
816
|
+
function resolveMethodConfig(opts) {
|
|
817
|
+
const methods = uniquePickerMethods(opts.methods, DEFAULT_PICKER_METHODS);
|
|
818
|
+
const advancedMethods = uniquePickerMethods(opts.advancedMethods, DEFAULT_ADVANCED_METHODS)
|
|
819
|
+
.filter(method => methods.includes(method));
|
|
820
|
+
return { methods, advancedMethods };
|
|
821
|
+
}
|
|
822
|
+
function resolveRelayUrls(opts) {
|
|
823
|
+
const relayUrls = opts.relayUrls ?? (opts.relayUrl ? [opts.relayUrl] : [DEFAULTS.relayUrl]);
|
|
824
|
+
const cleanRelayUrls = relayUrls.map(relay => relay.trim()).filter(Boolean);
|
|
825
|
+
return cleanRelayUrls.length > 0 ? cleanRelayUrls : [DEFAULTS.relayUrl];
|
|
826
|
+
}
|
|
616
827
|
function resolveOptions(opts) {
|
|
617
828
|
const challenge = opts.challenge ?? generateChallenge();
|
|
618
829
|
if (!/^[0-9a-f]{64}$/i.test(challenge))
|
|
619
830
|
throw new Error('challenge-must-be-64-hex');
|
|
620
831
|
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
|
|
621
832
|
const timeout = Math.max(5000, Math.min(opts.timeout ?? DEFAULTS.timeout, 600000));
|
|
833
|
+
const relayUrls = resolveRelayUrls(opts);
|
|
834
|
+
const methodConfig = resolveMethodConfig(opts);
|
|
622
835
|
const result = {
|
|
623
836
|
appName: opts.appName,
|
|
624
837
|
challenge: challenge.toLowerCase(),
|
|
625
838
|
origin,
|
|
626
|
-
|
|
839
|
+
methods: methodConfig.methods,
|
|
840
|
+
advancedMethods: methodConfig.advancedMethods,
|
|
841
|
+
relayUrl: relayUrls[0],
|
|
842
|
+
relayUrls,
|
|
843
|
+
nostrConnectPerms: opts.nostrConnectPerms ?? DEFAULT_NOSTR_CONNECT_PERMS,
|
|
627
844
|
theme: opts.theme ?? DEFAULTS.theme,
|
|
628
845
|
timeout,
|
|
629
846
|
signetAppOrigin: opts.signetAppOrigin ?? DEFAULTS.signetAppOrigin,
|
|
@@ -672,7 +889,7 @@ async function runLoginModal(opts) {
|
|
|
672
889
|
while (true) {
|
|
673
890
|
const choice = resolved.preferredMethod
|
|
674
891
|
? resolved.preferredMethod
|
|
675
|
-
: await Promise.race([renderPicker(refs, resolved
|
|
892
|
+
: await Promise.race([renderPicker(refs, resolved), aborted]);
|
|
676
893
|
if (userAborted)
|
|
677
894
|
return null;
|
|
678
895
|
if (choice === null || choice === 'cancel')
|
package/dist/signers.d.ts
CHANGED
|
@@ -77,7 +77,8 @@ export declare function createBunkerSignerFromNostrConnect(input: {
|
|
|
77
77
|
*/
|
|
78
78
|
export declare function buildNostrConnectUri(input: {
|
|
79
79
|
clientPubkeyHex: string;
|
|
80
|
-
relayUrl
|
|
80
|
+
relayUrl?: string;
|
|
81
|
+
relayUrls?: string[];
|
|
81
82
|
secret: string;
|
|
82
83
|
perms?: string[];
|
|
83
84
|
appName?: string;
|
package/dist/signers.js
CHANGED
|
@@ -126,12 +126,21 @@ export async function createBunkerSignerFromNostrConnect(input) {
|
|
|
126
126
|
* it's talking to the right peer; it must be unguessable.
|
|
127
127
|
*/
|
|
128
128
|
export function buildNostrConnectUri(input) {
|
|
129
|
-
const { clientPubkeyHex,
|
|
129
|
+
const { clientPubkeyHex, secret } = input;
|
|
130
130
|
if (!/^[0-9a-f]{64}$/i.test(clientPubkeyHex))
|
|
131
131
|
throw new Error('invalid-client-pubkey');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
const relayUrls = input.relayUrls ?? (input.relayUrl ? [input.relayUrl] : []);
|
|
133
|
+
const cleanRelayUrls = relayUrls.map(relay => relay.trim()).filter(Boolean);
|
|
134
|
+
if (cleanRelayUrls.length === 0)
|
|
135
|
+
throw new Error('relay-url-required');
|
|
136
|
+
for (const relayUrl of cleanRelayUrls) {
|
|
137
|
+
if (!/^wss?:\/\//.test(relayUrl))
|
|
138
|
+
throw new Error('invalid-relay-url');
|
|
139
|
+
}
|
|
140
|
+
const params = new URLSearchParams();
|
|
141
|
+
for (const relayUrl of cleanRelayUrls)
|
|
142
|
+
params.append('relay', relayUrl);
|
|
143
|
+
params.set('secret', secret);
|
|
135
144
|
if (input.perms && input.perms.length > 0)
|
|
136
145
|
params.set('perms', input.perms.join(','));
|
|
137
146
|
if (input.appName)
|