smesh-signer-extension.ts raw
1 /* eslint-disable @typescript-eslint/no-explicit-any */
2 import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
3 import { ExtensionMethod } from '@common';
4
5 // Extend Window interface for NIP-07 and WebLN
6 declare global {
7 interface Window {
8 nostr?: any;
9 webln?: any;
10 }
11 }
12
13 type Relays = Record<string, { read: boolean; write: boolean }>;
14
15 // Fallback UUID generator for contexts where crypto.randomUUID is unavailable
16 function generateUUID(): string {
17 if (typeof crypto !== 'undefined' && crypto.randomUUID) {
18 return crypto.randomUUID();
19 }
20 // Fallback using crypto.getRandomValues
21 const bytes = new Uint8Array(16);
22 crypto.getRandomValues(bytes);
23 bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
24 bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
25 const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
26 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
27 }
28
29 class Messenger {
30 #requests = new Map<
31 string,
32 {
33 resolve: (value: unknown) => void;
34 reject: (reason: any) => void;
35 }
36 >();
37
38 constructor() {
39 window.addEventListener('message', this.#handleCallResponse.bind(this));
40 }
41
42 async request(method: ExtensionMethod, params: any): Promise<any> {
43 const id = generateUUID();
44
45 return new Promise((resolve, reject) => {
46 this.#requests.set(id, { resolve, reject });
47 window.postMessage(
48 {
49 id,
50 ext: 'smesh-signer',
51 method,
52 params,
53 },
54 '*'
55 );
56 });
57 }
58
59 #handleCallResponse(message: MessageEvent) {
60 // We also will receive our own messages, that we sent.
61 // We have to ignore them (they will not have a response field).
62 if (
63 !message.data ||
64 message.data.response === null ||
65 message.data.response === undefined ||
66 message.data.ext !== 'smesh-signer' ||
67 !this.#requests.has(message.data.id)
68 ) {
69 return;
70 }
71
72 if (message.data.response.error) {
73 this.#requests.get(message.data.id)?.reject(message.data.response.error);
74 } else {
75 this.#requests.get(message.data.id)?.resolve(message.data.response);
76 }
77
78 this.#requests.delete(message.data.id);
79 }
80 }
81
82 const nostr = {
83 messenger: new Messenger(),
84
85 async getPublicKey(): Promise<string> {
86 debug('getPublicKey received');
87 const pubkey = await this.messenger.request('getPublicKey', {});
88 debug(`getPublicKey response:`);
89 debug(pubkey);
90 return pubkey;
91 },
92
93 async signEvent(event: EventTemplate): Promise<NostrEvent> {
94 debug('signEvent received');
95 const signedEvent = await this.messenger.request('signEvent', event);
96 debug('signEvent response:');
97 debug(signedEvent);
98 return signedEvent;
99 },
100
101 async getRelays(): Promise<Relays> {
102 debug('getRelays received');
103 const relays = (await this.messenger.request('getRelays', {})) as Relays;
104 debug('getRelays response:');
105 debug(relays);
106 return relays;
107 },
108
109 nip04: {
110 that: this,
111
112 async encrypt(peerPubkey: string, plaintext: string): Promise<string> {
113 debug('nip04.encrypt received');
114 const ciphertext = (await nostr.messenger.request('nip04.encrypt', {
115 peerPubkey,
116 plaintext,
117 })) as string;
118 debug('nip04.encrypt response:');
119 debug(ciphertext);
120 return ciphertext;
121 },
122
123 async decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
124 debug('nip04.decrypt received');
125 const plaintext = (await nostr.messenger.request('nip04.decrypt', {
126 peerPubkey,
127 ciphertext,
128 })) as string;
129 debug('nip04.decrypt response:');
130 debug(plaintext);
131 return plaintext;
132 },
133 },
134
135 nip44: {
136 async encrypt(peerPubkey: string, plaintext: string): Promise<string> {
137 debug('nip44.encrypt received');
138 const ciphertext = (await nostr.messenger.request('nip44.encrypt', {
139 peerPubkey,
140 plaintext,
141 })) as string;
142 debug('nip44.encrypt response:');
143 debug(ciphertext);
144 return ciphertext;
145 },
146
147 async decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
148 debug('nip44.decrypt received');
149 const plaintext = (await nostr.messenger.request('nip44.decrypt', {
150 peerPubkey,
151 ciphertext,
152 })) as string;
153 debug('nip44.decrypt response:');
154 debug(plaintext);
155 return plaintext;
156 },
157 },
158
159 mls: {
160 async init(relayURLs: string[], lastEventTS?: number): Promise<string> {
161 debug('mls.init received');
162 // Persist relay URLs on window so mls-bridge.mjs can pick them up even if
163 // it loaded after this CustomEvent fired (DOM events are not queued).
164 (window as any)._nostrMlsRelays = relayURLs;
165 window.dispatchEvent(new CustomEvent('nostr-mls', { detail: { cmd: 'relays', relays: relayURLs } }));
166 const r = await nostr.messenger.request('mls.init' as any, { relayURLs, lastEventTS: lastEventTS || 0 });
167 debug('mls.init result: ' + r);
168 return r;
169 },
170 async sendDM(recipient: string, content: string): Promise<string> {
171 debug('mls.sendDM received: ' + recipient.slice(0, 8) + '...');
172 const r = await nostr.messenger.request('mls.sendDM' as any, { recipient, content });
173 debug('mls.sendDM result: ' + r);
174 return r;
175 },
176 async subscribe(): Promise<string> {
177 debug('mls.subscribe received');
178 return await nostr.messenger.request('mls.subscribe' as any, {});
179 },
180 async publishKP(): Promise<string> {
181 debug('mls.publishKP received');
182 return await nostr.messenger.request('mls.publishKP' as any, {});
183 },
184 async listGroups(): Promise<string[]> {
185 return await nostr.messenger.request('mls.listGroups' as any, {});
186 },
187 async deliverEvent(subId: number, eventJSON: string): Promise<string> {
188 return await nostr.messenger.request('mls.deliverEvent' as any, { subId, eventJSON });
189 },
190 async backupGroups(): Promise<string> {
191 return await nostr.messenger.request('mls.backupGroups' as any, {});
192 },
193 async restoreGroups(): Promise<string> {
194 return await nostr.messenger.request('mls.restoreGroups' as any, {});
195 },
196 async ratchetGroup(peerHex: string): Promise<string> {
197 return await nostr.messenger.request('mls.ratchetGroup' as any, { peerHex });
198 },
199 },
200 };
201
202 window.nostr = nostr as any;
203
204 // WebLN types (inline to avoid build issues with @common types in injected script)
205 interface RequestInvoiceArgs {
206 amount?: string | number;
207 defaultAmount?: string | number;
208 minimumAmount?: string | number;
209 maximumAmount?: string | number;
210 defaultMemo?: string;
211 }
212
213 interface KeysendArgs {
214 destination: string;
215 amount: string | number;
216 customRecords?: Record<string, string>;
217 }
218
219 // Create a shared messenger instance for WebLN
220 const weblnMessenger = nostr.messenger;
221
222 const webln = {
223 enabled: false,
224
225 async enable(): Promise<void> {
226 debug('webln.enable received');
227 await weblnMessenger.request('webln.enable', {});
228 this.enabled = true;
229 debug('webln.enable completed');
230 // Dispatch webln:enabled event as per WebLN spec
231 window.dispatchEvent(new Event('webln:enabled'));
232 },
233
234 async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
235 debug('webln.getInfo received');
236 const info = await weblnMessenger.request('webln.getInfo', {});
237 debug('webln.getInfo response:');
238 debug(info);
239 return info;
240 },
241
242 async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
243 debug('webln.sendPayment received');
244 const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
245 debug('webln.sendPayment response:');
246 debug(result);
247 return result;
248 },
249
250 async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
251 debug('webln.keysend received');
252 const result = await weblnMessenger.request('webln.keysend', args);
253 debug('webln.keysend response:');
254 debug(result);
255 return result;
256 },
257
258 async makeInvoice(
259 args: string | number | RequestInvoiceArgs
260 ): Promise<{ paymentRequest: string }> {
261 debug('webln.makeInvoice received');
262 // Normalize args to RequestInvoiceArgs
263 let normalizedArgs: RequestInvoiceArgs;
264 if (typeof args === 'string' || typeof args === 'number') {
265 normalizedArgs = { amount: args };
266 } else {
267 normalizedArgs = args;
268 }
269 const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
270 debug('webln.makeInvoice response:');
271 debug(result);
272 return result;
273 },
274
275 signMessage(): Promise<{ message: string; signature: string }> {
276 throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
277 },
278
279 verifyMessage(): Promise<void> {
280 throw new Error('verifyMessage is not supported - NWC does not provide message verification');
281 },
282 };
283
284 window.webln = webln as any;
285
286 // Dispatch webln:ready event to signal that webln is available
287 // This is dispatched on document as per the WebLN standard
288 document.dispatchEvent(new Event('webln:ready'));
289
290 // Listen for MLS push messages from the extension background.
291 // These arrive via content script relay and are dispatched as custom events
292 // so the page app can handle them (publish events, display DMs, etc.).
293 window.addEventListener('message', (event: MessageEvent) => {
294 if (event.data?.ext !== 'smesh-signer' || event.data?.type !== 'mls-push') return;
295 window.dispatchEvent(new CustomEvent('nostr-mls', { detail: event.data.data }));
296 });
297
298 const debug = function (value: any) {
299 console.log('[signer]', typeof value === 'string' ? value : JSON.stringify(value));
300 };
301