signet-login 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 forgesworn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # signet-login
2
+
3
+ [![GitHub Sponsors](https://img.shields.io/github/sponsors/TheCryptoDonkey?logo=githubsponsors&color=ea4aaa&label=Sponsor)](https://github.com/sponsors/TheCryptoDonkey)
4
+
5
+ **Sign in with Signet** for Nostr-aware websites. One picker, three backends:
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)
10
+
11
+ Returns a unified `SignetSigner` your code can use to sign Nostr events going forward.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install signet-login
17
+ ```
18
+
19
+ Or drop it in via CDN:
20
+
21
+ ```html
22
+ <script src="https://cdn.signet.forgesworn.dev/signet-login.iife.js"></script>
23
+ ```
24
+
25
+ The IIFE bundle additively extends `window.Signet` — it coexists with `signet-verify` on the same page in either load order.
26
+
27
+ ## Quick start
28
+
29
+ ```html
30
+ <button id="login">Sign in</button>
31
+
32
+ <script src="https://cdn.signet.forgesworn.dev/signet-login.iife.js"></script>
33
+ <script>
34
+ document.getElementById('login').addEventListener('click', async () => {
35
+ const session = await Signet.login({ appName: 'My Game' });
36
+ if (!session) return; // user cancelled
37
+
38
+ // Sign a Nostr event with the user's chosen signer:
39
+ const signed = await session.signer.signEvent({
40
+ kind: 30762,
41
+ content: '',
42
+ tags: [
43
+ ['game', 'my-game'],
44
+ ['score', '12350'],
45
+ ['p', session.pubkey],
46
+ ],
47
+ });
48
+ console.log('signed:', signed);
49
+ });
50
+ </script>
51
+ ```
52
+
53
+ ## API
54
+
55
+ ### `Signet.login(options)`
56
+
57
+ Show the picker, return a `SignetSession` on success or `null` on cancel/timeout.
58
+
59
+ ```ts
60
+ 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)
70
+ }
71
+
72
+ interface SignetSession {
73
+ pubkey: string; // hex
74
+ method: 'nip07' | 'redirect' | 'bunker';
75
+ signer: SignetSigner;
76
+ authEvent: SignetAuthEvent; // signed kind-21236 challenge proof
77
+ expiresAt?: number;
78
+ displayName?: string;
79
+ }
80
+ ```
81
+
82
+ ### `Signet.restoreSession(opts?)`
83
+
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.
85
+
86
+ ```js
87
+ const session = await Signet.restoreSession();
88
+ if (session?.signer.capabilities.canSignEvents) {
89
+ // we have ongoing signing capability
90
+ }
91
+ ```
92
+
93
+ ### `Signet.logout(currentSession?)`
94
+
95
+ Clear stored session and close the active signer.
96
+
97
+ ### `Signet.handleCallback(opts?)`
98
+
99
+ 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
+
101
+ ## The three signers
102
+
103
+ All three implement `SignetSigner`:
104
+
105
+ ```ts
106
+ interface SignetSigner {
107
+ readonly pubkey: string;
108
+ readonly method: 'nip07' | 'redirect' | 'bunker';
109
+ readonly capabilities: { canSignEvents: boolean; hasNip44: boolean };
110
+ signEvent(template: EventTemplate): Promise<NostrEvent>;
111
+ nip44?: { encrypt, decrypt };
112
+ close(): Promise<void>;
113
+ }
114
+ ```
115
+
116
+ | Signer | `canSignEvents` | Source |
117
+ |---|---|---|
118
+ | `Nip07Signer` | true | `window.nostr` (any NIP-07 extension) |
119
+ | `BunkerSignerImpl` | true | `nostr-tools` BunkerSigner over NIP-46 relay |
120
+ | `EphemeralSigner` | **false** | Auth-only — redirect returned only `authEvent` |
121
+
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:
123
+
124
+ ```js
125
+ if (session.signer.capabilities.canSignEvents) {
126
+ enableLeaderboardPublish();
127
+ } else {
128
+ promptUserToInstallExtensionOrPasteBunkerURI();
129
+ }
130
+ ```
131
+
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.
133
+
134
+ ## Server-side verification
135
+
136
+ The client sends `session.authEvent` to your server. Verify it before granting any privileges or paying out sats:
137
+
138
+ ```ts
139
+ import { verifyLogin } from 'signet-login/verify';
140
+
141
+ const result = verifyLogin(authEvent, {
142
+ expectedChallenge: theChallengeYouIssued,
143
+ expectedOrigin: 'https://my-game.example',
144
+ expectedAppName: 'My Game', // optional
145
+ maxAgeSeconds: 300, // default 300
146
+ });
147
+
148
+ if (result.valid) {
149
+ // result.pubkey is the authenticated user
150
+ } else {
151
+ // result.error: 'invalid-signature' | 'challenge-mismatch' | 'too-old' | …
152
+ }
153
+ ```
154
+
155
+ The verifier checks: schnorr signature, canonical event ID, kind=21236, challenge tag match, origin tag match, optional app tag match, freshness window (5-min default + 60s skew tolerance).
156
+
157
+ ## Storage
158
+
159
+ Session data is stored in localStorage under `signet:login.*`:
160
+
161
+ | Key | Purpose |
162
+ |---|---|
163
+ | `signet:login.pubkey` | Authenticated pubkey |
164
+ | `signet:login.method` | `nip07` / `redirect` / `bunker` |
165
+ | `signet:login.authEvent` | Serialised kind-21236 auth event |
166
+ | `signet:login.bunkerUri` | Bunker URI for reconnect (bunker only) |
167
+ | `signet:login.bunkerClientSk` | Client secret key hex (bunker only) |
168
+ | `signet:login.expiresAt` | Optional expiry |
169
+ | `signet:login.displayName` | Optional persona handle |
170
+
171
+ Storage namespace is `signet:login.*` so it doesn't collide with `signet:verify.*`. `Signet.logout()` clears all login keys without touching other Signet SDKs.
172
+
173
+ ## Coexistence with signet-verify
174
+
175
+ Both SDKs attach to `window.Signet` additively — load order doesn't matter:
176
+
177
+ ```html
178
+ <script src=".../signet-verify.iife.js"></script>
179
+ <script src=".../signet-login.iife.js"></script>
180
+
181
+ <script>
182
+ // age verification
183
+ const ageResult = await Signet.verifyAge('18+');
184
+ // login
185
+ const session = await Signet.login({ appName: 'My App' });
186
+ </script>
187
+ ```
188
+
189
+ Each SDK manages its own slice of `window.Signet` and `localStorage` namespaces.
190
+
191
+ ## Bundle size
192
+
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.
194
+
195
+ ## Browser support
196
+
197
+ ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `localStorage`, `crypto.subtle`, `WebSocket`, and the native `<dialog>` element.
198
+
199
+ ## Development
200
+
201
+ ```bash
202
+ npm install
203
+ npm run build # dist/signet-login.js (ESM) + dist/signet-login.iife.js (browser)
204
+ npm run typecheck
205
+ npm test # vitest in jsdom
206
+ ```
207
+
208
+ Examples in `examples/`:
209
+ - `basic.html` — full demo with login / sign / logout / restore
210
+ - `callback.html` — redirect-back receiver page
211
+
212
+ Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html`.
213
+
214
+ ## Out of scope
215
+
216
+ | Excluded | Where it lives |
217
+ |---|---|
218
+ | Age verification | `signet-verify` |
219
+ | Per-game persona derivation | Heartwood RPC (reserved scope) |
220
+ | Sign-time policy clauses | Reserved (G34 NLnet Jun) |
221
+ | Generating bunker URIs | Heartwood / bark |
222
+ | Lightning, payments | Out of scope |
223
+
224
+ ## License
225
+
226
+ MIT
227
+
228
+ ## Related
229
+
230
+ - [signet](https://github.com/forgesworn/signet) — protocol, specs, docs
231
+ - [signet-protocol](https://www.npmjs.com/package/signet-protocol) — npm primitives
232
+ - [signet-verify](https://github.com/forgesworn/signet-verify) — age verification + cross-device auth primitives
233
+ - [bark](https://github.com/forgesworn/bark) — NIP-07 browser extension that signs via NIP-46 to Heartwood
234
+ - [Heartwood](https://github.com/forgesworn/heartwood) — self-hosted signing appliance
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Callback-page helper.
3
+ *
4
+ * This is for consumers who want to support same-device redirect (signet-app
5
+ * redirects back to their callback page after signing). The cross-device QR
6
+ * path used by `Signet.login()` does NOT need this — it returns directly via
7
+ * a NIP-17 gift-wrapped relay event.
8
+ *
9
+ * v0.1 limitation: full same-device redirect support requires reconstruction
10
+ * of the signed kind-21236 event from URL params. signet-app's exact redirect-
11
+ * back parameter shape will determine this implementation. Until that's pinned
12
+ * down, this helper just relays the raw URL params back to the opener via
13
+ * postMessage and lets the consumer decide what to do.
14
+ */
15
+ export interface CallbackResult {
16
+ /** Raw URL parameters from the redirect-back. */
17
+ params: Record<string, string>;
18
+ /** True if this page was opened as a popup (window.opener present). */
19
+ isPopup: boolean;
20
+ }
21
+ /**
22
+ * Parse the current page's URL parameters and post them to the opener (if any).
23
+ * Optionally close the popup.
24
+ */
25
+ export declare function handleCallback(options?: {
26
+ closeAfterPost?: boolean;
27
+ }): CallbackResult;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Callback-page helper.
3
+ *
4
+ * This is for consumers who want to support same-device redirect (signet-app
5
+ * redirects back to their callback page after signing). The cross-device QR
6
+ * path used by `Signet.login()` does NOT need this — it returns directly via
7
+ * a NIP-17 gift-wrapped relay event.
8
+ *
9
+ * v0.1 limitation: full same-device redirect support requires reconstruction
10
+ * of the signed kind-21236 event from URL params. signet-app's exact redirect-
11
+ * back parameter shape will determine this implementation. Until that's pinned
12
+ * down, this helper just relays the raw URL params back to the opener via
13
+ * postMessage and lets the consumer decide what to do.
14
+ */
15
+ /**
16
+ * Parse the current page's URL parameters and post them to the opener (if any).
17
+ * Optionally close the popup.
18
+ */
19
+ export function handleCallback(options) {
20
+ const params = {};
21
+ if (typeof window !== 'undefined') {
22
+ const search = new URLSearchParams(window.location.search);
23
+ search.forEach((value, key) => {
24
+ params[key] = value;
25
+ });
26
+ }
27
+ const isPopup = typeof window !== 'undefined' && !!window.opener && window.opener !== window;
28
+ if (isPopup) {
29
+ try {
30
+ window.opener.postMessage({ type: 'signet-login-callback', params },
31
+ // Restrict target origin to opener's origin if known; fall back to '*' so
32
+ // cross-origin popups still deliver. Consumers must validate origin.
33
+ '*');
34
+ }
35
+ catch {
36
+ // postMessage failed — ignore
37
+ }
38
+ if (options?.closeAfterPost ?? true) {
39
+ try {
40
+ window.close();
41
+ }
42
+ catch { /* ignore */ }
43
+ }
44
+ }
45
+ return { params, isPopup };
46
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The login modal — picker → method-specific UI → resolved session.
3
+ *
4
+ * Mirrors signet-verify's <dialog>-based pattern: native focus trap,
5
+ * top-layer placement, theme-aware colours, no third-party UI deps.
6
+ */
7
+ import type { LoginOptions, SignetSession } from './types.js';
8
+ /**
9
+ * Entry point — show the modal, route to the chosen method, return a session.
10
+ *
11
+ * Returns null when the user cancels or the flow times out.
12
+ */
13
+ export declare function showLoginModal(opts: LoginOptions): Promise<SignetSession | null>;