@nostrify/nostrify 0.46.4

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.
Files changed (182) hide show
  1. package/.turbo/turbo-build.log +5 -0
  2. package/BunkerURI.test.ts +28 -0
  3. package/BunkerURI.ts +58 -0
  4. package/CHANGELOG.md +562 -0
  5. package/LICENSE +21 -0
  6. package/NBrowserSigner.test.ts +170 -0
  7. package/NBrowserSigner.ts +100 -0
  8. package/NCache.bench.ts +81 -0
  9. package/NCache.test.ts +22 -0
  10. package/NCache.ts +73 -0
  11. package/NConnectSigner.test.ts +102 -0
  12. package/NConnectSigner.ts +189 -0
  13. package/NIP05.test.ts +67 -0
  14. package/NIP05.ts +52 -0
  15. package/NIP50.test.ts +58 -0
  16. package/NIP50.ts +24 -0
  17. package/NIP98.test.ts +181 -0
  18. package/NIP98.ts +97 -0
  19. package/NKinds.test.ts +42 -0
  20. package/NKinds.ts +26 -0
  21. package/NPool.test.ts +117 -0
  22. package/NPool.ts +224 -0
  23. package/NRelay1.test.ts +174 -0
  24. package/NRelay1.ts +440 -0
  25. package/NSchema.test.ts +94 -0
  26. package/NSchema.ts +255 -0
  27. package/NSecSigner.bench.ts +55 -0
  28. package/NSecSigner.test.ts +26 -0
  29. package/NSecSigner.ts +60 -0
  30. package/NSet.bench.ts +10 -0
  31. package/NSet.test.ts +92 -0
  32. package/NSet.ts +203 -0
  33. package/README.md +314 -0
  34. package/RelayError.test.ts +23 -0
  35. package/RelayError.ts +22 -0
  36. package/dist/BunkerURI.d.ts +23 -0
  37. package/dist/BunkerURI.d.ts.map +1 -0
  38. package/dist/BunkerURI.js +52 -0
  39. package/dist/BunkerURI.js.map +1 -0
  40. package/dist/NBrowserSigner.d.ts +27 -0
  41. package/dist/NBrowserSigner.d.ts.map +1 -0
  42. package/dist/NBrowserSigner.js +96 -0
  43. package/dist/NBrowserSigner.js.map +1 -0
  44. package/dist/NCache.d.ts +34 -0
  45. package/dist/NCache.d.ts.map +1 -0
  46. package/dist/NCache.js +63 -0
  47. package/dist/NCache.js.map +1 -0
  48. package/dist/NConnectSigner.d.ts +50 -0
  49. package/dist/NConnectSigner.d.ts.map +1 -0
  50. package/dist/NConnectSigner.js +130 -0
  51. package/dist/NConnectSigner.js.map +1 -0
  52. package/dist/NIP05.d.ts +15 -0
  53. package/dist/NIP05.d.ts.map +1 -0
  54. package/dist/NIP05.js +40 -0
  55. package/dist/NIP05.js.map +1 -0
  56. package/dist/NIP50.d.ts +10 -0
  57. package/dist/NIP50.d.ts.map +1 -0
  58. package/dist/NIP50.js +26 -0
  59. package/dist/NIP50.js.map +1 -0
  60. package/dist/NIP98.d.ts +15 -0
  61. package/dist/NIP98.d.ts.map +1 -0
  62. package/dist/NIP98.js +71 -0
  63. package/dist/NIP98.js.map +1 -0
  64. package/dist/NKinds.d.ts +13 -0
  65. package/dist/NKinds.d.ts.map +1 -0
  66. package/dist/NKinds.js +27 -0
  67. package/dist/NKinds.js.map +1 -0
  68. package/dist/NPool.d.ts +91 -0
  69. package/dist/NPool.d.ts.map +1 -0
  70. package/dist/NPool.js +185 -0
  71. package/dist/NPool.js.map +1 -0
  72. package/dist/NRelay1.d.ts +80 -0
  73. package/dist/NRelay1.d.ts.map +1 -0
  74. package/dist/NRelay1.js +336 -0
  75. package/dist/NRelay1.js.map +1 -0
  76. package/dist/NSchema.d.ts +73 -0
  77. package/dist/NSchema.d.ts.map +1 -0
  78. package/dist/NSchema.js +215 -0
  79. package/dist/NSchema.js.map +1 -0
  80. package/dist/NSecSigner.d.ts +29 -0
  81. package/dist/NSecSigner.d.ts.map +1 -0
  82. package/dist/NSecSigner.js +52 -0
  83. package/dist/NSecSigner.js.map +1 -0
  84. package/dist/NSet.d.ts +76 -0
  85. package/dist/NSet.d.ts.map +1 -0
  86. package/dist/NSet.js +174 -0
  87. package/dist/NSet.js.map +1 -0
  88. package/dist/RelayError.d.ts +10 -0
  89. package/dist/RelayError.d.ts.map +1 -0
  90. package/dist/RelayError.js +23 -0
  91. package/dist/RelayError.js.map +1 -0
  92. package/dist/ln/LNURL.d.ts +55 -0
  93. package/dist/ln/LNURL.d.ts.map +1 -0
  94. package/dist/ln/LNURL.js +105 -0
  95. package/dist/ln/LNURL.js.map +1 -0
  96. package/dist/ln/mod.d.ts +4 -0
  97. package/dist/ln/mod.d.ts.map +1 -0
  98. package/dist/ln/mod.js +6 -0
  99. package/dist/ln/mod.js.map +1 -0
  100. package/dist/ln/types/LNURLCallback.d.ts +8 -0
  101. package/dist/ln/types/LNURLCallback.d.ts.map +1 -0
  102. package/dist/ln/types/LNURLCallback.js +3 -0
  103. package/dist/ln/types/LNURLCallback.js.map +1 -0
  104. package/dist/ln/types/LNURLDetails.d.ts +20 -0
  105. package/dist/ln/types/LNURLDetails.d.ts.map +1 -0
  106. package/dist/ln/types/LNURLDetails.js +3 -0
  107. package/dist/ln/types/LNURLDetails.js.map +1 -0
  108. package/dist/mod.d.ts +16 -0
  109. package/dist/mod.d.ts.map +1 -0
  110. package/dist/mod.js +32 -0
  111. package/dist/mod.js.map +1 -0
  112. package/dist/test/ErrorRelay.d.ts +21 -0
  113. package/dist/test/ErrorRelay.d.ts.map +1 -0
  114. package/dist/test/ErrorRelay.js +26 -0
  115. package/dist/test/ErrorRelay.js.map +1 -0
  116. package/dist/test/MockRelay.d.ts +20 -0
  117. package/dist/test/MockRelay.d.ts.map +1 -0
  118. package/dist/test/MockRelay.js +66 -0
  119. package/dist/test/MockRelay.js.map +1 -0
  120. package/dist/test/TestRelayServer.d.ts +25 -0
  121. package/dist/test/TestRelayServer.d.ts.map +1 -0
  122. package/dist/test/TestRelayServer.js +134 -0
  123. package/dist/test/TestRelayServer.js.map +1 -0
  124. package/dist/test/mod.d.ts +8 -0
  125. package/dist/test/mod.d.ts.map +1 -0
  126. package/dist/test/mod.js +28 -0
  127. package/dist/test/mod.js.map +1 -0
  128. package/dist/tsconfig.tsbuildinfo +1 -0
  129. package/dist/uploaders/BlossomUploader.d.ts +26 -0
  130. package/dist/uploaders/BlossomUploader.d.ts.map +1 -0
  131. package/dist/uploaders/BlossomUploader.js +71 -0
  132. package/dist/uploaders/BlossomUploader.js.map +1 -0
  133. package/dist/uploaders/NostrBuildUploader.d.ts +24 -0
  134. package/dist/uploaders/NostrBuildUploader.d.ts.map +1 -0
  135. package/dist/uploaders/NostrBuildUploader.js +67 -0
  136. package/dist/uploaders/NostrBuildUploader.js.map +1 -0
  137. package/dist/uploaders/mod.d.ts +3 -0
  138. package/dist/uploaders/mod.d.ts.map +1 -0
  139. package/dist/uploaders/mod.js +8 -0
  140. package/dist/uploaders/mod.js.map +1 -0
  141. package/dist/utils/CircularSet.d.ts +13 -0
  142. package/dist/utils/CircularSet.d.ts.map +1 -0
  143. package/dist/utils/CircularSet.js +35 -0
  144. package/dist/utils/CircularSet.js.map +1 -0
  145. package/dist/utils/Machina.d.ts +36 -0
  146. package/dist/utils/Machina.d.ts.map +1 -0
  147. package/dist/utils/Machina.js +66 -0
  148. package/dist/utils/Machina.js.map +1 -0
  149. package/dist/utils/N64.d.ts +9 -0
  150. package/dist/utils/N64.d.ts.map +1 -0
  151. package/dist/utils/N64.js +23 -0
  152. package/dist/utils/N64.js.map +1 -0
  153. package/dist/utils/mod.d.ts +3 -0
  154. package/dist/utils/mod.d.ts.map +1 -0
  155. package/dist/utils/mod.js +8 -0
  156. package/dist/utils/mod.js.map +1 -0
  157. package/ln/LNURL.test.ts +87 -0
  158. package/ln/LNURL.ts +146 -0
  159. package/ln/mod.ts +4 -0
  160. package/ln/types/LNURLCallback.ts +7 -0
  161. package/ln/types/LNURLDetails.ts +19 -0
  162. package/mod.ts +16 -0
  163. package/package.json +23 -0
  164. package/test/ErrorRelay.test.ts +19 -0
  165. package/test/ErrorRelay.ts +40 -0
  166. package/test/MockRelay.test.ts +20 -0
  167. package/test/MockRelay.ts +92 -0
  168. package/test/TestRelayServer.ts +156 -0
  169. package/test/mod.ts +28 -0
  170. package/tsconfig.json +14 -0
  171. package/uploaders/BlossomUploader.test.ts +26 -0
  172. package/uploaders/BlossomUploader.ts +98 -0
  173. package/uploaders/NostrBuildUploader.test.ts +22 -0
  174. package/uploaders/NostrBuildUploader.ts +89 -0
  175. package/uploaders/mod.ts +2 -0
  176. package/utils/CircularSet.test.ts +15 -0
  177. package/utils/CircularSet.ts +34 -0
  178. package/utils/Machina.test.ts +91 -0
  179. package/utils/Machina.ts +66 -0
  180. package/utils/N64.test.ts +27 -0
  181. package/utils/N64.ts +23 -0
  182. package/utils/mod.ts +2 -0
@@ -0,0 +1,189 @@
1
+ // deno-lint-ignore-file require-await
2
+
3
+ import { z } from 'zod';
4
+
5
+ import { NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NRelay } from '@nostrify/types';
6
+
7
+ import { NSchema as n } from './NSchema';
8
+
9
+ /** Options for `NConnectSigner`. */
10
+ export interface NConnectSignerOpts {
11
+ /** Relay to facilitate connection. */
12
+ relay: NRelay;
13
+ /** Remote pubkey to sign as. */
14
+ pubkey: string;
15
+ /** Local signer to sign the request events. */
16
+ signer: NostrSigner;
17
+ /** Timeout for requests. */
18
+ timeout?: number;
19
+ /** Encryption to use when encrypting local messages. Decryption is automatic. */
20
+ encryption?: 'nip04' | 'nip44';
21
+ }
22
+
23
+ /** [NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) remote signer through a relay. */
24
+ export class NConnectSigner implements NostrSigner {
25
+ private relay: NRelay;
26
+ private pubkey: string;
27
+ private signer: NostrSigner;
28
+ private timeout?: number;
29
+ private encryption: 'nip04' | 'nip44';
30
+
31
+ constructor(
32
+ { relay, pubkey, signer, timeout, encryption = 'nip44' }: NConnectSignerOpts,
33
+ ) {
34
+ this.relay = relay;
35
+ this.pubkey = pubkey;
36
+ this.signer = signer;
37
+ this.timeout = timeout;
38
+ this.encryption = encryption;
39
+ }
40
+
41
+ async getPublicKey(): Promise<string> {
42
+ return this.cmd('get_public_key', []);
43
+ }
44
+
45
+ async signEvent(
46
+ event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>,
47
+ ): Promise<NostrEvent> {
48
+ const result = await this.cmd('sign_event', [JSON.stringify(event)]);
49
+ return n.json().pipe(n.event()).parse(result);
50
+ }
51
+
52
+ async getRelays(): Promise<
53
+ Record<string, { read: boolean; write: boolean }>
54
+ > {
55
+ const result = await this.cmd('get_relays', []);
56
+
57
+ // @ts-expect-error This should be fine.
58
+ return n
59
+ .json()
60
+ .pipe(
61
+ z.record(
62
+ z.string(),
63
+ z.object({ read: z.boolean(), write: z.boolean() }),
64
+ ),
65
+ )
66
+ .parse(result);
67
+ }
68
+
69
+ readonly nip04 = {
70
+ encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
71
+ return this.cmd('nip04_encrypt', [pubkey, plaintext]);
72
+ },
73
+
74
+ decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
75
+ return this.cmd('nip04_decrypt', [pubkey, ciphertext]);
76
+ },
77
+ };
78
+
79
+ readonly nip44 = {
80
+ encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
81
+ return this.cmd('nip44_encrypt', [pubkey, plaintext]);
82
+ },
83
+
84
+ decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
85
+ return this.cmd('nip44_decrypt', [pubkey, ciphertext]);
86
+ },
87
+ };
88
+
89
+ /** Send a `connect` command to the relay. It should respond with `ack`. */
90
+ async connect(secret?: string): Promise<string> {
91
+ const params: string[] = [this.pubkey];
92
+
93
+ if (secret) {
94
+ params.push(secret);
95
+ }
96
+
97
+ return this.cmd('connect', params);
98
+ }
99
+
100
+ /** Send a `ping` command to the signer. It should respond with `pong`. */
101
+ async ping(): Promise<string> {
102
+ return this.cmd('ping', []);
103
+ }
104
+
105
+ /** High-level RPC method. Returns the string result, or throws on error. */
106
+ private async cmd(method: string, params: string[]): Promise<string> {
107
+ const signal = typeof this.timeout === 'number' ? AbortSignal.timeout(this.timeout) : undefined;
108
+
109
+ const { result, error } = await this.send(
110
+ { id: crypto.randomUUID(), method, params },
111
+ { signal },
112
+ );
113
+
114
+ if (error) {
115
+ throw new Error(error);
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ /** Low-level send method. Deals directly with connect request/response. */
122
+ private async send(
123
+ request: NostrConnectRequest,
124
+ opts: { signal?: AbortSignal } = {},
125
+ ): Promise<NostrConnectResponse> {
126
+ const { signal } = opts;
127
+
128
+ const event = await this.signer.signEvent({
129
+ kind: 24133,
130
+ content: await this.encrypt(this.pubkey, JSON.stringify(request)),
131
+ created_at: Math.floor(Date.now() / 1000),
132
+ tags: [['p', this.pubkey]],
133
+ });
134
+
135
+ const local = await this.signer.getPublicKey();
136
+
137
+ const req = this.relay.req(
138
+ [{ kinds: [24133], authors: [this.pubkey], '#p': [local] }],
139
+ { signal },
140
+ );
141
+
142
+ // Ensure the REQ is opened before sending the EVENT
143
+ const promise = new Promise<NostrConnectResponse>((resolve, reject) => {
144
+ (async () => {
145
+ try {
146
+ for await (const msg of req) {
147
+ if (msg[0] === 'CLOSED') throw new Error('Subscription closed');
148
+ if (msg[0] === 'EVENT') {
149
+ const event = msg[2];
150
+ const decrypted = await this.decrypt(this.pubkey, event.content);
151
+ const response = n.json().pipe(n.connectResponse()).parse(
152
+ decrypted,
153
+ );
154
+ if (response.id === request.id) {
155
+ resolve(response);
156
+ return;
157
+ }
158
+ }
159
+ }
160
+ } catch (error) {
161
+ reject(error);
162
+ }
163
+ })();
164
+ });
165
+
166
+ await this.relay.event(event, { signal });
167
+ return promise;
168
+ }
169
+
170
+ /** Local encrypt depending on settings. */
171
+ private async encrypt(pubkey: string, plaintext: string): Promise<string> {
172
+ switch (this.encryption) {
173
+ case 'nip04':
174
+ return this.signer.nip04!.encrypt(pubkey, plaintext);
175
+ case 'nip44':
176
+ return this.signer.nip44!.encrypt(pubkey, plaintext);
177
+ }
178
+ }
179
+
180
+ /** Local decrypt depending on settings. */
181
+ private async decrypt(pubkey: string, ciphertext: string): Promise<string> {
182
+ switch (this.encryption) {
183
+ case 'nip04':
184
+ return this.signer.nip04!.decrypt(pubkey, ciphertext);
185
+ case 'nip44':
186
+ return this.signer.nip44!.decrypt(pubkey, ciphertext);
187
+ }
188
+ }
189
+ }
package/NIP05.test.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { assertEquals, assertRejects } from '@std/assert';
2
+ import { returnsNext, stub } from '@std/testing/mock';
3
+
4
+ import { NIP05 } from './NIP05.ts';
5
+
6
+ Deno.test('NIP05.lookup', async () => {
7
+ const { default: nostrJson } = await import('../../fixtures/nostr.json', { with: { type: 'json' } });
8
+
9
+ const fetch = stub(
10
+ globalThis,
11
+ 'fetch',
12
+ returnsNext([
13
+ Promise.resolve(new Response(JSON.stringify(nostrJson))),
14
+ ]),
15
+ );
16
+
17
+ const result = await NIP05.lookup('alex_at_gleasonator.com@mostr.pub', { fetch });
18
+
19
+ const expected = {
20
+ pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
21
+ relays: ['wss://relay.mostr.pub'],
22
+ };
23
+
24
+ assertEquals(result, expected);
25
+ fetch.restore();
26
+ });
27
+
28
+ // https://github.com/nostrability/nostrability/issues/143#issuecomment-2565772246
29
+ Deno.test('NIP05.lookup with invalid values but valid profile pointer', async () => {
30
+ const { default: nostrJson } = await import('../../fixtures/lncal.json', { with: { type: 'json' } });
31
+
32
+ const fetch = stub(
33
+ globalThis,
34
+ 'fetch',
35
+ returnsNext([
36
+ Promise.resolve(new Response(JSON.stringify(nostrJson))),
37
+ ]),
38
+ );
39
+
40
+ const result = await NIP05.lookup('elsat@lncal.com', { fetch });
41
+
42
+ const expected = {
43
+ pubkey: '17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4',
44
+ relays: undefined,
45
+ };
46
+
47
+ assertEquals(result, expected);
48
+ fetch.restore();
49
+ });
50
+
51
+ Deno.test('NIP05.lookup with invalid document', () => {
52
+ const fetch = stub(
53
+ globalThis,
54
+ 'fetch',
55
+ returnsNext([
56
+ Promise.resolve(new Response(JSON.stringify({ names: 'yolo' }))),
57
+ Promise.resolve(new Response(JSON.stringify({}))),
58
+ Promise.resolve(new Response(JSON.stringify([]))),
59
+ ]),
60
+ );
61
+
62
+ assertRejects(() => NIP05.lookup('alex@gleasonator.dev', { fetch }));
63
+ assertRejects(() => NIP05.lookup('alex@gleasonator.dev', { fetch }));
64
+ assertRejects(() => NIP05.lookup('alex@gleasonator.dev', { fetch }));
65
+
66
+ fetch.restore();
67
+ });
package/NIP05.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { NProfilePointer } from '@nostrify/types';
2
+
3
+ import { NSchema as n, z } from './NSchema';
4
+
5
+ interface LookupOpts {
6
+ fetch?: typeof fetch;
7
+ signal?: AbortSignal;
8
+ }
9
+
10
+ export class NIP05 {
11
+ /** NIP-05 value regex. */
12
+ static regex(): RegExp {
13
+ return /^(?:([\w.+-]+)@)?([\w.-]+)$/;
14
+ }
15
+
16
+ /** Nostr pubkey with relays object. */
17
+ private static profilePointerSchema(): z.ZodType<NProfilePointer> {
18
+ // @ts-expect-error This should be fine.
19
+ return z.object({
20
+ pubkey: n.id(),
21
+ relays: n.relayUrl().array().optional(),
22
+ });
23
+ }
24
+
25
+ /** Resolve NIP-05 name to a profile pointer. */
26
+ static async lookup(
27
+ nip05: string,
28
+ opts?: LookupOpts,
29
+ ): Promise<NProfilePointer> {
30
+ const { fetch = globalThis.fetch.bind(globalThis), signal } = opts ?? {};
31
+
32
+ const match = nip05.match(NIP05.regex());
33
+ if (!match) throw new Error(`NIP-05: invalid name ${nip05}`);
34
+
35
+ const [_, name = '_', domain] = match;
36
+
37
+ const url = new URL('/.well-known/nostr.json', `https://${domain}/`);
38
+ url.searchParams.set('name', name);
39
+
40
+ const response = await fetch(url, { signal });
41
+ const json = await response.json();
42
+
43
+ try {
44
+ const pubkey = json.names[name];
45
+ const relays = json.relays?.[pubkey];
46
+
47
+ return NIP05.profilePointerSchema().parse({ pubkey, relays });
48
+ } catch {
49
+ throw new Error(`NIP-05: no match for ${nip05}`);
50
+ }
51
+ }
52
+ }
package/NIP50.test.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { assertEquals } from '@std/assert';
2
+
3
+ import { NIP50 } from './NIP50.ts';
4
+
5
+ Deno.test('NIP50.parseInput', () => {
6
+ assertEquals(NIP50.parseInput(''), []);
7
+ assertEquals(NIP50.parseInput(' '), []);
8
+ assertEquals(NIP50.parseInput('hello'), ['hello']);
9
+ assertEquals(NIP50.parseInput('hello world'), ['hello', 'world']);
10
+ assertEquals(NIP50.parseInput('hello "world"'), ['hello', 'world']);
11
+
12
+ assertEquals(
13
+ NIP50.parseInput('hello "world" "hello world"'),
14
+ ['hello', 'world', 'hello world'],
15
+ );
16
+
17
+ assertEquals(
18
+ NIP50.parseInput('domain:gleasonator.dev'),
19
+ [{ key: 'domain', value: 'gleasonator.dev' }],
20
+ );
21
+
22
+ assertEquals(
23
+ NIP50.parseInput('domain: yolo'),
24
+ ['domain:', 'yolo'],
25
+ );
26
+
27
+ assertEquals(
28
+ NIP50.parseInput('domain:localhost:8000'),
29
+ [{ key: 'domain', value: 'localhost:8000' }],
30
+ );
31
+
32
+ assertEquals(
33
+ NIP50.parseInput('name:John "New York" age:30 hobbies:programming'),
34
+ [
35
+ { key: 'name', value: 'John' },
36
+ 'New York',
37
+ { key: 'age', value: '30' },
38
+ { key: 'hobbies', value: 'programming' },
39
+ ],
40
+ );
41
+ });
42
+
43
+ Deno.test('NIP50.parseInput with negated token', () => {
44
+ assertEquals(
45
+ NIP50.parseInput('-reply:true'),
46
+ [{ key: '-reply', value: 'true' }],
47
+ );
48
+
49
+ assertEquals(
50
+ NIP50.parseInput('hello -reply:true'),
51
+ ['hello', { key: '-reply', value: 'true' }],
52
+ );
53
+
54
+ assertEquals(
55
+ NIP50.parseInput('-media:true -reply:true'),
56
+ [{ key: '-media', value: 'true' }, { key: '-reply', value: 'true' }],
57
+ );
58
+ });
package/NIP50.ts ADDED
@@ -0,0 +1,24 @@
1
+ type SearchToken = string | { key: string; value: string };
2
+
3
+ /** [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md) search functionality. */
4
+ export class NIP50 {
5
+ static parseInput(input: string): SearchToken[] {
6
+ const regex = /(\B-\w+:[^\s"]+)|(\b\w+:[^\s"]+)|(".*?")|(\S+)/g;
7
+
8
+ const tokens: SearchToken[] = [];
9
+ let match: RegExpExecArray | null;
10
+
11
+ while ((match = regex.exec(input)) !== null) {
12
+ if (match[1] || match[2]) {
13
+ const [key, ...values] = (match[1] || match[2]).split(':');
14
+ tokens.push({ key, value: values.join(':') });
15
+ } else if (match[3]) {
16
+ tokens.push(match[3].replace(/"/g, ''));
17
+ } else if (match[4]) {
18
+ tokens.push(match[4]);
19
+ }
20
+ }
21
+
22
+ return tokens;
23
+ }
24
+ }
package/NIP98.test.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { assertEquals, assertRejects } from '@std/assert';
2
+ import { generateSecretKey } from 'nostr-tools';
3
+ import { ZodError } from 'zod';
4
+
5
+ import { NIP98 } from './NIP98.ts';
6
+ import { NSecSigner } from './NSecSigner.ts';
7
+ import { N64 } from './utils/mod.ts';
8
+
9
+ Deno.test('NIP98.template', async () => {
10
+ const request = new Request('https://example.com');
11
+ const event = await NIP98.template(request);
12
+
13
+ assertEquals(event.kind, 27235);
14
+ assertEquals(event.tags, [
15
+ ['method', 'GET'],
16
+ ['u', 'https://example.com/'],
17
+ ]);
18
+ });
19
+
20
+ Deno.test('NIP98.template with payload', async () => {
21
+ const request = new Request('https://example.com', {
22
+ method: 'POST',
23
+ body: 'Hello, world!',
24
+ });
25
+ const event = await NIP98.template(request);
26
+
27
+ assertEquals(event.kind, 27235);
28
+ assertEquals(event.tags, [
29
+ ['method', 'POST'],
30
+ ['u', 'https://example.com/'],
31
+ ['payload', '315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3'],
32
+ ]);
33
+ });
34
+
35
+ Deno.test('NIP98.verify', async () => {
36
+ const signer = new NSecSigner(generateSecretKey());
37
+ const request = new Request('https://example.com');
38
+
39
+ const t = await NIP98.template(request);
40
+ const event = await signer.signEvent(t);
41
+
42
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
43
+
44
+ const proof = await NIP98.verify(request);
45
+
46
+ assertEquals(proof, event);
47
+ assertEquals(proof.pubkey, await signer.getPublicKey());
48
+ });
49
+
50
+ Deno.test('NIP98.verify fails with missing header', async () => {
51
+ const request = new Request('https://example.com');
52
+
53
+ await assertRejects(
54
+ () => NIP98.verify(request),
55
+ Error,
56
+ 'Missing Nostr authorization header',
57
+ );
58
+ });
59
+
60
+ Deno.test('NIP98.verify fails with missing token', async () => {
61
+ const request = new Request('https://example.com');
62
+ request.headers.set('authorization', 'Nostr');
63
+
64
+ await assertRejects(
65
+ () => NIP98.verify(request),
66
+ Error,
67
+ 'Missing Nostr authorization token',
68
+ );
69
+ });
70
+
71
+ Deno.test('NIP98.verify fails with invalid token', async () => {
72
+ const request = new Request('https://example.com');
73
+ request.headers.set('authorization', 'Nostr invalid');
74
+
75
+ await assertRejects(
76
+ () => NIP98.verify(request),
77
+ ZodError,
78
+ );
79
+ });
80
+
81
+ Deno.test('NIP98.verify fails with invalid event', async () => {
82
+ const signer = new NSecSigner(generateSecretKey());
83
+ const request = new Request('https://example.com');
84
+
85
+ const t = await NIP98.template(request);
86
+ const event = await signer.signEvent(t);
87
+
88
+ event.sig = 'invalid';
89
+
90
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
91
+
92
+ await assertRejects(
93
+ () => NIP98.verify(request),
94
+ Error,
95
+ 'Event signature is invalid',
96
+ );
97
+ });
98
+
99
+ Deno.test('NIP98.verify fails with wrong event kind', async () => {
100
+ const signer = new NSecSigner(generateSecretKey());
101
+ const request = new Request('https://example.com');
102
+
103
+ const t = await NIP98.template(request);
104
+ const event = await signer.signEvent({ ...t, kind: 1 });
105
+
106
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
107
+
108
+ await assertRejects(
109
+ () => NIP98.verify(request),
110
+ Error,
111
+ 'Event must be kind 27235',
112
+ );
113
+ });
114
+
115
+ Deno.test('NIP98.verify fails with wrong request URL', async () => {
116
+ const signer = new NSecSigner(generateSecretKey());
117
+ const request = new Request('https://example.com');
118
+
119
+ const t = await NIP98.template(request);
120
+ const event = await signer.signEvent({ ...t, tags: [['u', 'https://example.org/']] });
121
+
122
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
123
+
124
+ await assertRejects(
125
+ () => NIP98.verify(request),
126
+ Error,
127
+ 'Event URL does not match request URL',
128
+ );
129
+ });
130
+
131
+ Deno.test('NIP98.verify fails with wrong request method', async () => {
132
+ const signer = new NSecSigner(generateSecretKey());
133
+ const request = new Request('https://example.com');
134
+
135
+ const t = await NIP98.template(request);
136
+ const event = await signer.signEvent({ ...t, tags: [['u', 'https://example.com/'], ['method', 'POST']] });
137
+
138
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
139
+
140
+ await assertRejects(
141
+ () => NIP98.verify(request),
142
+ Error,
143
+ 'Event method does not match HTTP request method',
144
+ );
145
+ });
146
+
147
+ Deno.test('NIP98.verify fails with expired event', async () => {
148
+ const signer = new NSecSigner(generateSecretKey());
149
+ const request = new Request('https://example.com');
150
+
151
+ const t = await NIP98.template(request);
152
+ const event = await signer.signEvent({ ...t, created_at: 0 });
153
+
154
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
155
+
156
+ await assertRejects(
157
+ () => NIP98.verify(request),
158
+ Error,
159
+ 'Event expired',
160
+ );
161
+ });
162
+
163
+ Deno.test('NIP98.verify fails with invalid payload', async () => {
164
+ const signer = new NSecSigner(generateSecretKey());
165
+ const request = new Request('https://example.com', {
166
+ method: 'POST',
167
+ body: 'Hello, world!',
168
+ });
169
+
170
+ const t = await NIP98.template(request);
171
+ const tags = t.tags.filter(([name]) => name !== 'payload');
172
+ const event = await signer.signEvent({ ...t, tags: [...tags, ['payload', 'invalid']] });
173
+
174
+ request.headers.set('authorization', `Nostr ${N64.encodeEvent(event)}`);
175
+
176
+ await assertRejects(
177
+ () => NIP98.verify(request),
178
+ Error,
179
+ 'Event payload does not match request body',
180
+ );
181
+ });
package/NIP98.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { NostrEvent } from '@nostrify/types';
2
+ import { encodeHex } from '@std/encoding/hex';
3
+ import { verifyEvent as _verifyEvent } from 'nostr-tools';
4
+
5
+ import { N64 } from './utils/N64';
6
+
7
+ /** [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP auth. */
8
+ export class NIP98 {
9
+ /** Generate an auth event template from a Request. */
10
+ static async template(
11
+ request: Request,
12
+ opts?: { validatePayload?: boolean },
13
+ ): Promise<Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>> {
14
+ const {
15
+ validatePayload = ['POST', 'PUT', 'PATCH'].includes(request.method),
16
+ } = opts ?? {};
17
+ const { method, url } = request;
18
+
19
+ const tags = [
20
+ ['method', method],
21
+ ['u', url],
22
+ ];
23
+
24
+ if (validatePayload) {
25
+ const buffer = await request.clone().arrayBuffer();
26
+ const digest = await crypto.subtle.digest('SHA-256', buffer);
27
+
28
+ tags.push(['payload', encodeHex(digest)]);
29
+ }
30
+
31
+ return {
32
+ kind: 27235,
33
+ content: '',
34
+ tags,
35
+ created_at: Math.floor(Date.now() / 1000),
36
+ };
37
+ }
38
+
39
+ /** Compare the auth event with the request, throwing a human-readable error if validation fails. */
40
+ static async verify(
41
+ request: Request,
42
+ opts?: {
43
+ maxAge?: number;
44
+ validatePayload?: boolean;
45
+ verifyEvent?: (event: NostrEvent) => boolean;
46
+ },
47
+ ): Promise<NostrEvent> {
48
+ const {
49
+ maxAge = 60_000,
50
+ validatePayload = ['POST', 'PUT', 'PATCH'].includes(request.method),
51
+ verifyEvent = _verifyEvent,
52
+ } = opts ?? {};
53
+
54
+ const header = request.headers.get('authorization');
55
+ if (!header) {
56
+ throw new Error('Missing Nostr authorization header');
57
+ }
58
+
59
+ const token = header.match(/^Nostr (.+)$/)?.[1];
60
+ if (!token) {
61
+ throw new Error('Missing Nostr authorization token');
62
+ }
63
+
64
+ const event = N64.decodeEvent(token);
65
+ if (!verifyEvent(event)) {
66
+ throw new Error('Event signature is invalid');
67
+ }
68
+
69
+ const age = Date.now() - (event.created_at * 1_000);
70
+ const u = event.tags.find(([name]) => name === 'u')?.[1];
71
+ const method = event.tags.find(([name]) => name === 'method')?.[1];
72
+ const payload = event.tags.find(([name]) => name === 'payload')?.[1];
73
+
74
+ if (event.kind !== 27235) {
75
+ throw new Error('Event must be kind 27235');
76
+ }
77
+ if (u !== request.url) {
78
+ throw new Error('Event URL does not match request URL');
79
+ }
80
+ if (method !== request.method) {
81
+ throw new Error('Event method does not match HTTP request method');
82
+ }
83
+ if (age >= maxAge) {
84
+ throw new Error('Event expired');
85
+ }
86
+ if (validatePayload && payload !== undefined) {
87
+ const buffer = await request.clone().arrayBuffer();
88
+ const digest = await crypto.subtle.digest('SHA-256', buffer);
89
+
90
+ if (encodeHex(digest) !== payload) {
91
+ throw new Error('Event payload does not match request body');
92
+ }
93
+ }
94
+
95
+ return event;
96
+ }
97
+ }