atagen

vineyard

public
Star0
Watch0

Commit 25d14e49ab68

tarball

atagen <boss@atagen.co>2026-09-24 11:13 UTC

18 files changed+5952-0
init
Added file: .gitignore+6 additions
@@ -0,0 +1,6 @@
1+target/
2+/result
3+*.sqlite
4+*.sqlite-shm
5+*.sqlite-wal
6+*.sqlite.lock
Added file: Cargo.lock+2047 additions
@@ -0,0 +1,2047 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "aead"
7+version = "0.5.2"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
10+dependencies = [
11+ "crypto-common",
12+ "generic-array",
13+]
14+
15+[[package]]
16+name = "aho-corasick"
17+version = "1.1.5"
18+source = "registry+https://github.com/rust-lang/crates.io-index"
19+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
20+dependencies = [
21+ "memchr",
22+]
23+
24+[[package]]
25+name = "anstream"
26+version = "1.0.0"
27+source = "registry+https://github.com/rust-lang/crates.io-index"
28+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
29+dependencies = [
30+ "anstyle",
31+ "anstyle-parse",
32+ "anstyle-query",
33+ "anstyle-wincon",
34+ "colorchoice",
35+ "is_terminal_polyfill",
36+ "utf8parse",
37+]
38+
39+[[package]]
40+name = "anstyle"
41+version = "1.0.14"
42+source = "registry+https://github.com/rust-lang/crates.io-index"
43+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
44+
45+[[package]]
46+name = "anstyle-parse"
47+version = "1.0.0"
48+source = "registry+https://github.com/rust-lang/crates.io-index"
49+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
50+dependencies = [
51+ "utf8parse",
52+]
53+
54+[[package]]
55+name = "anstyle-query"
56+version = "1.1.5"
57+source = "registry+https://github.com/rust-lang/crates.io-index"
58+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
59+dependencies = [
60+ "windows-sys 0.61.2",
61+]
62+
63+[[package]]
64+name = "anstyle-wincon"
65+version = "3.0.11"
66+source = "registry+https://github.com/rust-lang/crates.io-index"
67+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
68+dependencies = [
69+ "anstyle",
70+ "once_cell_polyfill",
71+ "windows-sys 0.61.2",
72+]
73+
74+[[package]]
75+name = "anyhow"
76+version = "1.0.104"
77+source = "registry+https://github.com/rust-lang/crates.io-index"
78+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
79+
80+[[package]]
81+name = "arrayvec"
82+version = "0.7.8"
83+source = "registry+https://github.com/rust-lang/crates.io-index"
84+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
85+
86+[[package]]
87+name = "atomic-waker"
88+version = "1.1.2"
89+source = "registry+https://github.com/rust-lang/crates.io-index"
90+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
91+
92+[[package]]
93+name = "base64"
94+version = "0.22.1"
95+source = "registry+https://github.com/rust-lang/crates.io-index"
96+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
97+
98+[[package]]
99+name = "bech32"
100+version = "0.12.0"
101+source = "registry+https://github.com/rust-lang/crates.io-index"
102+checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8"
103+
104+[[package]]
105+name = "bip39"
106+version = "2.2.2"
107+source = "registry+https://github.com/rust-lang/crates.io-index"
108+checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
109+dependencies = [
110+ "bitcoin_hashes 0.14.101",
111+ "serde",
112+ "unicode-normalization",
113+]
114+
115+[[package]]
116+name = "bitcoin-consensus-encoding"
117+version = "1.2.0"
118+source = "registry+https://github.com/rust-lang/crates.io-index"
119+checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec"
120+dependencies = [
121+ "bitcoin-internals",
122+ "hex-conservative 1.3.0",
123+ "serde",
124+]
125+
126+[[package]]
127+name = "bitcoin-internals"
128+version = "0.6.0"
129+source = "registry+https://github.com/rust-lang/crates.io-index"
130+checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5"
131+
132+[[package]]
133+name = "bitcoin-io"
134+version = "0.1.101"
135+source = "registry+https://github.com/rust-lang/crates.io-index"
136+checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78"
137+dependencies = [
138+ "bitcoin-consensus-encoding",
139+]
140+
141+[[package]]
142+name = "bitcoin_hashes"
143+version = "0.14.101"
144+source = "registry+https://github.com/rust-lang/crates.io-index"
145+checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
146+dependencies = [
147+ "bitcoin-io",
148+ "hex-conservative 0.2.3",
149+]
150+
151+[[package]]
152+name = "bitcoin_hashes"
153+version = "1.2.0"
154+source = "registry+https://github.com/rust-lang/crates.io-index"
155+checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e"
156+dependencies = [
157+ "bitcoin-consensus-encoding",
158+ "bitcoin-internals",
159+ "hex-conservative 1.3.0",
160+ "serde",
161+]
162+
163+[[package]]
164+name = "bitflags"
165+version = "2.13.2"
166+source = "registry+https://github.com/rust-lang/crates.io-index"
167+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
168+
169+[[package]]
170+name = "block-buffer"
171+version = "0.10.4"
172+source = "registry+https://github.com/rust-lang/crates.io-index"
173+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
174+dependencies = [
175+ "generic-array",
176+]
177+
178+[[package]]
179+name = "block-padding"
180+version = "0.3.3"
181+source = "registry+https://github.com/rust-lang/crates.io-index"
182+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
183+dependencies = [
184+ "generic-array",
185+]
186+
187+[[package]]
188+name = "bumpalo"
189+version = "3.20.3"
190+source = "registry+https://github.com/rust-lang/crates.io-index"
191+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
192+
193+[[package]]
194+name = "byteorder"
195+version = "1.5.0"
196+source = "registry+https://github.com/rust-lang/crates.io-index"
197+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
198+
199+[[package]]
200+name = "bytes"
201+version = "1.12.1"
202+source = "registry+https://github.com/rust-lang/crates.io-index"
203+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
204+
205+[[package]]
206+name = "cbc"
207+version = "0.1.2"
208+source = "registry+https://github.com/rust-lang/crates.io-index"
209+checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
210+dependencies = [
211+ "cipher",
212+]
213+
214+[[package]]
215+name = "cc"
216+version = "1.4.6"
217+source = "registry+https://github.com/rust-lang/crates.io-index"
218+checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
219+dependencies = [
220+ "find-msvc-tools",
221+ "shlex",
222+]
223+
224+[[package]]
225+name = "cfg-if"
226+version = "1.0.5"
227+source = "registry+https://github.com/rust-lang/crates.io-index"
228+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
229+
230+[[package]]
231+name = "chacha20"
232+version = "0.9.1"
233+source = "registry+https://github.com/rust-lang/crates.io-index"
234+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
235+dependencies = [
236+ "cfg-if",
237+ "cipher",
238+ "cpufeatures",
239+]
240+
241+[[package]]
242+name = "chacha20poly1305"
243+version = "0.10.1"
244+source = "registry+https://github.com/rust-lang/crates.io-index"
245+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
246+dependencies = [
247+ "aead",
248+ "chacha20",
249+ "cipher",
250+ "poly1305",
251+ "zeroize",
252+]
253+
254+[[package]]
255+name = "cipher"
256+version = "0.4.4"
257+source = "registry+https://github.com/rust-lang/crates.io-index"
258+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
259+dependencies = [
260+ "crypto-common",
261+ "inout",
262+ "zeroize",
263+]
264+
265+[[package]]
266+name = "clap"
267+version = "4.6.7"
268+source = "registry+https://github.com/rust-lang/crates.io-index"
269+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
270+dependencies = [
271+ "clap_builder",
272+ "clap_derive",
273+]
274+
275+[[package]]
276+name = "clap_builder"
277+version = "4.6.7"
278+source = "registry+https://github.com/rust-lang/crates.io-index"
279+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
280+dependencies = [
281+ "anstream",
282+ "anstyle",
283+ "clap_lex",
284+ "strsim",
285+]
286+
287+[[package]]
288+name = "clap_derive"
289+version = "4.6.7"
290+source = "registry+https://github.com/rust-lang/crates.io-index"
291+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
292+dependencies = [
293+ "heck",
294+ "proc-macro2",
295+ "quote",
296+ "syn 3.0.6",
297+]
298+
299+[[package]]
300+name = "clap_lex"
301+version = "1.1.1"
302+source = "registry+https://github.com/rust-lang/crates.io-index"
303+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
304+
305+[[package]]
306+name = "colorchoice"
307+version = "1.0.5"
308+source = "registry+https://github.com/rust-lang/crates.io-index"
309+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
310+
311+[[package]]
312+name = "cpufeatures"
313+version = "0.2.17"
314+source = "registry+https://github.com/rust-lang/crates.io-index"
315+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
316+dependencies = [
317+ "libc",
318+]
319+
320+[[package]]
321+name = "crypto-common"
322+version = "0.1.7"
323+source = "registry+https://github.com/rust-lang/crates.io-index"
324+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
325+dependencies = [
326+ "generic-array",
327+ "typenum",
328+]
329+
330+[[package]]
331+name = "data-encoding"
332+version = "2.11.1"
333+source = "registry+https://github.com/rust-lang/crates.io-index"
334+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
335+
336+[[package]]
337+name = "digest"
338+version = "0.10.7"
339+source = "registry+https://github.com/rust-lang/crates.io-index"
340+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
341+dependencies = [
342+ "block-buffer",
343+ "crypto-common",
344+]
345+
346+[[package]]
347+name = "displaydoc"
348+version = "0.2.7"
349+source = "registry+https://github.com/rust-lang/crates.io-index"
350+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
351+dependencies = [
352+ "proc-macro2",
353+ "quote",
354+ "syn 3.0.6",
355+]
356+
357+[[package]]
358+name = "equivalent"
359+version = "1.0.2"
360+source = "registry+https://github.com/rust-lang/crates.io-index"
361+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
362+
363+[[package]]
364+name = "errno"
365+version = "0.3.14"
366+source = "registry+https://github.com/rust-lang/crates.io-index"
367+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
368+dependencies = [
369+ "libc",
370+ "windows-sys 0.61.2",
371+]
372+
373+[[package]]
374+name = "fallible-iterator"
375+version = "0.3.0"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
378+
379+[[package]]
380+name = "fallible-streaming-iterator"
381+version = "0.1.9"
382+source = "registry+https://github.com/rust-lang/crates.io-index"
383+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
384+
385+[[package]]
386+name = "faster-hex"
387+version = "0.10.0"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73"
390+dependencies = [
391+ "heapless",
392+ "serde",
393+]
394+
395+[[package]]
396+name = "find-msvc-tools"
397+version = "0.1.12"
398+source = "registry+https://github.com/rust-lang/crates.io-index"
399+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
400+
401+[[package]]
402+name = "foldhash"
403+version = "0.2.0"
404+source = "registry+https://github.com/rust-lang/crates.io-index"
405+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
406+
407+[[package]]
408+name = "form_urlencoded"
409+version = "1.2.2"
410+source = "registry+https://github.com/rust-lang/crates.io-index"
411+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
412+dependencies = [
413+ "percent-encoding",
414+]
415+
416+[[package]]
417+name = "fs2"
418+version = "0.4.3"
419+source = "registry+https://github.com/rust-lang/crates.io-index"
420+checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
421+dependencies = [
422+ "libc",
423+ "winapi",
424+]
425+
426+[[package]]
427+name = "futures-channel"
428+version = "0.3.34"
429+source = "registry+https://github.com/rust-lang/crates.io-index"
430+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
431+dependencies = [
432+ "futures-core",
433+]
434+
435+[[package]]
436+name = "futures-core"
437+version = "0.3.34"
438+source = "registry+https://github.com/rust-lang/crates.io-index"
439+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
440+
441+[[package]]
442+name = "futures-macro"
443+version = "0.3.34"
444+source = "registry+https://github.com/rust-lang/crates.io-index"
445+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
446+dependencies = [
447+ "proc-macro2",
448+ "quote",
449+ "syn 3.0.6",
450+]
451+
452+[[package]]
453+name = "futures-sink"
454+version = "0.3.34"
455+source = "registry+https://github.com/rust-lang/crates.io-index"
456+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
457+
458+[[package]]
459+name = "futures-task"
460+version = "0.3.34"
461+source = "registry+https://github.com/rust-lang/crates.io-index"
462+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
463+
464+[[package]]
465+name = "futures-util"
466+version = "0.3.34"
467+source = "registry+https://github.com/rust-lang/crates.io-index"
468+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
469+dependencies = [
470+ "futures-core",
471+ "futures-macro",
472+ "futures-sink",
473+ "futures-task",
474+ "pin-project-lite",
475+ "slab",
476+]
477+
478+[[package]]
479+name = "generic-array"
480+version = "0.14.7"
481+source = "registry+https://github.com/rust-lang/crates.io-index"
482+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
483+dependencies = [
484+ "typenum",
485+ "version_check",
486+]
487+
488+[[package]]
489+name = "getrandom"
490+version = "0.2.17"
491+source = "registry+https://github.com/rust-lang/crates.io-index"
492+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
493+dependencies = [
494+ "cfg-if",
495+ "libc",
496+ "wasi",
497+]
498+
499+[[package]]
500+name = "getrandom"
501+version = "0.3.4"
502+source = "registry+https://github.com/rust-lang/crates.io-index"
503+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
504+dependencies = [
505+ "cfg-if",
506+ "libc",
507+ "r-efi 5.3.0",
508+ "wasip2",
509+]
510+
511+[[package]]
512+name = "getrandom"
513+version = "0.4.3"
514+source = "registry+https://github.com/rust-lang/crates.io-index"
515+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
516+dependencies = [
517+ "cfg-if",
518+ "libc",
519+ "r-efi 6.0.0",
520+]
521+
522+[[package]]
523+name = "hash32"
524+version = "0.3.1"
525+source = "registry+https://github.com/rust-lang/crates.io-index"
526+checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
527+dependencies = [
528+ "byteorder",
529+]
530+
531+[[package]]
532+name = "hashbrown"
533+version = "0.16.1"
534+source = "registry+https://github.com/rust-lang/crates.io-index"
535+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
536+dependencies = [
537+ "foldhash",
538+]
539+
540+[[package]]
541+name = "hashbrown"
542+version = "0.17.1"
543+source = "registry+https://github.com/rust-lang/crates.io-index"
544+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
545+
546+[[package]]
547+name = "hashlink"
548+version = "0.11.1"
549+source = "registry+https://github.com/rust-lang/crates.io-index"
550+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
551+dependencies = [
552+ "hashbrown 0.16.1",
553+]
554+
555+[[package]]
556+name = "heapless"
557+version = "0.8.0"
558+source = "registry+https://github.com/rust-lang/crates.io-index"
559+checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
560+dependencies = [
561+ "hash32",
562+ "stable_deref_trait",
563+]
564+
565+[[package]]
566+name = "heck"
567+version = "0.5.0"
568+source = "registry+https://github.com/rust-lang/crates.io-index"
569+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
570+
571+[[package]]
572+name = "hex-conservative"
573+version = "0.2.3"
574+source = "registry+https://github.com/rust-lang/crates.io-index"
575+checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
576+dependencies = [
577+ "arrayvec",
578+]
579+
580+[[package]]
581+name = "hex-conservative"
582+version = "1.3.0"
583+source = "registry+https://github.com/rust-lang/crates.io-index"
584+checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8"
585+dependencies = [
586+ "arrayvec",
587+]
588+
589+[[package]]
590+name = "http"
591+version = "1.5.0"
592+source = "registry+https://github.com/rust-lang/crates.io-index"
593+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
594+dependencies = [
595+ "bytes",
596+ "itoa",
597+]
598+
599+[[package]]
600+name = "http-body"
601+version = "1.1.0"
602+source = "registry+https://github.com/rust-lang/crates.io-index"
603+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
604+dependencies = [
605+ "bytes",
606+ "http",
607+]
608+
609+[[package]]
610+name = "http-body-util"
611+version = "0.1.5"
612+source = "registry+https://github.com/rust-lang/crates.io-index"
613+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
614+dependencies = [
615+ "bytes",
616+ "futures-core",
617+ "http",
618+ "http-body",
619+ "pin-project-lite",
620+]
621+
622+[[package]]
623+name = "httparse"
624+version = "1.10.1"
625+source = "registry+https://github.com/rust-lang/crates.io-index"
626+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
627+
628+[[package]]
629+name = "httpdate"
630+version = "1.0.3"
631+source = "registry+https://github.com/rust-lang/crates.io-index"
632+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
633+
634+[[package]]
635+name = "hyper"
636+version = "1.11.1"
637+source = "registry+https://github.com/rust-lang/crates.io-index"
638+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
639+dependencies = [
640+ "atomic-waker",
641+ "bytes",
642+ "futures-channel",
643+ "futures-core",
644+ "http",
645+ "http-body",
646+ "httparse",
647+ "httpdate",
648+ "itoa",
649+ "pin-project-lite",
650+ "smallvec",
651+ "tokio",
652+]
653+
654+[[package]]
655+name = "hyper-util"
656+version = "0.1.20"
657+source = "registry+https://github.com/rust-lang/crates.io-index"
658+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
659+dependencies = [
660+ "bytes",
661+ "http",
662+ "http-body",
663+ "hyper",
664+ "pin-project-lite",
665+ "tokio",
666+]
667+
668+[[package]]
669+name = "icu_collections"
670+version = "2.1.1"
671+source = "registry+https://github.com/rust-lang/crates.io-index"
672+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
673+dependencies = [
674+ "displaydoc",
675+ "potential_utf",
676+ "yoke",
677+ "zerofrom",
678+ "zerovec",
679+]
680+
681+[[package]]
682+name = "icu_locale_core"
683+version = "2.1.1"
684+source = "registry+https://github.com/rust-lang/crates.io-index"
685+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
686+dependencies = [
687+ "displaydoc",
688+ "litemap",
689+ "tinystr",
690+ "writeable",
691+ "zerovec",
692+]
693+
694+[[package]]
695+name = "icu_normalizer"
696+version = "2.1.1"
697+source = "registry+https://github.com/rust-lang/crates.io-index"
698+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
699+dependencies = [
700+ "icu_collections",
701+ "icu_normalizer_data",
702+ "icu_properties",
703+ "icu_provider",
704+ "smallvec",
705+ "zerovec",
706+]
707+
708+[[package]]
709+name = "icu_normalizer_data"
710+version = "2.1.1"
711+source = "registry+https://github.com/rust-lang/crates.io-index"
712+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
713+
714+[[package]]
715+name = "icu_properties"
716+version = "2.1.2"
717+source = "registry+https://github.com/rust-lang/crates.io-index"
718+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
719+dependencies = [
720+ "icu_collections",
721+ "icu_locale_core",
722+ "icu_properties_data",
723+ "icu_provider",
724+ "zerotrie",
725+ "zerovec",
726+]
727+
728+[[package]]
729+name = "icu_properties_data"
730+version = "2.1.2"
731+source = "registry+https://github.com/rust-lang/crates.io-index"
732+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
733+
734+[[package]]
735+name = "icu_provider"
736+version = "2.1.1"
737+source = "registry+https://github.com/rust-lang/crates.io-index"
738+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
739+dependencies = [
740+ "displaydoc",
741+ "icu_locale_core",
742+ "writeable",
743+ "yoke",
744+ "zerofrom",
745+ "zerotrie",
746+ "zerovec",
747+]
748+
749+[[package]]
750+name = "idna"
751+version = "1.1.0"
752+source = "registry+https://github.com/rust-lang/crates.io-index"
753+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
754+dependencies = [
755+ "idna_adapter",
756+ "smallvec",
757+ "utf8_iter",
758+]
759+
760+[[package]]
761+name = "idna_adapter"
762+version = "1.2.1"
763+source = "registry+https://github.com/rust-lang/crates.io-index"
764+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
765+dependencies = [
766+ "icu_normalizer",
767+ "icu_properties",
768+]
769+
770+[[package]]
771+name = "indexmap"
772+version = "2.14.2"
773+source = "registry+https://github.com/rust-lang/crates.io-index"
774+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
775+dependencies = [
776+ "equivalent",
777+ "hashbrown 0.17.1",
778+]
779+
780+[[package]]
781+name = "inout"
782+version = "0.1.4"
783+source = "registry+https://github.com/rust-lang/crates.io-index"
784+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
785+dependencies = [
786+ "block-padding",
787+ "generic-array",
788+]
789+
790+[[package]]
791+name = "is_terminal_polyfill"
792+version = "1.70.2"
793+source = "registry+https://github.com/rust-lang/crates.io-index"
794+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
795+
796+[[package]]
797+name = "itoa"
798+version = "1.0.18"
799+source = "registry+https://github.com/rust-lang/crates.io-index"
800+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
801+
802+[[package]]
803+name = "js-sys"
804+version = "0.3.105"
805+source = "registry+https://github.com/rust-lang/crates.io-index"
806+checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
807+dependencies = [
808+ "cfg-if",
809+ "wasm-bindgen",
810+]
811+
812+[[package]]
813+name = "lazy_static"
814+version = "1.5.0"
815+source = "registry+https://github.com/rust-lang/crates.io-index"
816+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
817+
818+[[package]]
819+name = "libc"
820+version = "0.2.189"
821+source = "registry+https://github.com/rust-lang/crates.io-index"
822+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
823+
824+[[package]]
825+name = "libsqlite3-sys"
826+version = "0.36.0"
827+source = "registry+https://github.com/rust-lang/crates.io-index"
828+checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
829+dependencies = [
830+ "cc",
831+ "pkg-config",
832+ "vcpkg",
833+]
834+
835+[[package]]
836+name = "litemap"
837+version = "0.8.3"
838+source = "registry+https://github.com/rust-lang/crates.io-index"
839+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
840+
841+[[package]]
842+name = "log"
843+version = "0.4.34"
844+source = "registry+https://github.com/rust-lang/crates.io-index"
845+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
846+
847+[[package]]
848+name = "matchers"
849+version = "0.2.0"
850+source = "registry+https://github.com/rust-lang/crates.io-index"
851+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
852+dependencies = [
853+ "regex-automata",
854+]
855+
856+[[package]]
857+name = "memchr"
858+version = "2.8.3"
859+source = "registry+https://github.com/rust-lang/crates.io-index"
860+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
861+
862+[[package]]
863+name = "mio"
864+version = "1.2.3"
865+source = "registry+https://github.com/rust-lang/crates.io-index"
866+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
867+dependencies = [
868+ "libc",
869+ "wasi",
870+ "windows-sys 0.61.2",
871+]
872+
873+[[package]]
874+name = "nostr"
875+version = "0.45.5"
876+source = "registry+https://github.com/rust-lang/crates.io-index"
877+checksum = "38ca5c25a6df8d4d78fdf39b42792438f6ce22b4809ccbfdb27582ed9ca45407"
878+dependencies = [
879+ "base64",
880+ "bech32",
881+ "bip39",
882+ "bitcoin_hashes 1.2.0",
883+ "cbc",
884+ "chacha20",
885+ "chacha20poly1305",
886+ "faster-hex",
887+ "opaquerr",
888+ "rand 0.10.2",
889+ "secp256k1",
890+ "serde",
891+ "serde_json",
892+ "unicode-normalization",
893+ "universal-time",
894+ "url",
895+ "zeroize",
896+]
897+
898+[[package]]
899+name = "nostr-database"
900+version = "0.45.2"
901+source = "registry+https://github.com/rust-lang/crates.io-index"
902+checksum = "309950383c9854ca413d195705400e78b1376aedc48f3256754a86c609c047e8"
903+dependencies = [
904+ "nostr",
905+ "opaquerr",
906+]
907+
908+[[package]]
909+name = "nu-ansi-term"
910+version = "0.50.3"
911+source = "registry+https://github.com/rust-lang/crates.io-index"
912+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
913+dependencies = [
914+ "windows-sys 0.61.2",
915+]
916+
917+[[package]]
918+name = "once_cell"
919+version = "1.21.4"
920+source = "registry+https://github.com/rust-lang/crates.io-index"
921+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
922+
923+[[package]]
924+name = "once_cell_polyfill"
925+version = "1.70.2"
926+source = "registry+https://github.com/rust-lang/crates.io-index"
927+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
928+
929+[[package]]
930+name = "opaque-debug"
931+version = "0.3.1"
932+source = "registry+https://github.com/rust-lang/crates.io-index"
933+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
934+
935+[[package]]
936+name = "opaquerr"
937+version = "0.2.0"
938+source = "registry+https://github.com/rust-lang/crates.io-index"
939+checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9"
940+
941+[[package]]
942+name = "percent-encoding"
943+version = "2.3.2"
944+source = "registry+https://github.com/rust-lang/crates.io-index"
945+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
946+
947+[[package]]
948+name = "pin-project-lite"
949+version = "0.2.17"
950+source = "registry+https://github.com/rust-lang/crates.io-index"
951+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
952+
953+[[package]]
954+name = "pkg-config"
955+version = "0.3.34"
956+source = "registry+https://github.com/rust-lang/crates.io-index"
957+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
958+
959+[[package]]
960+name = "poly1305"
961+version = "0.8.0"
962+source = "registry+https://github.com/rust-lang/crates.io-index"
963+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
964+dependencies = [
965+ "cpufeatures",
966+ "opaque-debug",
967+ "universal-hash",
968+]
969+
970+[[package]]
971+name = "potential_utf"
972+version = "0.1.6"
973+source = "registry+https://github.com/rust-lang/crates.io-index"
974+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
975+dependencies = [
976+ "zerovec",
977+]
978+
979+[[package]]
980+name = "ppv-lite86"
981+version = "0.2.21"
982+source = "registry+https://github.com/rust-lang/crates.io-index"
983+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
984+dependencies = [
985+ "zerocopy",
986+]
987+
988+[[package]]
989+name = "proc-macro2"
990+version = "1.0.107"
991+source = "registry+https://github.com/rust-lang/crates.io-index"
992+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
993+dependencies = [
994+ "unicode-ident",
995+]
996+
997+[[package]]
998+name = "quote"
999+version = "1.0.47"
1000+source = "registry+https://github.com/rust-lang/crates.io-index"
1001+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
1002+dependencies = [
1003+ "proc-macro2",
1004+]
1005+
1006+[[package]]
1007+name = "r-efi"
1008+version = "5.3.0"
1009+source = "registry+https://github.com/rust-lang/crates.io-index"
1010+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1011+
1012+[[package]]
1013+name = "r-efi"
1014+version = "6.0.0"
1015+source = "registry+https://github.com/rust-lang/crates.io-index"
1016+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1017+
1018+[[package]]
1019+name = "rand"
1020+version = "0.8.8"
1021+source = "registry+https://github.com/rust-lang/crates.io-index"
1022+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
1023+dependencies = [
1024+ "libc",
1025+ "rand_chacha 0.3.1",
1026+ "rand_core 0.6.4",
1027+]
1028+
1029+[[package]]
1030+name = "rand"
1031+version = "0.9.5"
1032+source = "registry+https://github.com/rust-lang/crates.io-index"
1033+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
1034+dependencies = [
1035+ "rand_chacha 0.9.0",
1036+ "rand_core 0.9.5",
1037+]
1038+
1039+[[package]]
1040+name = "rand"
1041+version = "0.10.2"
1042+source = "registry+https://github.com/rust-lang/crates.io-index"
1043+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
1044+dependencies = [
1045+ "getrandom 0.4.3",
1046+ "rand_core 0.10.1",
1047+]
1048+
1049+[[package]]
1050+name = "rand_chacha"
1051+version = "0.3.1"
1052+source = "registry+https://github.com/rust-lang/crates.io-index"
1053+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1054+dependencies = [
1055+ "ppv-lite86",
1056+ "rand_core 0.6.4",
1057+]
1058+
1059+[[package]]
1060+name = "rand_chacha"
1061+version = "0.9.0"
1062+source = "registry+https://github.com/rust-lang/crates.io-index"
1063+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1064+dependencies = [
1065+ "ppv-lite86",
1066+ "rand_core 0.9.5",
1067+]
1068+
1069+[[package]]
1070+name = "rand_core"
1071+version = "0.6.4"
1072+source = "registry+https://github.com/rust-lang/crates.io-index"
1073+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1074+dependencies = [
1075+ "getrandom 0.2.17",
1076+]
1077+
1078+[[package]]
1079+name = "rand_core"
1080+version = "0.9.5"
1081+source = "registry+https://github.com/rust-lang/crates.io-index"
1082+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1083+dependencies = [
1084+ "getrandom 0.3.4",
1085+]
1086+
1087+[[package]]
1088+name = "rand_core"
1089+version = "0.10.1"
1090+source = "registry+https://github.com/rust-lang/crates.io-index"
1091+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1092+
1093+[[package]]
1094+name = "regex-automata"
1095+version = "0.4.18"
1096+source = "registry+https://github.com/rust-lang/crates.io-index"
1097+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
1098+dependencies = [
1099+ "aho-corasick",
1100+ "memchr",
1101+ "regex-syntax",
1102+]
1103+
1104+[[package]]
1105+name = "regex-syntax"
1106+version = "0.8.11"
1107+source = "registry+https://github.com/rust-lang/crates.io-index"
1108+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1109+
1110+[[package]]
1111+name = "ring"
1112+version = "0.17.14"
1113+source = "registry+https://github.com/rust-lang/crates.io-index"
1114+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1115+dependencies = [
1116+ "cc",
1117+ "cfg-if",
1118+ "getrandom 0.2.17",
1119+ "libc",
1120+ "untrusted",
1121+ "windows-sys 0.52.0",
1122+]
1123+
1124+[[package]]
1125+name = "rsqlite-vfs"
1126+version = "0.1.1"
1127+source = "registry+https://github.com/rust-lang/crates.io-index"
1128+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
1129+dependencies = [
1130+ "hashbrown 0.16.1",
1131+ "thiserror",
1132+]
1133+
1134+[[package]]
1135+name = "rusqlite"
1136+version = "0.38.0"
1137+source = "registry+https://github.com/rust-lang/crates.io-index"
1138+checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
1139+dependencies = [
1140+ "bitflags",
1141+ "fallible-iterator",
1142+ "fallible-streaming-iterator",
1143+ "hashlink",
1144+ "libsqlite3-sys",
1145+ "smallvec",
1146+ "sqlite-wasm-rs",
1147+]
1148+
1149+[[package]]
1150+name = "rustls"
1151+version = "0.23.45"
1152+source = "registry+https://github.com/rust-lang/crates.io-index"
1153+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
1154+dependencies = [
1155+ "once_cell",
1156+ "rustls-pki-types",
1157+ "rustls-webpki",
1158+ "subtle",
1159+ "zeroize",
1160+]
1161+
1162+[[package]]
1163+name = "rustls-pki-types"
1164+version = "1.15.1"
1165+source = "registry+https://github.com/rust-lang/crates.io-index"
1166+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1167+dependencies = [
1168+ "zeroize",
1169+]
1170+
1171+[[package]]
1172+name = "rustls-webpki"
1173+version = "0.103.15"
1174+source = "registry+https://github.com/rust-lang/crates.io-index"
1175+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
1176+dependencies = [
1177+ "ring",
1178+ "rustls-pki-types",
1179+ "untrusted",
1180+]
1181+
1182+[[package]]
1183+name = "rustversion"
1184+version = "1.0.23"
1185+source = "registry+https://github.com/rust-lang/crates.io-index"
1186+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1187+
1188+[[package]]
1189+name = "secp256k1"
1190+version = "0.30.0"
1191+source = "registry+https://github.com/rust-lang/crates.io-index"
1192+checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252"
1193+dependencies = [
1194+ "bitcoin_hashes 0.14.101",
1195+ "rand 0.8.8",
1196+ "secp256k1-sys",
1197+]
1198+
1199+[[package]]
1200+name = "secp256k1-sys"
1201+version = "0.10.1"
1202+source = "registry+https://github.com/rust-lang/crates.io-index"
1203+checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
1204+dependencies = [
1205+ "cc",
1206+]
1207+
1208+[[package]]
1209+name = "serde"
1210+version = "1.0.229"
1211+source = "registry+https://github.com/rust-lang/crates.io-index"
1212+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
1213+dependencies = [
1214+ "serde_core",
1215+ "serde_derive",
1216+]
1217+
1218+[[package]]
1219+name = "serde_core"
1220+version = "1.0.229"
1221+source = "registry+https://github.com/rust-lang/crates.io-index"
1222+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
1223+dependencies = [
1224+ "serde_derive",
1225+]
1226+
1227+[[package]]
1228+name = "serde_derive"
1229+version = "1.0.229"
1230+source = "registry+https://github.com/rust-lang/crates.io-index"
1231+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
1232+dependencies = [
1233+ "proc-macro2",
1234+ "quote",
1235+ "syn 3.0.6",
1236+]
1237+
1238+[[package]]
1239+name = "serde_json"
1240+version = "1.0.151"
1241+source = "registry+https://github.com/rust-lang/crates.io-index"
1242+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
1243+dependencies = [
1244+ "itoa",
1245+ "memchr",
1246+ "serde",
1247+ "serde_core",
1248+ "zmij",
1249+]
1250+
1251+[[package]]
1252+name = "serde_spanned"
1253+version = "0.6.9"
1254+source = "registry+https://github.com/rust-lang/crates.io-index"
1255+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
1256+dependencies = [
1257+ "serde",
1258+]
1259+
1260+[[package]]
1261+name = "sha1"
1262+version = "0.10.7"
1263+source = "registry+https://github.com/rust-lang/crates.io-index"
1264+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
1265+dependencies = [
1266+ "cfg-if",
1267+ "cpufeatures",
1268+ "digest",
1269+]
1270+
1271+[[package]]
1272+name = "sharded-slab"
1273+version = "0.1.7"
1274+source = "registry+https://github.com/rust-lang/crates.io-index"
1275+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1276+dependencies = [
1277+ "lazy_static",
1278+]
1279+
1280+[[package]]
1281+name = "shlex"
1282+version = "2.0.1"
1283+source = "registry+https://github.com/rust-lang/crates.io-index"
1284+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1285+
1286+[[package]]
1287+name = "signal-hook-registry"
1288+version = "1.4.8"
1289+source = "registry+https://github.com/rust-lang/crates.io-index"
1290+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1291+dependencies = [
1292+ "errno",
1293+ "libc",
1294+]
1295+
1296+[[package]]
1297+name = "slab"
1298+version = "0.4.12"
1299+source = "registry+https://github.com/rust-lang/crates.io-index"
1300+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1301+
1302+[[package]]
1303+name = "smallvec"
1304+version = "1.16.1"
1305+source = "registry+https://github.com/rust-lang/crates.io-index"
1306+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
1307+
1308+[[package]]
1309+name = "socket2"
1310+version = "0.6.5"
1311+source = "registry+https://github.com/rust-lang/crates.io-index"
1312+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
1313+dependencies = [
1314+ "libc",
1315+ "windows-sys 0.61.2",
1316+]
1317+
1318+[[package]]
1319+name = "sqlite-wasm-rs"
1320+version = "0.5.5"
1321+source = "registry+https://github.com/rust-lang/crates.io-index"
1322+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
1323+dependencies = [
1324+ "cc",
1325+ "js-sys",
1326+ "rsqlite-vfs",
1327+ "wasm-bindgen",
1328+]
1329+
1330+[[package]]
1331+name = "stable_deref_trait"
1332+version = "1.2.1"
1333+source = "registry+https://github.com/rust-lang/crates.io-index"
1334+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1335+
1336+[[package]]
1337+name = "strsim"
1338+version = "0.11.1"
1339+source = "registry+https://github.com/rust-lang/crates.io-index"
1340+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1341+
1342+[[package]]
1343+name = "subtle"
1344+version = "2.6.1"
1345+source = "registry+https://github.com/rust-lang/crates.io-index"
1346+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1347+
1348+[[package]]
1349+name = "syn"
1350+version = "2.0.119"
1351+source = "registry+https://github.com/rust-lang/crates.io-index"
1352+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
1353+dependencies = [
1354+ "proc-macro2",
1355+ "quote",
1356+ "unicode-ident",
1357+]
1358+
1359+[[package]]
1360+name = "syn"
1361+version = "3.0.6"
1362+source = "registry+https://github.com/rust-lang/crates.io-index"
1363+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
1364+dependencies = [
1365+ "proc-macro2",
1366+ "quote",
1367+ "unicode-ident",
1368+]
1369+
1370+[[package]]
1371+name = "synstructure"
1372+version = "0.14.0"
1373+source = "registry+https://github.com/rust-lang/crates.io-index"
1374+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
1375+dependencies = [
1376+ "proc-macro2",
1377+ "quote",
1378+ "syn 3.0.6",
1379+]
1380+
1381+[[package]]
1382+name = "thiserror"
1383+version = "2.0.20"
1384+source = "registry+https://github.com/rust-lang/crates.io-index"
1385+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
1386+dependencies = [
1387+ "thiserror-impl",
1388+]
1389+
1390+[[package]]
1391+name = "thiserror-impl"
1392+version = "2.0.20"
1393+source = "registry+https://github.com/rust-lang/crates.io-index"
1394+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
1395+dependencies = [
1396+ "proc-macro2",
1397+ "quote",
1398+ "syn 3.0.6",
1399+]
1400+
1401+[[package]]
1402+name = "thread_local"
1403+version = "1.1.10"
1404+source = "registry+https://github.com/rust-lang/crates.io-index"
1405+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
1406+dependencies = [
1407+ "cfg-if",
1408+]
1409+
1410+[[package]]
1411+name = "tinystr"
1412+version = "0.8.4"
1413+source = "registry+https://github.com/rust-lang/crates.io-index"
1414+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
1415+dependencies = [
1416+ "displaydoc",
1417+ "zerovec",
1418+]
1419+
1420+[[package]]
1421+name = "tinyvec"
1422+version = "1.13.3"
1423+source = "registry+https://github.com/rust-lang/crates.io-index"
1424+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
1425+
1426+[[package]]
1427+name = "tokio"
1428+version = "1.53.1"
1429+source = "registry+https://github.com/rust-lang/crates.io-index"
1430+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
1431+dependencies = [
1432+ "bytes",
1433+ "libc",
1434+ "mio",
1435+ "pin-project-lite",
1436+ "signal-hook-registry",
1437+ "socket2",
1438+ "tokio-macros",
1439+ "windows-sys 0.61.2",
1440+]
1441+
1442+[[package]]
1443+name = "tokio-macros"
1444+version = "2.7.2"
1445+source = "registry+https://github.com/rust-lang/crates.io-index"
1446+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
1447+dependencies = [
1448+ "proc-macro2",
1449+ "quote",
1450+ "syn 3.0.6",
1451+]
1452+
1453+[[package]]
1454+name = "tokio-rustls"
1455+version = "0.26.5"
1456+source = "registry+https://github.com/rust-lang/crates.io-index"
1457+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
1458+dependencies = [
1459+ "rustls",
1460+ "tokio",
1461+]
1462+
1463+[[package]]
1464+name = "tokio-tungstenite"
1465+version = "0.28.0"
1466+source = "registry+https://github.com/rust-lang/crates.io-index"
1467+checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
1468+dependencies = [
1469+ "futures-util",
1470+ "log",
1471+ "rustls",
1472+ "rustls-pki-types",
1473+ "tokio",
1474+ "tokio-rustls",
1475+ "tungstenite",
1476+ "webpki-roots 0.26.11",
1477+]
1478+
1479+[[package]]
1480+name = "tokio-util"
1481+version = "0.7.19"
1482+source = "registry+https://github.com/rust-lang/crates.io-index"
1483+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
1484+dependencies = [
1485+ "bytes",
1486+ "futures-core",
1487+ "futures-sink",
1488+ "futures-util",
1489+ "pin-project-lite",
1490+ "tokio",
1491+]
1492+
1493+[[package]]
1494+name = "toml"
1495+version = "0.8.23"
1496+source = "registry+https://github.com/rust-lang/crates.io-index"
1497+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
1498+dependencies = [
1499+ "serde",
1500+ "serde_spanned",
1501+ "toml_datetime",
1502+ "toml_edit",
1503+]
1504+
1505+[[package]]
1506+name = "toml_datetime"
1507+version = "0.6.11"
1508+source = "registry+https://github.com/rust-lang/crates.io-index"
1509+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
1510+dependencies = [
1511+ "serde",
1512+]
1513+
1514+[[package]]
1515+name = "toml_edit"
1516+version = "0.22.27"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
1519+dependencies = [
1520+ "indexmap",
1521+ "serde",
1522+ "serde_spanned",
1523+ "toml_datetime",
1524+ "toml_write",
1525+ "winnow",
1526+]
1527+
1528+[[package]]
1529+name = "toml_write"
1530+version = "0.1.2"
1531+source = "registry+https://github.com/rust-lang/crates.io-index"
1532+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
1533+
1534+[[package]]
1535+name = "tracing"
1536+version = "0.1.44"
1537+source = "registry+https://github.com/rust-lang/crates.io-index"
1538+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1539+dependencies = [
1540+ "pin-project-lite",
1541+ "tracing-attributes",
1542+ "tracing-core",
1543+]
1544+
1545+[[package]]
1546+name = "tracing-attributes"
1547+version = "0.1.31"
1548+source = "registry+https://github.com/rust-lang/crates.io-index"
1549+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1550+dependencies = [
1551+ "proc-macro2",
1552+ "quote",
1553+ "syn 2.0.119",
1554+]
1555+
1556+[[package]]
1557+name = "tracing-core"
1558+version = "0.1.36"
1559+source = "registry+https://github.com/rust-lang/crates.io-index"
1560+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1561+dependencies = [
1562+ "once_cell",
1563+ "valuable",
1564+]
1565+
1566+[[package]]
1567+name = "tracing-log"
1568+version = "0.2.0"
1569+source = "registry+https://github.com/rust-lang/crates.io-index"
1570+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1571+dependencies = [
1572+ "log",
1573+ "once_cell",
1574+ "tracing-core",
1575+]
1576+
1577+[[package]]
1578+name = "tracing-subscriber"
1579+version = "0.3.23"
1580+source = "registry+https://github.com/rust-lang/crates.io-index"
1581+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
1582+dependencies = [
1583+ "matchers",
1584+ "nu-ansi-term",
1585+ "once_cell",
1586+ "regex-automata",
1587+ "sharded-slab",
1588+ "smallvec",
1589+ "thread_local",
1590+ "tracing",
1591+ "tracing-core",
1592+ "tracing-log",
1593+]
1594+
1595+[[package]]
1596+name = "tungstenite"
1597+version = "0.28.0"
1598+source = "registry+https://github.com/rust-lang/crates.io-index"
1599+checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
1600+dependencies = [
1601+ "bytes",
1602+ "data-encoding",
1603+ "http",
1604+ "httparse",
1605+ "log",
1606+ "rand 0.9.5",
1607+ "rustls",
1608+ "rustls-pki-types",
1609+ "sha1",
1610+ "thiserror",
1611+ "utf-8",
1612+]
1613+
1614+[[package]]
1615+name = "typenum"
1616+version = "1.20.1"
1617+source = "registry+https://github.com/rust-lang/crates.io-index"
1618+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1619+
1620+[[package]]
1621+name = "unicode-ident"
1622+version = "1.0.26"
1623+source = "registry+https://github.com/rust-lang/crates.io-index"
1624+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
1625+
1626+[[package]]
1627+name = "unicode-normalization"
1628+version = "0.1.25"
1629+source = "registry+https://github.com/rust-lang/crates.io-index"
1630+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
1631+dependencies = [
1632+ "tinyvec",
1633+]
1634+
1635+[[package]]
1636+name = "universal-hash"
1637+version = "0.5.1"
1638+source = "registry+https://github.com/rust-lang/crates.io-index"
1639+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
1640+dependencies = [
1641+ "crypto-common",
1642+ "subtle",
1643+]
1644+
1645+[[package]]
1646+name = "universal-time"
1647+version = "0.3.1"
1648+source = "registry+https://github.com/rust-lang/crates.io-index"
1649+checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856"
1650+
1651+[[package]]
1652+name = "untrusted"
1653+version = "0.9.0"
1654+source = "registry+https://github.com/rust-lang/crates.io-index"
1655+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1656+
1657+[[package]]
1658+name = "url"
1659+version = "2.5.8"
1660+source = "registry+https://github.com/rust-lang/crates.io-index"
1661+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1662+dependencies = [
1663+ "form_urlencoded",
1664+ "idna",
1665+ "percent-encoding",
1666+ "serde",
1667+ "serde_derive",
1668+]
1669+
1670+[[package]]
1671+name = "utf-8"
1672+version = "0.7.6"
1673+source = "registry+https://github.com/rust-lang/crates.io-index"
1674+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
1675+
1676+[[package]]
1677+name = "utf8_iter"
1678+version = "1.0.4"
1679+source = "registry+https://github.com/rust-lang/crates.io-index"
1680+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1681+
1682+[[package]]
1683+name = "utf8parse"
1684+version = "0.2.2"
1685+source = "registry+https://github.com/rust-lang/crates.io-index"
1686+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1687+
1688+[[package]]
1689+name = "valuable"
1690+version = "0.1.1"
1691+source = "registry+https://github.com/rust-lang/crates.io-index"
1692+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1693+
1694+[[package]]
1695+name = "vcpkg"
1696+version = "0.2.15"
1697+source = "registry+https://github.com/rust-lang/crates.io-index"
1698+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
1699+
1700+[[package]]
1701+name = "version_check"
1702+version = "0.9.5"
1703+source = "registry+https://github.com/rust-lang/crates.io-index"
1704+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1705+
1706+[[package]]
1707+name = "vineyard"
1708+version = "0.1.0"
1709+dependencies = [
1710+ "anyhow",
1711+ "clap",
1712+ "fs2",
1713+ "futures-util",
1714+ "http-body-util",
1715+ "hyper",
1716+ "hyper-util",
1717+ "nostr",
1718+ "nostr-database",
1719+ "rusqlite",
1720+ "serde",
1721+ "serde_json",
1722+ "tokio",
1723+ "tokio-tungstenite",
1724+ "tokio-util",
1725+ "toml",
1726+ "tracing",
1727+ "tracing-subscriber",
1728+ "url",
1729+]
1730+
1731+[[package]]
1732+name = "wasi"
1733+version = "0.11.1+wasi-snapshot-preview1"
1734+source = "registry+https://github.com/rust-lang/crates.io-index"
1735+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1736+
1737+[[package]]
1738+name = "wasip2"
1739+version = "1.0.1+wasi-0.2.4"
1740+source = "registry+https://github.com/rust-lang/crates.io-index"
1741+checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
1742+dependencies = [
1743+ "wit-bindgen",
1744+]
1745+
1746+[[package]]
1747+name = "wasm-bindgen"
1748+version = "0.2.128"
1749+source = "registry+https://github.com/rust-lang/crates.io-index"
1750+checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
1751+dependencies = [
1752+ "cfg-if",
1753+ "once_cell",
1754+ "rustversion",
1755+ "wasm-bindgen-macro",
1756+ "wasm-bindgen-shared",
1757+]
1758+
1759+[[package]]
1760+name = "wasm-bindgen-macro"
1761+version = "0.2.128"
1762+source = "registry+https://github.com/rust-lang/crates.io-index"
1763+checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
1764+dependencies = [
1765+ "quote",
1766+ "wasm-bindgen-macro-support",
1767+]
1768+
1769+[[package]]
1770+name = "wasm-bindgen-macro-support"
1771+version = "0.2.128"
1772+source = "registry+https://github.com/rust-lang/crates.io-index"
1773+checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
1774+dependencies = [
1775+ "bumpalo",
1776+ "proc-macro2",
1777+ "quote",
1778+ "syn 3.0.6",
1779+ "wasm-bindgen-shared",
1780+]
1781+
1782+[[package]]
1783+name = "wasm-bindgen-shared"
1784+version = "0.2.128"
1785+source = "registry+https://github.com/rust-lang/crates.io-index"
1786+checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
1787+dependencies = [
1788+ "unicode-ident",
1789+]
1790+
1791+[[package]]
1792+name = "webpki-roots"
1793+version = "0.26.11"
1794+source = "registry+https://github.com/rust-lang/crates.io-index"
1795+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
1796+dependencies = [
1797+ "webpki-roots 1.0.9",
1798+]
1799+
1800+[[package]]
1801+name = "webpki-roots"
1802+version = "1.0.9"
1803+source = "registry+https://github.com/rust-lang/crates.io-index"
1804+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
1805+dependencies = [
1806+ "rustls-pki-types",
1807+]
1808+
1809+[[package]]
1810+name = "winapi"
1811+version = "0.3.9"
1812+source = "registry+https://github.com/rust-lang/crates.io-index"
1813+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
1814+dependencies = [
1815+ "winapi-i686-pc-windows-gnu",
1816+ "winapi-x86_64-pc-windows-gnu",
1817+]
1818+
1819+[[package]]
1820+name = "winapi-i686-pc-windows-gnu"
1821+version = "0.4.0"
1822+source = "registry+https://github.com/rust-lang/crates.io-index"
1823+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
1824+
1825+[[package]]
1826+name = "winapi-x86_64-pc-windows-gnu"
1827+version = "0.4.0"
1828+source = "registry+https://github.com/rust-lang/crates.io-index"
1829+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
1830+
1831+[[package]]
1832+name = "windows-link"
1833+version = "0.2.1"
1834+source = "registry+https://github.com/rust-lang/crates.io-index"
1835+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1836+
1837+[[package]]
1838+name = "windows-sys"
1839+version = "0.52.0"
1840+source = "registry+https://github.com/rust-lang/crates.io-index"
1841+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
1842+dependencies = [
1843+ "windows-targets",
1844+]
1845+
1846+[[package]]
1847+name = "windows-sys"
1848+version = "0.61.2"
1849+source = "registry+https://github.com/rust-lang/crates.io-index"
1850+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1851+dependencies = [
1852+ "windows-link",
1853+]
1854+
1855+[[package]]
1856+name = "windows-targets"
1857+version = "0.52.6"
1858+source = "registry+https://github.com/rust-lang/crates.io-index"
1859+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1860+dependencies = [
1861+ "windows_aarch64_gnullvm",
1862+ "windows_aarch64_msvc",
1863+ "windows_i686_gnu",
1864+ "windows_i686_gnullvm",
1865+ "windows_i686_msvc",
1866+ "windows_x86_64_gnu",
1867+ "windows_x86_64_gnullvm",
1868+ "windows_x86_64_msvc",
1869+]
1870+
1871+[[package]]
1872+name = "windows_aarch64_gnullvm"
1873+version = "0.52.6"
1874+source = "registry+https://github.com/rust-lang/crates.io-index"
1875+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1876+
1877+[[package]]
1878+name = "windows_aarch64_msvc"
1879+version = "0.52.6"
1880+source = "registry+https://github.com/rust-lang/crates.io-index"
1881+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1882+
1883+[[package]]
1884+name = "windows_i686_gnu"
1885+version = "0.52.6"
1886+source = "registry+https://github.com/rust-lang/crates.io-index"
1887+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1888+
1889+[[package]]
1890+name = "windows_i686_gnullvm"
1891+version = "0.52.6"
1892+source = "registry+https://github.com/rust-lang/crates.io-index"
1893+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1894+
1895+[[package]]
1896+name = "windows_i686_msvc"
1897+version = "0.52.6"
1898+source = "registry+https://github.com/rust-lang/crates.io-index"
1899+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1900+
1901+[[package]]
1902+name = "windows_x86_64_gnu"
1903+version = "0.52.6"
1904+source = "registry+https://github.com/rust-lang/crates.io-index"
1905+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1906+
1907+[[package]]
1908+name = "windows_x86_64_gnullvm"
1909+version = "0.52.6"
1910+source = "registry+https://github.com/rust-lang/crates.io-index"
1911+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1912+
1913+[[package]]
1914+name = "windows_x86_64_msvc"
1915+version = "0.52.6"
1916+source = "registry+https://github.com/rust-lang/crates.io-index"
1917+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1918+
1919+[[package]]
1920+name = "winnow"
1921+version = "0.7.15"
1922+source = "registry+https://github.com/rust-lang/crates.io-index"
1923+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
1924+dependencies = [
1925+ "memchr",
1926+]
1927+
1928+[[package]]
1929+name = "wit-bindgen"
1930+version = "0.46.0"
1931+source = "registry+https://github.com/rust-lang/crates.io-index"
1932+checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
1933+
1934+[[package]]
1935+name = "writeable"
1936+version = "0.6.4"
1937+source = "registry+https://github.com/rust-lang/crates.io-index"
1938+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
1939+
1940+[[package]]
1941+name = "yoke"
1942+version = "0.8.3"
1943+source = "registry+https://github.com/rust-lang/crates.io-index"
1944+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1945+dependencies = [
1946+ "stable_deref_trait",
1947+ "yoke-derive",
1948+ "zerofrom",
1949+]
1950+
1951+[[package]]
1952+name = "yoke-derive"
1953+version = "0.8.3"
1954+source = "registry+https://github.com/rust-lang/crates.io-index"
1955+checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
1956+dependencies = [
1957+ "proc-macro2",
1958+ "quote",
1959+ "syn 3.0.6",
1960+ "synstructure",
1961+]
1962+
1963+[[package]]
1964+name = "zerocopy"
1965+version = "0.8.57"
1966+source = "registry+https://github.com/rust-lang/crates.io-index"
1967+checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
1968+dependencies = [
1969+ "zerocopy-derive",
1970+]
1971+
1972+[[package]]
1973+name = "zerocopy-derive"
1974+version = "0.8.57"
1975+source = "registry+https://github.com/rust-lang/crates.io-index"
1976+checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
1977+dependencies = [
1978+ "proc-macro2",
1979+ "quote",
1980+ "syn 2.0.119",
1981+]
1982+
1983+[[package]]
1984+name = "zerofrom"
1985+version = "0.1.8"
1986+source = "registry+https://github.com/rust-lang/crates.io-index"
1987+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1988+dependencies = [
1989+ "zerofrom-derive",
1990+]
1991+
1992+[[package]]
1993+name = "zerofrom-derive"
1994+version = "0.1.8"
1995+source = "registry+https://github.com/rust-lang/crates.io-index"
1996+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
1997+dependencies = [
1998+ "proc-macro2",
1999+ "quote",
2000+ "syn 3.0.6",
2001+ "synstructure",
2002+]
2003+
2004+[[package]]
2005+name = "zeroize"
2006+version = "1.9.0"
2007+source = "registry+https://github.com/rust-lang/crates.io-index"
2008+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2009+
2010+[[package]]
2011+name = "zerotrie"
2012+version = "0.2.5"
2013+source = "registry+https://github.com/rust-lang/crates.io-index"
2014+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
2015+dependencies = [
2016+ "displaydoc",
2017+ "yoke",
2018+ "zerofrom",
2019+]
2020+
2021+[[package]]
2022+name = "zerovec"
2023+version = "0.11.8"
2024+source = "registry+https://github.com/rust-lang/crates.io-index"
2025+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
2026+dependencies = [
2027+ "yoke",
2028+ "zerofrom",
2029+ "zerovec-derive",
2030+]
2031+
2032+[[package]]
2033+name = "zerovec-derive"
2034+version = "0.11.6"
2035+source = "registry+https://github.com/rust-lang/crates.io-index"
2036+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
2037+dependencies = [
2038+ "proc-macro2",
2039+ "quote",
2040+ "syn 3.0.6",
2041+]
2042+
2043+[[package]]
2044+name = "zmij"
2045+version = "1.0.23"
2046+source = "registry+https://github.com/rust-lang/crates.io-index"
2047+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Added file: Cargo.toml+68 additions
@@ -0,0 +1,68 @@
1+[package]
2+name = "vineyard"
3+version = "0.1.0"
4+edition = "2024"
5+rust-version = "1.85"
6+description = "a persistent nostr relay for git forges"
7+publish = false
8+
9+[dependencies]
10+anyhow = "1"
11+clap = { version = "4", features = ["derive"] }
12+futures-util = "0.3"
13+fs2 = "0.4"
14+http-body-util = "0.1"
15+hyper = { version = "1", features = ["server", "http1"] }
16+hyper-util = { version = "0.1", features = ["tokio"] }
17+nostr = "=0.45.5"
18+nostr-database = "=0.45.2"
19+rusqlite = { version = "0.38", features = ["bundled"] }
20+serde = { version = "1", features = ["derive"] }
21+serde_json = "1"
22+tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "net", "sync", "time", "io-util"] }
23+tokio-util = { version = "0.7", features = ["rt"] }
24+tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
25+toml = "0.8"
26+tracing = "0.1"
27+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
28+url = "2"
29+
30+[lints.clippy]
31+cargo = { level = "warn", priority = -1 }
32+nursery = { level = "warn", priority = -1 }
33+pedantic = { level = "warn", priority = -1 }
34+
35+blanket_clippy_restriction_lints = "allow"
36+restriction = { level = "warn", priority = -1 }
37+
38+arbitrary_source_item_ordering = "allow"
39+arithmetic_side_effects = "allow"
40+decimal_literal_representation = "allow"
41+else_if_without_else = "allow"
42+error_impl_error = "allow"
43+exhaustive_enums = "allow"
44+exhaustive_structs = "allow"
45+field_scoped_visibility_modifiers = "allow"
46+implicit_return = "allow"
47+indexing_slicing = "allow"
48+integer_division = "allow"
49+integer_division_remainder_used = "allow"
50+missing_trait_methods = "allow"
51+missing_docs_in_private_items = "allow"
52+missing_errors_doc = "allow"
53+missing_inline_in_public_items = "allow"
54+missing_panics_doc = "allow"
55+module_name_repetitions = "allow"
56+multiple_crate_versions = "allow"
57+question_mark_used = "allow"
58+ref_patterns = "allow"
59+pub_with_shorthand = "allow"
60+separated_literal_suffix = "allow"
61+single_call_fn = "allow"
62+std_instead_of_alloc = "allow"
63+std_instead_of_core = "allow"
64+tests_outside_test_module = "allow"
65+
66+[profile.release]
67+lto = "thin"
68+strip = true
Added file: README.md+108 additions
@@ -0,0 +1,108 @@
1+# vineyard
2+
3+a nostr federation companion for brewery
4+
5+public repository discovery and signed event exchange between forges
6+git hosting and signing keys stay with brewery
7+
8+## run
9+
10+copy `vineyard.example.toml` and replace `forge_pubkeys` with brewery's public key
11+database paths are relative to the config file
12+
13+```sh
14+nix build
15+result/bin/vineyard check-config --config vineyard.toml
16+result/bin/vineyard serve --config vineyard.toml
17+```
18+
19+put tls termination in front of the loopback listener
20+forward websocket upgrades at the path in `public_url`
21+`/healthz` checks database access
22+request `application/nostr+json` at the websocket path for relay information
23+
24+## nixos
25+
26+import `nixosModules.vineyard` and configure the service
27+
28+```nix
29+services.vineyard = {
30+ enable = true;
31+ publicUrl = "wss://relay.example.org/";
32+ forgePubkeys = [ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ];
33+ peers = [ { url = "wss://peer.example.org/"; } ];
34+};
35+```
36+
37+the module runs vineyard under a dynamic user and stores data in `/var/lib/vineyard`
38+
39+## peers
40+
41+add one or more bootstrap relays to connect your forge to the network
42+
43+```toml
44+[[peers]]
45+url = "wss://peer.example.org/"
46+query_limit = 500
47+```
48+
49+use a query limit no larger than the peer supports
50+peers must accept forge events without nip42 authentication
51+remote repository announcements retain their signer's identity
52+brewery publishes its signed nip 65 relay list
53+peer relay lists are persisted and joined automatically
54+discovered peers are bounded by `max_discovered_peers`
55+discovered peers require wss and public dns addresses by default
56+set `allow_private_discovery` only for trusted private networks or local development
57+
58+delivery progress and failed-event retries persist in sqlite
59+incoming history uses a seven-day overlap
60+set a peer's `rescan_from` to an older unix timestamp for manual recovery
61+single-second history overflow stops replay without advancing its checkpoint
62+
63+## brewery
64+
65+build brewery with its `federation` feature or nix package option
66+
67+```sh
68+export BREWERY_FEDERATION=1
69+export BREWERY_NOSTR_RELAYS=wss://relay.example.org/
70+export BREWERY_PUBLIC_URL=https://git.example.org
71+brewery repo issue-mode alice/widget users_and_tokens
72+brewery repo patch-mode alice/widget tokens
73+```
74+
75+repositories must be public
76+brewery retains git hosting and verifies openpgp binding proofs
77+the companion brewery changes persist replay progress in `federation.inbox.json`
78+set `BREWERY_FEDERATION_RESCAN_FROM` to a unix timestamp for an older rescan
79+
80+## limits
81+
82+defaults are in `vineyard.example.toml`
83+merged history is bounded by `max_query_bytes`
84+queries without an explicit limit fail when history exceeds the result cap
85+`max_repositories` bounds the shared repository directory
86+slow subscribers disconnect and must replay
87+peer limits use the direct socket address, so a proxy shares one allowance
88+
89+the database budget limits sqlite pages, not total disk usage
90+leave space for the wal and backups
91+accepted events are not evicted to make room
92+
93+## backup
94+
95+stop vineyard before copying its config and database
96+preserve any accompanying sqlite wal file
97+restore with the same forge public keys
98+
99+use the companion brewery backup command to retain `nostr.key` and federation state
100+keep the restored key private and restore a custom key path before starting brewery
101+a new forge key changes every repository's nostr address
102+
103+## development
104+
105+```sh
106+nix develop
107+nix flake check
108+```
Added file: flake.lock+27 additions
@@ -0,0 +1,27 @@
1+{
2+ "nodes": {
3+ "nixpkgs": {
4+ "locked": {
5+ "lastModified": 1787360063,
6+ "narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",
7+ "owner": "NixOS",
8+ "repo": "nixpkgs",
9+ "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
10+ "type": "github"
11+ },
12+ "original": {
13+ "owner": "NixOS",
14+ "repo": "nixpkgs",
15+ "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
16+ "type": "github"
17+ }
18+ },
19+ "root": {
20+ "inputs": {
21+ "nixpkgs": "nixpkgs"
22+ }
23+ }
24+ },
25+ "root": "root",
26+ "version": 7
27+}
Added file: flake.nix+38 additions
@@ -0,0 +1,38 @@
1+{
2+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/2c423e03bbafcff28bfadc6781a4a8257f205cb5";
3+ outputs = { self, nixpkgs }: let
4+ systems = [ "x86_64-linux" "aarch64-linux" ];
5+ forAllSystems = nixpkgs.lib.genAttrs systems;
6+ packageFor = system: let
7+ pkgs = nixpkgs.legacyPackages.${system};
8+ in pkgs.rustPlatform.buildRustPackage {
9+ pname = "vineyard";
10+ version = "0.1.0";
11+ src = pkgs.lib.cleanSourceWith {
12+ src = ./.;
13+ filter = path: type:
14+ pkgs.lib.cleanSourceFilter path type
15+ && !(type == "directory" && builtins.elem (builtins.baseNameOf path) [ "target" ".jj" ".git" ]);
16+ };
17+ cargoLock.lockFile = ./Cargo.lock;
18+ doCheck = true;
19+ };
20+ in {
21+ nixosModules.default = import ./nix/module.nix self;
22+ nixosModules.vineyard = self.nixosModules.default;
23+ packages = forAllSystems (system: {
24+ default = packageFor system;
25+ vineyard = packageFor system;
26+ });
27+ checks = forAllSystems (system: {
28+ vineyard = packageFor system;
29+ });
30+ devShells = forAllSystems (system: let
31+ pkgs = nixpkgs.legacyPackages.${system};
32+ in {
33+ default = pkgs.mkShell {
34+ packages = with pkgs; [ cargo rustc rustfmt clippy pkg-config ];
35+ };
36+ });
37+ };
38+}
Added file: nix/module.nix+128 additions
@@ -0,0 +1,128 @@
1+self: { config, lib, pkgs, ... }:
2+let
3+ cfg = config.services.vineyard;
4+ toml = pkgs.formats.toml { };
5+ peerToml = peer: {
6+ url = peer.url;
7+ query_limit = peer.queryLimit;
8+ } // lib.optionalAttrs (peer.rescanFrom != null) {
9+ rescan_from = peer.rescanFrom;
10+ };
11+ configFile = toml.generate "vineyard.toml" {
12+ listen = cfg.listen;
13+ public_url = cfg.publicUrl;
14+ database = "/var/lib/vineyard/events.sqlite";
15+ forge_pubkeys = cfg.forgePubkeys;
16+ peers = map peerToml cfg.peers;
17+ allow_private_discovery = cfg.allowPrivateDiscovery;
18+ limits = cfg.limits;
19+ };
20+ port = lib.strings.toIntBase10 (lib.last (lib.splitString ":" cfg.listen));
21+in
22+{
23+ options.services.vineyard = {
24+ enable = lib.mkEnableOption "vineyard";
25+ package = lib.mkOption {
26+ type = lib.types.package;
27+ default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
28+ description = "vineyard package to run";
29+ };
30+ listen = lib.mkOption {
31+ type = lib.types.str;
32+ default = "127.0.0.1:7777";
33+ description = "address and port for vineyard to listen on";
34+ };
35+ publicUrl = lib.mkOption {
36+ type = lib.types.str;
37+ default = "";
38+ description = "public websocket url for vineyard";
39+ };
40+ forgePubkeys = lib.mkOption {
41+ type = lib.types.listOf lib.types.str;
42+ default = [ ];
43+ description = "locally hosted forge public keys in hex";
44+ };
45+ peers = lib.mkOption {
46+ type = lib.types.listOf (lib.types.submodule {
47+ options = {
48+ url = lib.mkOption {
49+ type = lib.types.str;
50+ description = "peer relay url";
51+ };
52+ queryLimit = lib.mkOption {
53+ type = lib.types.ints.positive;
54+ default = 500;
55+ description = "peer query limit";
56+ };
57+ rescanFrom = lib.mkOption {
58+ type = lib.types.nullOr lib.types.ints.unsigned;
59+ default = null;
60+ description = "peer rescan timestamp";
61+ };
62+ };
63+ });
64+ default = [ ];
65+ description = "peer relays for event exchange";
66+ };
67+ allowPrivateDiscovery = lib.mkOption {
68+ type = lib.types.bool;
69+ default = false;
70+ description = "allow discovered relays on private networks";
71+ };
72+ limits = lib.mkOption {
73+ type = lib.types.attrsOf lib.types.int;
74+ default = { };
75+ description = "relay limit overrides";
76+ };
77+ openFirewall = lib.mkOption {
78+ type = lib.types.bool;
79+ default = false;
80+ description = "open firewall for listen port";
81+ };
82+ };
83+ config = lib.mkIf cfg.enable {
84+ assertions = [
85+ {
86+ assertion = cfg.publicUrl != "";
87+ message = "publicUrl must be set";
88+ }
89+ {
90+ assertion = cfg.forgePubkeys != [ ];
91+ message = "forgePubkeys must not be empty";
92+ }
93+ ];
94+ networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ port ];
95+ systemd.services.vineyard = {
96+ description = "vineyard nostr relay";
97+ wantedBy = [ "multi-user.target" ];
98+ after = [ "network-online.target" ];
99+ wants = [ "network-online.target" ];
100+ serviceConfig = {
101+ ExecStartPre = "${cfg.package}/bin/vineyard check-config --config ${configFile}";
102+ ExecStart = "${cfg.package}/bin/vineyard serve --config ${configFile}";
103+ DynamicUser = true;
104+ StateDirectory = "vineyard";
105+ StateDirectoryMode = "0700";
106+ WorkingDirectory = "/var/lib/vineyard";
107+ UMask = "0077";
108+ Restart = "on-failure";
109+ RestartSec = "5";
110+ NoNewPrivileges = true;
111+ ProtectSystem = "strict";
112+ ProtectHome = true;
113+ PrivateTmp = true;
114+ PrivateDevices = true;
115+ ProtectKernelTunables = true;
116+ ProtectKernelModules = true;
117+ ProtectControlGroups = true;
118+ RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
119+ RestrictNamespaces = true;
120+ LockPersonality = true;
121+ RestrictRealtime = true;
122+ RestrictSUIDSGID = true;
123+ RemoveIPC = true;
124+ CapabilityBoundingSet = "";
125+ };
126+ };
127+ };
128+}
Added file: src/config.rs+315 additions
@@ -0,0 +1,315 @@
1+use std::collections::HashSet;
2+use std::fs::read_to_string;
3+use std::net::SocketAddr;
4+use std::path::{Path, PathBuf};
5+
6+use nostr::key::PublicKey;
7+use serde::Deserialize;
8+use tokio::sync::Semaphore;
9+
10+fn default_listen() -> SocketAddr {
11+ SocketAddr::from(([127, 0, 0, 1], 7777))
12+}
13+
14+fn default_database() -> PathBuf {
15+ PathBuf::from("vineyard.sqlite")
16+}
17+
18+const fn default_max_connections() -> usize {
19+ 32
20+}
21+
22+const fn default_max_connections_per_ip() -> usize {
23+ 8
24+}
25+
26+const fn default_max_subscriptions() -> usize {
27+ 16
28+}
29+
30+const fn default_max_event_bytes() -> usize {
31+ 2_096_896
32+}
33+
34+const fn default_max_message_bytes() -> usize {
35+ 2_097_152
36+}
37+
38+const fn default_max_tags() -> usize {
39+ 128
40+}
41+
42+const fn default_max_filters() -> usize {
43+ 8
44+}
45+
46+const fn default_max_filter_bytes() -> usize {
47+ 32_768
48+}
49+
50+const fn default_max_query_results() -> usize {
51+ 500
52+}
53+
54+const fn default_max_repositories() -> usize {
55+ 4096
56+}
57+
58+const fn default_max_discovered_peers() -> usize {
59+ 32
60+}
61+
62+const fn default_max_query_bytes() -> usize {
63+ 16_777_216
64+}
65+
66+const fn default_events_per_minute() -> u32 {
67+ 120
68+}
69+
70+const fn default_queries_per_minute() -> u32 {
71+ 120
72+}
73+
74+const fn default_max_database_bytes() -> u64 {
75+ 1_073_741_824
76+}
77+
78+const fn default_min_free_bytes() -> u64 {
79+ 67_108_864
80+}
81+
82+const fn default_write_timeout_seconds() -> u64 {
83+ 10
84+}
85+
86+#[derive(Debug, Clone, Deserialize)]
87+#[serde(deny_unknown_fields)]
88+pub struct Config {
89+ #[serde(default = "default_listen")]
90+ pub listen: SocketAddr,
91+ pub public_url: String,
92+ #[serde(default = "default_database")]
93+ pub database: PathBuf,
94+ pub forge_pubkeys: Vec<String>,
95+ #[serde(default)]
96+ pub peers: Vec<Peer>,
97+ #[serde(default)]
98+ pub allow_private_discovery: bool,
99+ #[serde(default)]
100+ pub limits: Limits,
101+}
102+
103+#[derive(Debug, Clone, Deserialize)]
104+#[serde(deny_unknown_fields)]
105+pub struct Peer {
106+ pub url: String,
107+ #[serde(default = "default_max_query_results")]
108+ pub query_limit: usize,
109+ pub rescan_from: Option<u64>,
110+}
111+
112+#[derive(Debug, Clone, Deserialize)]
113+#[serde(deny_unknown_fields)]
114+pub struct Limits {
115+ #[serde(default = "default_max_repositories")]
116+ pub max_repositories: usize,
117+ #[serde(default = "default_max_discovered_peers")]
118+ pub max_discovered_peers: usize,
119+ #[serde(default = "default_max_connections")]
120+ pub max_connections: usize,
121+ #[serde(default = "default_max_connections_per_ip")]
122+ pub max_connections_per_ip: usize,
123+ #[serde(default = "default_max_subscriptions")]
124+ pub max_subscriptions: usize,
125+ #[serde(default = "default_max_event_bytes")]
126+ pub max_event_bytes: usize,
127+ #[serde(default = "default_max_message_bytes")]
128+ pub max_message_bytes: usize,
129+ #[serde(default = "default_max_tags")]
130+ pub max_tags: usize,
131+ #[serde(default = "default_max_filters")]
132+ pub max_filters: usize,
133+ #[serde(default = "default_max_filter_bytes")]
134+ pub max_filter_bytes: usize,
135+ #[serde(default = "default_max_query_results")]
136+ pub max_query_results: usize,
137+ #[serde(default = "default_max_query_bytes")]
138+ pub max_query_bytes: usize,
139+ #[serde(default = "default_events_per_minute")]
140+ pub events_per_minute: u32,
141+ #[serde(default = "default_queries_per_minute")]
142+ pub queries_per_minute: u32,
143+ #[serde(default = "default_max_database_bytes")]
144+ pub max_database_bytes: u64,
145+ #[serde(default = "default_min_free_bytes")]
146+ pub min_free_bytes: u64,
147+ #[serde(default = "default_write_timeout_seconds")]
148+ pub write_timeout_seconds: u64,
149+}
150+
151+impl Default for Limits {
152+ fn default() -> Self {
153+ Self {
154+ max_repositories: default_max_repositories(),
155+ max_discovered_peers: default_max_discovered_peers(),
156+ max_connections: default_max_connections(),
157+ max_connections_per_ip: default_max_connections_per_ip(),
158+ max_subscriptions: default_max_subscriptions(),
159+ max_event_bytes: default_max_event_bytes(),
160+ max_message_bytes: default_max_message_bytes(),
161+ max_tags: default_max_tags(),
162+ max_filters: default_max_filters(),
163+ max_filter_bytes: default_max_filter_bytes(),
164+ max_query_results: default_max_query_results(),
165+ max_query_bytes: default_max_query_bytes(),
166+ events_per_minute: default_events_per_minute(),
167+ queries_per_minute: default_queries_per_minute(),
168+ max_database_bytes: default_max_database_bytes(),
169+ min_free_bytes: default_min_free_bytes(),
170+ write_timeout_seconds: default_write_timeout_seconds(),
171+ }
172+ }
173+}
174+
175+impl Config {
176+ pub fn load(path: &Path) -> anyhow::Result<Self> {
177+ let text = read_to_string(path).map_err(|error| {
178+ anyhow::anyhow!("failed to read config {}: {error}", path.display())
179+ })?;
180+ let mut cfg: Self = toml::from_str(&text).map_err(|error| {
181+ anyhow::anyhow!("failed to parse config {}: {error}", path.display())
182+ })?;
183+ if cfg.database.is_relative() {
184+ if let Some(parent) = path.parent() {
185+ if !parent.as_os_str().is_empty() {
186+ cfg.database = parent.join(&cfg.database);
187+ }
188+ }
189+ }
190+ cfg.validate()?;
191+ Ok(cfg)
192+ }
193+
194+ pub fn validate(&self) -> anyhow::Result<()> {
195+ let url = relay_url(&self.public_url)
196+ .ok_or_else(|| anyhow::anyhow!("invalid public_url {}", self.public_url))?;
197+ self.forge_keys()?;
198+ if self.peers.len() > 16 {
199+ anyhow::bail!("at most 16 peer relays are supported");
200+ }
201+ let mut peers = HashSet::new();
202+ for peer in &self.peers {
203+ let peer_url = relay_url(&peer.url).ok_or_else(|| {
204+ anyhow::anyhow!(
205+ "peer urls must be ws or wss without credentials, queries or fragments"
206+ )
207+ })?;
208+ if peer_url == url || !peers.insert(peer_url) {
209+ anyhow::bail!("peer urls must be unique and distinct from public_url");
210+ }
211+ if !(2..10000).contains(&peer.query_limit) {
212+ anyhow::bail!("peer query_limit must be between 2 and 9999");
213+ }
214+ }
215+ if self.limits.max_connections == 0
216+ || self.limits.max_connections_per_ip == 0
217+ || self.limits.max_subscriptions == 0
218+ || self.limits.max_event_bytes == 0
219+ || self.limits.max_message_bytes == 0
220+ || self.limits.max_tags == 0
221+ || self.limits.max_filters == 0
222+ || self.limits.max_filter_bytes == 0
223+ || self.limits.max_query_results == 0
224+ || self.limits.max_query_bytes == 0
225+ || self.limits.events_per_minute == 0
226+ || self.limits.queries_per_minute == 0
227+ || self.limits.max_database_bytes == 0
228+ || self.limits.min_free_bytes == 0
229+ || self.limits.write_timeout_seconds == 0
230+ {
231+ anyhow::bail!("limits must all be positive");
232+ }
233+ if self.limits.max_connections_per_ip > self.limits.max_connections {
234+ anyhow::bail!("max_connections_per_ip must not exceed max_connections");
235+ }
236+ let min_message = self
237+ .limits
238+ .max_event_bytes
239+ .checked_add(256)
240+ .ok_or_else(|| anyhow::anyhow!("max_event_bytes too large"))?;
241+ if min_message > self.limits.max_message_bytes {
242+ anyhow::bail!("max_message_bytes must fit max_event_bytes plus 256 bytes of overhead");
243+ }
244+ if self.limits.max_event_bytes > self.limits.max_query_bytes {
245+ anyhow::bail!("max_query_bytes must fit one maximum size event");
246+ }
247+ if self.limits.max_repositories == 0 || self.limits.max_repositories >= 10000 {
248+ anyhow::bail!("max_repositories must be between 1 and 9999");
249+ }
250+ if self.limits.max_discovered_peers == 0 || self.limits.max_discovered_peers > 256 {
251+ anyhow::bail!("max_discovered_peers must be between 1 and 256");
252+ }
253+ if self.limits.max_query_results >= 10000 {
254+ anyhow::bail!("max_query_results must be below 10000");
255+ }
256+ if self.limits.max_connections > Semaphore::MAX_PERMITS {
257+ anyhow::bail!("max_connections exceeds the semaphore limit");
258+ }
259+ self.limits
260+ .max_filter_bytes
261+ .checked_mul(self.limits.max_subscriptions)
262+ .ok_or_else(|| anyhow::anyhow!("subscription byte budget is too large"))?;
263+ if self.websocket_path()? == "/healthz" {
264+ anyhow::bail!("public_url must not use the health endpoint");
265+ }
266+ Ok(())
267+ }
268+
269+ pub fn forge_keys(&self) -> anyhow::Result<HashSet<PublicKey>> {
270+ if self.forge_pubkeys.is_empty() {
271+ anyhow::bail!("forge_pubkeys must not be empty");
272+ }
273+ let mut set = HashSet::new();
274+ for raw in &self.forge_pubkeys {
275+ let trimmed = raw.trim();
276+ if trimmed.len() != 64 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
277+ anyhow::bail!("invalid forge public key: {raw}");
278+ }
279+ let key: PublicKey = trimmed
280+ .parse()
281+ .map_err(|error| anyhow::anyhow!("invalid forge public key {raw}: {error}"))?;
282+ if !set.insert(key) {
283+ anyhow::bail!("duplicate forge public key: {raw}");
284+ }
285+ }
286+ Ok(set)
287+ }
288+
289+ pub fn websocket_path(&self) -> anyhow::Result<String> {
290+ let url = url::Url::parse(&self.public_url)
291+ .map_err(|error| anyhow::anyhow!("invalid public_url {}: {error}", self.public_url))?;
292+ let path = url.path();
293+ if path.is_empty() {
294+ Ok("/".to_owned())
295+ } else if path.starts_with('/') {
296+ Ok(path.to_owned())
297+ } else {
298+ Ok(format!("/{path}"))
299+ }
300+ }
301+}
302+
303+pub(crate) fn relay_url(raw: &str) -> Option<url::Url> {
304+ let url = url::Url::parse(raw).ok()?;
305+ if !matches!(url.scheme(), "ws" | "wss")
306+ || url.host_str().is_none()
307+ || !url.username().is_empty()
308+ || url.password().is_some()
309+ || url.query().is_some()
310+ || url.fragment().is_some()
311+ {
312+ return None;
313+ }
314+ Some(url)
315+}
Added file: src/federation.rs+650 additions
@@ -0,0 +1,650 @@
1+use std::collections::{BTreeMap, HashMap, HashSet};
2+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
3+use std::sync::Arc;
4+use std::time::{Duration, SystemTime, UNIX_EPOCH};
5+
6+use anyhow::{Context as _, Result, bail};
7+use futures_util::{SinkExt as _, StreamExt as _};
8+use nostr::event::{Event, Kind};
9+use nostr::filter::{Filter, MatchEventOptions};
10+use nostr::key::PublicKey;
11+use nostr::message::MachineReadablePrefix;
12+use nostr_database::{NostrDatabase as _, RejectedReason, SaveEventStatus};
13+use serde_json::{Value, json};
14+use tokio::net::{TcpStream, lookup_host};
15+use tokio::time::{Instant, sleep, timeout, timeout_at};
16+use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
17+use tokio_tungstenite::{
18+ MaybeTlsStream, WebSocketStream, client_async_tls_with_config, connect_async_with_config,
19+};
20+use tokio_util::sync::CancellationToken;
21+use tokio_util::task::TaskTracker;
22+
23+use crate::config::{Config, Peer, relay_url};
24+use crate::policy::{Policy, WritePolicyResult, coordinate};
25+use crate::relay::Relay;
26+use crate::store::Store;
27+
28+const KINDS: [u16; 10] = [5, 1617, 1621, 1630, 1631, 1632, 10002, 30382, 30617, 30618];
29+const OVERLAP: u64 = 7 * 24 * 60 * 60;
30+const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
31+type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
32+
33+#[derive(Clone)]
34+struct Link {
35+ peer: Peer,
36+ config: Arc<Config>,
37+ store: Arc<Store>,
38+ relay: Relay,
39+ policy: Policy,
40+ discovered: bool,
41+ stop: CancellationToken,
42+}
43+
44+struct Connection {
45+ socket: Socket,
46+ stop: CancellationToken,
47+ write_timeout: Duration,
48+ request_sequence: u64,
49+}
50+
51+pub fn spawn(
52+ config: Arc<Config>,
53+ store: Arc<Store>,
54+ relay: Relay,
55+ policy: Policy,
56+ task_tracker: &TaskTracker,
57+ stop: CancellationToken,
58+) {
59+ let tasks = task_tracker.clone();
60+ task_tracker
61+ .clone()
62+ .spawn(manage(config, store, relay, policy, tasks, stop));
63+}
64+
65+async fn manage(
66+ config: Arc<Config>,
67+ store: Arc<Store>,
68+ relay: Relay,
69+ policy: Policy,
70+ tasks: TaskTracker,
71+ stop: CancellationToken,
72+) {
73+ let mut active = HashMap::<String, CancellationToken>::new();
74+ loop {
75+ let mut desired = BTreeMap::new();
76+ for configured_peer in &config.peers {
77+ let mut normalized_peer = configured_peer.clone();
78+ let Some(url) = relay_url(&normalized_peer.url) else {
79+ continue;
80+ };
81+ normalized_peer.url = url.to_string();
82+ desired.insert(normalized_peer.url.clone(), (normalized_peer, false));
83+ }
84+ match store
85+ .journal
86+ .discovered(config.limits.max_discovered_peers)
87+ .await
88+ {
89+ Ok(urls) => {
90+ for raw in urls {
91+ if let Some(peer) = discovered_peer(&config, &raw) {
92+ desired.entry(peer.url.clone()).or_insert((peer, true));
93+ }
94+ }
95+ }
96+ Err(error) => tracing::error!(%error, "loading discovered peers failed"),
97+ }
98+
99+ active.retain(|url, peer_stop| {
100+ if desired.contains_key(url) {
101+ true
102+ } else {
103+ peer_stop.cancel();
104+ false
105+ }
106+ });
107+ for (url, (peer, discovered)) in desired {
108+ if active.contains_key(&url) {
109+ continue;
110+ }
111+ let peer_stop = stop.child_token();
112+ let link = Link {
113+ peer,
114+ config: Arc::clone(&config),
115+ store: Arc::clone(&store),
116+ relay: relay.clone(),
117+ policy: policy.clone(),
118+ discovered,
119+ stop: peer_stop.clone(),
120+ };
121+ tasks.spawn(run(link.clone(), true));
122+ tasks.spawn(run(link, false));
123+ active.insert(url, peer_stop);
124+ }
125+
126+ tokio::select! {
127+ () = stop.cancelled() => break,
128+ () = sleep(Duration::from_secs(10)) => {}
129+ }
130+ }
131+ for peer_stop in active.into_values() {
132+ peer_stop.cancel();
133+ }
134+}
135+
136+fn discovered_peer(config: &Config, raw: &str) -> Option<Peer> {
137+ let url = relay_url(raw)?;
138+ if (!config.allow_private_discovery && url.scheme() != "wss")
139+ || url == relay_url(&config.public_url)?
140+ {
141+ return None;
142+ }
143+ Some(Peer {
144+ url: url.to_string(),
145+ query_limit: config.limits.max_query_results,
146+ rescan_from: None,
147+ })
148+}
149+
150+async fn run(link: Link, outbound: bool) {
151+ let direction = if outbound { "outbound" } else { "inbound" };
152+ let mut backoff = 5;
153+ while !link.stop.is_cancelled() {
154+ let started = Instant::now();
155+ let result = if outbound {
156+ publish(&link).await
157+ } else {
158+ subscribe(&link).await
159+ };
160+ if link.stop.is_cancelled() {
161+ break;
162+ }
163+ if let Err(error) = result {
164+ tracing::warn!(peer = %link.peer.url, %direction, %error, "peer connection interrupted");
165+ }
166+ if started.elapsed() > Duration::from_secs(60) {
167+ backoff = 5;
168+ }
169+ tokio::select! {
170+ () = link.stop.cancelled() => break,
171+ () = sleep(Duration::from_secs(backoff)) => {},
172+ }
173+ backoff = (backoff * 2).min(60);
174+ }
175+}
176+
177+impl Connection {
178+ async fn open(link: &Link) -> Result<Self> {
179+ let config = WebSocketConfig::default()
180+ .max_message_size(Some(link.config.limits.max_message_bytes))
181+ .max_frame_size(Some(link.config.limits.max_message_bytes));
182+ let connected = async {
183+ if link.discovered {
184+ connect_discovered(link, config).await
185+ } else {
186+ connect_async_with_config(&link.peer.url, Some(config), false)
187+ .await
188+ .map(|(socket, _)| socket)
189+ .map_err(Into::into)
190+ }
191+ };
192+ let socket = tokio::select! {
193+ () = link.stop.cancelled() => bail!("stopping"),
194+ result = timeout(RESPONSE_TIMEOUT, connected) => {
195+ result.context("peer connection timed out")??
196+ }
197+ };
198+ Ok(Self {
199+ socket,
200+ stop: link.stop.clone(),
201+ write_timeout: Duration::from_secs(link.config.limits.write_timeout_seconds),
202+ request_sequence: 0,
203+ })
204+ }
205+
206+ async fn send(&mut self, value: Value) -> Result<()> {
207+ self.send_message(Message::Text(value.to_string().into()))
208+ .await
209+ }
210+
211+ async fn send_message(&mut self, message: Message) -> Result<()> {
212+ tokio::select! {
213+ () = self.stop.cancelled() => bail!("stopping"),
214+ result = timeout(self.write_timeout, self.socket.send(message)) => {
215+ result.context("peer write timed out")??;
216+ Ok(())
217+ }
218+ }
219+ }
220+
221+ async fn next(&mut self, deadline: Instant) -> Result<Option<Value>> {
222+ loop {
223+ let message = tokio::select! {
224+ () = self.stop.cancelled() => bail!("stopping"),
225+ result = timeout_at(deadline, self.socket.next()) => match result {
226+ Err(_) => return Ok(None),
227+ Ok(Some(message)) => message?,
228+ Ok(None) => bail!("peer disconnected"),
229+ }
230+ };
231+ match message {
232+ Message::Text(text) => return Ok(Some(serde_json::from_str(&text)?)),
233+ Message::Ping(payload) => self.send_message(Message::Pong(payload)).await?,
234+ Message::Close(_) => bail!("peer closed the connection"),
235+ Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {}
236+ }
237+ }
238+ }
239+}
240+
241+async fn connect_discovered(link: &Link, config: WebSocketConfig) -> Result<Socket> {
242+ let url = url::Url::parse(&link.peer.url)?;
243+ let host = url.host_str().context("discovered peer has no host")?;
244+ let port = url
245+ .port_or_known_default()
246+ .context("discovered peer has no port")?;
247+ let mut last_error = None;
248+ for address in lookup_host((host, port)).await? {
249+ if !link.config.allow_private_discovery && !public_ip(address.ip()) {
250+ continue;
251+ }
252+ match TcpStream::connect(address).await {
253+ Ok(stream) => {
254+ return client_async_tls_with_config(&link.peer.url, stream, Some(config), None)
255+ .await
256+ .map(|(socket, _)| socket)
257+ .map_err(Into::into);
258+ }
259+ Err(error) => last_error = Some(error),
260+ }
261+ }
262+ match last_error {
263+ Some(error) => Err(error.into()),
264+ None => bail!("discovered peer did not resolve to an allowed address"),
265+ }
266+}
267+
268+fn public_ip(address: IpAddr) -> bool {
269+ match address {
270+ IpAddr::V4(ipv4) => public_ipv4(ipv4),
271+ IpAddr::V6(ipv6) => public_ipv6(ipv6),
272+ }
273+}
274+
275+fn public_ipv4(ip: Ipv4Addr) -> bool {
276+ let [first, second, _, _] = ip.octets();
277+ !(ip.is_private()
278+ || ip.is_loopback()
279+ || ip.is_link_local()
280+ || ip.is_broadcast()
281+ || ip.is_documentation()
282+ || ip.is_unspecified()
283+ || ip.is_multicast()
284+ || first == 0
285+ || (first == 100 && (64..=127).contains(&second))
286+ || (first == 198 && matches!(second, 18 | 19)))
287+}
288+
289+fn public_ipv6(ip: Ipv6Addr) -> bool {
290+ if let Some(ipv4) = ip.to_ipv4_mapped() {
291+ return public_ipv4(ipv4);
292+ }
293+ let segments = ip.segments();
294+ !(ip.is_loopback()
295+ || ip.is_unspecified()
296+ || ip.is_unique_local()
297+ || ip.is_unicast_link_local()
298+ || ip.is_multicast()
299+ || (segments[0] == 0x2001 && segments[1] == 0x0db8))
300+}
301+
302+async fn publish(link: &Link) -> Result<()> {
303+ let mut connection = Connection::open(link).await?;
304+ let mut announced = HashSet::new();
305+ loop {
306+ let pending = link.store.journal.pending(&link.peer.url, 32).await?;
307+ if pending.is_empty() {
308+ connection
309+ .next(Instant::now() + Duration::from_secs(1))
310+ .await?;
311+ continue;
312+ }
313+ for (sequence, id) in pending {
314+ if announced.len() >= link.config.limits.max_repositories {
315+ announced.clear();
316+ }
317+ if let Some(event) = link.store.event_by_id(&id).await? {
318+ let repository_event = repository_coordinate(&event).is_some();
319+ if let Some(announcement) = announcement_for(link, &event).await? {
320+ if announced.insert(announcement.id) {
321+ if let Some(error) = publish_event(&mut connection, &announcement).await? {
322+ announced.remove(&announcement.id);
323+ defer(link, sequence, &error).await?;
324+ continue;
325+ }
326+ }
327+ } else if repository_event {
328+ link.store
329+ .journal
330+ .acknowledge(&link.peer.url, sequence)
331+ .await?;
332+ continue;
333+ }
334+ if let Some(error) = publish_event(&mut connection, &event).await? {
335+ if link.store.event_by_id(&id).await?.is_some() {
336+ defer(link, sequence, &error).await?;
337+ continue;
338+ }
339+ } else if event.kind == Kind::from(30617) {
340+ announced.insert(event.id);
341+ }
342+ }
343+ link.store
344+ .journal
345+ .acknowledge(&link.peer.url, sequence)
346+ .await?;
347+ }
348+ }
349+}
350+
351+async fn defer(link: &Link, sequence: u64, error: &str) -> Result<()> {
352+ let retry_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 300;
353+ link.store
354+ .journal
355+ .defer(&link.peer.url, sequence, error, retry_at)
356+ .await?;
357+ tracing::warn!(peer = %link.peer.url, sequence, %error, "peer delivery deferred");
358+ Ok(())
359+}
360+
361+async fn publish_event(connection: &mut Connection, event: &Event) -> Result<Option<String>> {
362+ tokio::select! {
363+ () = connection.stop.cancelled() => bail!("stopping"),
364+ () = sleep(Duration::from_secs(1)) => {},
365+ }
366+ connection.send(json!(["EVENT", event])).await?;
367+ let id = event.id.to_hex();
368+ let deadline = Instant::now() + RESPONSE_TIMEOUT;
369+ loop {
370+ let frame = connection
371+ .next(deadline)
372+ .await?
373+ .context("peer acknowledgement timed out")?;
374+ if frame[0] == "OK" && frame[1].as_str() == Some(&id) {
375+ let reason = frame[3].as_str().unwrap_or("peer rejected event");
376+ return Ok(if frame[2] == true || reason.starts_with("duplicate:") {
377+ None
378+ } else {
379+ Some(reason.chars().take(512).collect())
380+ });
381+ }
382+ }
383+}
384+
385+async fn announcement_for(link: &Link, event: &Event) -> Result<Option<Event>> {
386+ let Some((author, identifier)) = repository_coordinate(event) else {
387+ return Ok(None);
388+ };
389+ Ok(link
390+ .store
391+ .query(
392+ Filter::new()
393+ .kind(Kind::from(30617))
394+ .author(author)
395+ .identifier(identifier)
396+ .limit(1),
397+ )
398+ .await?
399+ .into_iter()
400+ .next())
401+}
402+
403+fn repository_coordinate(event: &Event) -> Option<(PublicKey, String)> {
404+ if event.kind == Kind::from(30618) {
405+ event
406+ .tags
407+ .identifier()
408+ .map(|identifier| (event.pubkey, identifier))
409+ } else if matches!(event.kind.as_u16(), 1617 | 1621 | 1630..=1632) {
410+ event.tags.iter().find_map(|tag| {
411+ let values = tag.as_slice();
412+ if values.first().map(String::as_str) != Some("a") {
413+ return None;
414+ }
415+ let (kind, author, identifier) = coordinate(values.get(1)?)?;
416+ if kind != 30617 {
417+ return None;
418+ }
419+ Some((author, identifier.to_owned()))
420+ })
421+ } else {
422+ None
423+ }
424+}
425+
426+async fn import(link: &Link, value: Value) -> Result<()> {
427+ let event: Event = serde_json::from_value(value)?;
428+ if event.verify().is_err() {
429+ tracing::debug!(peer = %link.peer.url, event = %event.id, "ignored invalid peer event");
430+ return Ok(());
431+ }
432+ match link.policy.admit_peer(&event).await {
433+ WritePolicyResult::Accept => {}
434+ WritePolicyResult::Respond { status: true, .. } => return Ok(()),
435+ WritePolicyResult::Respond {
436+ prefix: MachineReadablePrefix::Error | MachineReadablePrefix::RateLimited,
437+ message,
438+ ..
439+ } => bail!("peer import paused by {message}"),
440+ WritePolicyResult::Respond { message, .. } => {
441+ tracing::debug!(peer = %link.peer.url, event = %event.id, %message, "ignored out of scope peer event");
442+ return Ok(());
443+ }
444+ }
445+ match link.relay.add_event(event).await? {
446+ SaveEventStatus::Success
447+ | SaveEventStatus::Rejected(
448+ RejectedReason::Duplicate
449+ | RejectedReason::Deleted
450+ | RejectedReason::Expired
451+ | RejectedReason::Replaced
452+ | RejectedReason::InvalidDelete
453+ | RejectedReason::Vanished,
454+ ) => Ok(()),
455+ status @ SaveEventStatus::Rejected(_) => {
456+ bail!("peer event was not stored with status {status:?}")
457+ }
458+ }
459+}
460+
461+async fn subscribe(link: &Link) -> Result<()> {
462+ let mut connection = Connection::open(link).await?;
463+ connection
464+ .send(json!(["REQ", "live", {"kinds": KINDS, "limit": 0_u8}]))
465+ .await?;
466+ let deadline = Instant::now() + RESPONSE_TIMEOUT;
467+ loop {
468+ let frame = connection
469+ .next(deadline)
470+ .await?
471+ .context("live subscription timed out")?;
472+ if frame[0] == "EOSE" && frame[1] == "live" {
473+ break;
474+ }
475+ handle_live(link, frame).await?;
476+ }
477+ let mut completed = HashSet::new();
478+ let mut next_replay = Instant::now();
479+ loop {
480+ let replay_all = Instant::now() >= next_replay;
481+ let until = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
482+ for (scope, filter) in [
483+ ("announcements".to_owned(), json!({"kinds": [30_617_u16]})),
484+ ("bindings".to_owned(), json!({"kinds": [30_382_u16]})),
485+ ("relays".to_owned(), json!({"kinds": [10_002_u16]})),
486+ ] {
487+ if replay_all || !completed.contains(&scope) {
488+ replay(link, &mut connection, &scope, filter, until).await?;
489+ completed.insert(scope);
490+ }
491+ }
492+ let mut active_scopes = HashSet::from([
493+ "announcements".to_owned(),
494+ "bindings".to_owned(),
495+ "relays".to_owned(),
496+ ]);
497+ for (author, identifier) in link
498+ .store
499+ .journal
500+ .repositories(link.config.limits.max_repositories)
501+ .await?
502+ {
503+ let address = format!("30617:{author}:{identifier}");
504+ for (scope, filter) in [
505+ (
506+ format!("state:{address}"),
507+ json!({"kinds": [30_618_u16], "authors": [author], "#d": [identifier]}),
508+ ),
509+ (
510+ format!("contributions:{address}"),
511+ json!({"kinds": [1_617_u16, 1_621_u16, 1_630_u16, 1_631_u16, 1_632_u16], "#a": [address]}),
512+ ),
513+ ] {
514+ active_scopes.insert(scope.clone());
515+ if replay_all || !completed.contains(&scope) {
516+ replay(link, &mut connection, &scope, filter, until).await?;
517+ completed.insert(scope);
518+ }
519+ }
520+ }
521+ completed.retain(|scope| active_scopes.contains(scope));
522+ if replay_all {
523+ replay(
524+ link,
525+ &mut connection,
526+ "deletions",
527+ json!({"kinds": [5_u16]}),
528+ until,
529+ )
530+ .await?;
531+ next_replay = Instant::now() + Duration::from_secs(300);
532+ }
533+ let refresh = Instant::now() + Duration::from_secs(30);
534+ while let Some(frame) = connection.next(refresh).await? {
535+ handle_live(link, frame).await?;
536+ }
537+ }
538+}
539+
540+async fn handle_live(link: &Link, frame: Value) -> Result<()> {
541+ if frame[1] != "live" {
542+ return Ok(());
543+ }
544+ match frame[0].as_str() {
545+ Some("EVENT") => import(link, frame[2].clone()).await,
546+ Some("CLOSED") => bail!("peer closed live subscription with {}", frame[2]),
547+ _ => Ok(()),
548+ }
549+}
550+
551+async fn replay(
552+ link: &Link,
553+ connection: &mut Connection,
554+ scope: &str,
555+ filter: Value,
556+ until: u64,
557+) -> Result<()> {
558+ let checkpoint = link
559+ .store
560+ .journal
561+ .checkpoint(&link.peer.url, scope)
562+ .await?
563+ .unwrap_or(0);
564+ let overlap_start = checkpoint.saturating_sub(OVERLAP);
565+ let replay_start = link
566+ .peer
567+ .rescan_from
568+ .map_or(overlap_start, |from| from.min(overlap_start));
569+ let mut ranges = vec![(replay_start.min(until), until)];
570+ while let Some((since, range_until)) = ranges.pop() {
571+ if replay_range(link, connection, filter.clone(), since, range_until).await? {
572+ link.store
573+ .journal
574+ .advance(&link.peer.url, scope, range_until)
575+ .await?;
576+ } else {
577+ if since == range_until {
578+ bail!(
579+ "recovery required for {scope} at {since}, one second exceeds the peer history limit"
580+ );
581+ }
582+ let middle = since + (range_until - since) / 2;
583+ ranges.push((middle + 1, range_until));
584+ ranges.push((since, middle));
585+ }
586+ }
587+ Ok(())
588+}
589+
590+async fn replay_range(
591+ link: &Link,
592+ connection: &mut Connection,
593+ mut filter: Value,
594+ since: u64,
595+ until: u64,
596+) -> Result<bool> {
597+ let pause = Instant::now() + Duration::from_secs(1);
598+ while let Some(frame) = connection.next(pause).await? {
599+ handle_live(link, frame).await?;
600+ }
601+ filter["since"] = json!(since);
602+ filter["until"] = json!(until);
603+ filter["limit"] = json!(link.peer.query_limit);
604+ let matching: Filter = serde_json::from_value(filter.clone())?;
605+ connection.request_sequence += 1;
606+ let subscription = format!("history-{}", connection.request_sequence);
607+ connection
608+ .send(json!(["REQ", subscription, filter]))
609+ .await?;
610+ let deadline = Instant::now() + RESPONSE_TIMEOUT;
611+ let mut received = HashSet::new();
612+ let complete = loop {
613+ let frame = connection
614+ .next(deadline)
615+ .await?
616+ .context("peer history timed out")?;
617+ if frame[1] != subscription {
618+ handle_live(link, frame).await?;
619+ continue;
620+ }
621+ match frame[0].as_str() {
622+ Some("EVENT") => {
623+ let event: Event = serde_json::from_value(frame[2].clone())?;
624+ if !matching.match_event(&event, MatchEventOptions::new()) {
625+ bail!("peer returned an event outside the requested history");
626+ }
627+ received.insert(event.id);
628+ import(link, frame[2].clone()).await?;
629+ if received.len() >= link.peer.query_limit {
630+ break false;
631+ }
632+ }
633+ Some("EOSE") => break true,
634+ Some("CLOSED") => {
635+ let message = frame[2].as_str().unwrap_or_default();
636+ if (message.starts_with("rate-limited:") || message.starts_with("error:"))
637+ && (message.contains("byte limit")
638+ || message.contains("result limit")
639+ || message.contains("too many results"))
640+ {
641+ break false;
642+ }
643+ bail!("peer closed history with {message}");
644+ }
645+ _ => {}
646+ }
647+ };
648+ connection.send(json!(["CLOSE", subscription])).await?;
649+ Ok(complete)
650+}
Added file: src/journal.rs+1028 additions
@@ -0,0 +1,1028 @@
1+use std::cmp::Ordering;
2+use std::collections::{BTreeSet, HashSet};
3+use std::error::Error;
4+use std::path::{Path, PathBuf};
5+use std::sync::Arc;
6+use std::time::{Duration, SystemTime, UNIX_EPOCH};
7+
8+use anyhow::{Context as _, Result};
9+use nostr::event::{Event, EventId, Kind, Signature, Tags};
10+use nostr::filter::Filter;
11+use nostr::key::PublicKey;
12+use nostr::types::Timestamp;
13+use nostr_database::{DatabaseEventStatus, RejectedReason, SaveEventStatus};
14+use rusqlite::{
15+ Connection, OptionalExtension as _, Params, Row, Transaction, params, params_from_iter,
16+};
17+use tokio::sync::{Mutex, MutexGuard};
18+use tokio::task::spawn_blocking;
19+
20+use crate::config::relay_url;
21+use crate::query::EventQuery;
22+
23+const SCHEMA: &str = "
24+CREATE TABLE IF NOT EXISTS events(
25+ id BLOB PRIMARY KEY NOT NULL CHECK(length(id) = 32),
26+ pubkey BLOB NOT NULL CHECK(length(pubkey) = 32),
27+ created_at INTEGER NOT NULL CHECK(created_at > 0),
28+ kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 65535),
29+ content TEXT NOT NULL,
30+ tags JSONB NOT NULL,
31+ sig BLOB NOT NULL CHECK(length(sig) = 64)
32+) WITHOUT ROWID;
33+
34+CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
35+CREATE INDEX IF NOT EXISTS idx_events_pubkey_created_at ON events(pubkey, created_at DESC);
36+CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind_created_at
37+ON events(pubkey, kind, created_at DESC);
38+CREATE INDEX IF NOT EXISTS idx_events_kind_created_at ON events(kind, created_at DESC);
39+
40+DROP INDEX IF EXISTS idx_events_pubkey;
41+DROP INDEX IF EXISTS idx_events_kind;
42+
43+CREATE TABLE IF NOT EXISTS event_tags(
44+ event_id BLOB NOT NULL CHECK(length(event_id) = 32),
45+ tag_name TEXT NOT NULL CHECK(length(tag_name) = 1),
46+ tag_value TEXT NOT NULL,
47+ FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE,
48+ PRIMARY KEY(tag_name, tag_value, event_id)
49+);
50+
51+CREATE INDEX IF NOT EXISTS idx_tags_event_id ON event_tags(event_id);
52+
53+CREATE TABLE IF NOT EXISTS deleted_ids(
54+ event_id BLOB PRIMARY KEY NOT NULL CHECK(length(event_id) = 32)
55+) WITHOUT ROWID;
56+
57+CREATE TABLE IF NOT EXISTS deleted_coordinates(
58+ pubkey BLOB NOT NULL CHECK(length(pubkey) = 32),
59+ kind INTEGER NOT NULL CHECK(kind BETWEEN 0 AND 65535),
60+ identifier TEXT NOT NULL,
61+ deleted_at INTEGER NOT NULL CHECK(deleted_at > 0),
62+ PRIMARY KEY(pubkey, kind, identifier)
63+) WITHOUT ROWID;
64+
65+DROP TRIGGER IF EXISTS sanctum_deleted_after_insert;
66+DROP TABLE IF EXISTS sanctum_deleted_events;
67+
68+CREATE TABLE IF NOT EXISTS vineyard_delivery(
69+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
70+ event_id BLOB NOT NULL UNIQUE REFERENCES events(id) ON DELETE CASCADE
71+);
72+
73+CREATE TABLE IF NOT EXISTS vineyard_peers(
74+ peer TEXT PRIMARY KEY,
75+ delivered INTEGER NOT NULL DEFAULT 0
76+);
77+
78+CREATE TABLE IF NOT EXISTS vineyard_checkpoints(
79+ peer TEXT,
80+ scope TEXT,
81+ until INTEGER NOT NULL,
82+ PRIMARY KEY(peer, scope)
83+);
84+
85+CREATE TABLE IF NOT EXISTS vineyard_retries(
86+ peer TEXT,
87+ sequence INTEGER REFERENCES vineyard_delivery(sequence) ON DELETE CASCADE,
88+ retry_at INTEGER,
89+ error TEXT,
90+ PRIMARY KEY(peer, sequence)
91+);
92+
93+CREATE TABLE IF NOT EXISTS vineyard_discovered_peers(
94+ signer BLOB,
95+ url TEXT,
96+ discovered_at INTEGER,
97+ PRIMARY KEY(signer, url)
98+) WITHOUT ROWID;
99+
100+CREATE INDEX IF NOT EXISTS vineyard_retries_due
101+ON vineyard_retries(peer, retry_at, sequence);
102+
103+CREATE INDEX IF NOT EXISTS vineyard_discovered_urls
104+ON vineyard_discovered_peers(url);
105+
106+CREATE TRIGGER IF NOT EXISTS vineyard_delivery_after_insert
107+AFTER INSERT ON events
108+BEGIN
109+ INSERT OR IGNORE INTO vineyard_delivery(event_id) VALUES (NEW.id);
110+END;
111+
112+";
113+
114+const BACKFILL_DELIVERY: &str = "
115+INSERT INTO vineyard_delivery(event_id)
116+SELECT event.id
117+FROM events AS event
118+LEFT JOIN vineyard_delivery AS delivery ON delivery.event_id = event.id
119+WHERE delivery.event_id IS NULL
120+ORDER BY
121+ CASE WHEN event.kind = 30617 THEN 0 ELSE 1 END,
122+ event.created_at,
123+ event.id
124+";
125+
126+const DISCOVERED_RELAY_TAGS: &str = "
127+SELECT event.pubkey, json_extract(tag.value, '$[1]'), event.created_at
128+FROM events AS event, json_each(event.tags) AS tag
129+WHERE event.kind = 10002
130+ AND json_extract(tag.value, '$[0]') = 'r'
131+ AND typeof(json_extract(tag.value, '$[1]')) = 'text'
132+ORDER BY event.created_at DESC, event.id
133+";
134+
135+const DUE_DELIVERIES: &str = "
136+SELECT delivery.sequence, delivery.event_id
137+FROM vineyard_delivery AS delivery
138+JOIN vineyard_retries AS retry ON retry.sequence = delivery.sequence
139+WHERE retry.peer = ?1 AND retry.retry_at <= ?2
140+ORDER BY delivery.sequence
141+LIMIT ?3
142+";
143+
144+const NEW_DELIVERIES: &str = "
145+SELECT sequence, event_id
146+FROM vineyard_delivery
147+WHERE sequence > ?1
148+ORDER BY sequence
149+LIMIT ?2
150+";
151+
152+const REPOSITORIES: &str = "
153+SELECT lower(hex(event.pubkey)), tag.tag_value, event.tags
154+FROM events AS event
155+JOIN event_tags AS tag ON event.id = tag.event_id
156+WHERE event.kind = 30617 AND tag.tag_name = 'd'
157+ORDER BY 1, 2, event.created_at DESC, event.id
158+";
159+
160+type Delivery = (u64, EventId);
161+
162+#[derive(Debug, Clone)]
163+pub struct Journal {
164+ connection: Arc<Mutex<Connection>>,
165+ writes: Arc<Mutex<()>>,
166+}
167+
168+struct StoredEvent {
169+ id: Vec<u8>,
170+ pubkey: Vec<u8>,
171+ created_at: i64,
172+ kind: i64,
173+ content: String,
174+ tags: String,
175+ signature: Vec<u8>,
176+}
177+
178+impl Journal {
179+ pub async fn open(
180+ path: PathBuf,
181+ max_database_bytes: u64,
182+ max_discovered_peers: usize,
183+ excluded_peers: Vec<String>,
184+ writes: Arc<Mutex<()>>,
185+ ) -> Result<Self> {
186+ let connection = spawn_blocking(move || {
187+ open_connection(
188+ &path,
189+ max_database_bytes,
190+ max_discovered_peers,
191+ &excluded_peers,
192+ )
193+ })
194+ .await
195+ .context("journal task panicked")??;
196+
197+ Ok(Self {
198+ connection: Arc::new(Mutex::new(connection)),
199+ writes,
200+ })
201+ }
202+
203+ pub async fn pending(&self, peer: &str, limit: usize) -> Result<Vec<Delivery>> {
204+ let peer_url = peer.to_owned();
205+ self.run(move |connection| pending_deliveries(connection, &peer_url, limit))
206+ .await
207+ }
208+
209+ pub async fn acknowledge(&self, peer: &str, sequence: u64) -> Result<()> {
210+ let peer_url = peer.to_owned();
211+ self.write(move |connection| acknowledge_delivery(connection, &peer_url, sequence))
212+ .await
213+ }
214+
215+ pub async fn defer(&self, peer: &str, sequence: u64, error: &str, retry_at: u64) -> Result<()> {
216+ let peer_url = peer.to_owned();
217+ let message = error.chars().take(512).collect::<String>();
218+ self.write(move |connection| {
219+ defer_delivery(connection, &peer_url, sequence, &message, retry_at)
220+ })
221+ .await
222+ }
223+
224+ pub async fn checkpoint(&self, peer: &str, scope: &str) -> Result<Option<u64>> {
225+ let peer_url = peer.to_owned();
226+ let scope_name = scope.to_owned();
227+ self.run(move |connection| read_checkpoint(connection, &peer_url, &scope_name))
228+ .await
229+ }
230+
231+ pub async fn advance(&self, peer: &str, scope: &str, until: u64) -> Result<()> {
232+ let peer_url = peer.to_owned();
233+ let scope_name = scope.to_owned();
234+ self.write(move |connection| write_checkpoint(connection, &peer_url, &scope_name, until))
235+ .await
236+ }
237+
238+ pub async fn repositories(&self, limit: usize) -> Result<Vec<(String, String)>> {
239+ self.run(move |connection| list_repositories(connection, limit))
240+ .await
241+ }
242+
243+ pub async fn healthy(&self) -> bool {
244+ self.run(|connection| Ok(connection.query_row("SELECT 1", [], |_| Ok(()))?))
245+ .await
246+ .is_ok()
247+ }
248+
249+ pub async fn save_locked(
250+ &self,
251+ event: Event,
252+ _write: &MutexGuard<'_, ()>,
253+ ) -> Result<SaveEventStatus> {
254+ self.run(move |connection| save_event(connection, &event))
255+ .await
256+ }
257+
258+ pub async fn status(&self, event_id: EventId) -> Result<DatabaseEventStatus> {
259+ self.run(move |connection| event_status(connection, &event_id))
260+ .await
261+ }
262+
263+ pub async fn event(&self, event_id: EventId) -> Result<Option<Event>> {
264+ self.run(move |connection| event_by_id(connection, &event_id))
265+ .await
266+ }
267+
268+ pub async fn count(&self, filter: Filter) -> Result<usize> {
269+ self.run(move |connection| Ok(query_events(connection, &filter, usize::MAX)?.len()))
270+ .await
271+ }
272+
273+ pub async fn query(&self, filter: Filter, max_bytes: usize) -> Result<BTreeSet<Event>> {
274+ self.run(move |connection| query_events(connection, &filter, max_bytes))
275+ .await
276+ }
277+
278+ pub async fn replace_discovered_locked(
279+ &self,
280+ signer: PublicKey,
281+ urls: Vec<String>,
282+ max: usize,
283+ _write: &MutexGuard<'_, ()>,
284+ ) -> Result<()> {
285+ let signer_bytes = signer.as_bytes().to_vec();
286+ self.run(move |connection| replace_discovered(connection, &signer_bytes, urls, max))
287+ .await
288+ }
289+
290+ pub async fn discovered(&self, limit: usize) -> Result<Vec<String>> {
291+ self.run(move |connection| list_discovered(connection, limit))
292+ .await
293+ }
294+
295+ pub async fn forget_discovered(&self, urls: Vec<String>) -> Result<()> {
296+ self.write(move |connection| forget_discovered(connection, urls))
297+ .await
298+ }
299+
300+ async fn write<T, F>(&self, operation: F) -> Result<T>
301+ where
302+ T: Send + 'static,
303+ F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
304+ {
305+ let _write = self.writes.lock().await;
306+ self.run(operation).await
307+ }
308+
309+ async fn run<T, F>(&self, operation: F) -> Result<T>
310+ where
311+ T: Send + 'static,
312+ F: FnOnce(&mut Connection) -> Result<T> + Send + 'static,
313+ {
314+ let shared_connection = Arc::clone(&self.connection);
315+ let mut connection = shared_connection.lock_owned().await;
316+ spawn_blocking(move || operation(&mut connection))
317+ .await
318+ .context("journal task panicked")?
319+ }
320+}
321+
322+fn open_connection(
323+ path: &Path,
324+ max_database_bytes: u64,
325+ max_discovered_peers: usize,
326+ excluded_peers: &[String],
327+) -> Result<Connection> {
328+ let mut connection =
329+ Connection::open(path).with_context(|| format!("opening journal {}", path.display()))?;
330+ configure(&connection, max_database_bytes)?;
331+ migrate(&mut connection, max_discovered_peers, excluded_peers)?;
332+ Ok(connection)
333+}
334+
335+fn pending_deliveries(connection: &Connection, peer: &str, limit: usize) -> Result<Vec<Delivery>> {
336+ if limit == 0 {
337+ return Ok(Vec::new());
338+ }
339+
340+ let retry_limit = limit / 2;
341+ let mut pending = if retry_limit == 0 {
342+ Vec::new()
343+ } else {
344+ read_deliveries(
345+ connection,
346+ DUE_DELIVERIES,
347+ params![peer, unix_seconds(), integer(retry_limit)?],
348+ )?
349+ };
350+
351+ let remaining = limit - pending.len();
352+ if remaining == 0 {
353+ return Ok(pending);
354+ }
355+
356+ let delivered = read_delivered(connection, peer)?;
357+ pending.extend(read_deliveries(
358+ connection,
359+ NEW_DELIVERIES,
360+ params![delivered, integer(remaining)?],
361+ )?);
362+ Ok(pending)
363+}
364+
365+fn read_deliveries<P>(connection: &Connection, sql: &str, parameters: P) -> Result<Vec<Delivery>>
366+where
367+ P: Params,
368+{
369+ let mut statement = connection.prepare(sql)?;
370+ let mut rows = statement.query(parameters)?;
371+ let mut deliveries = Vec::new();
372+
373+ while let Some(row) = rows.next()? {
374+ let sequence = row.get::<_, i64>(0)?;
375+ let event_id = row.get::<_, Vec<u8>>(1)?;
376+ deliveries.push((
377+ u64::try_from(sequence).context("negative delivery sequence")?,
378+ EventId::from_slice(&event_id)
379+ .map_err(|error| anyhow::anyhow!("invalid event id {error}"))?,
380+ ));
381+ }
382+
383+ Ok(deliveries)
384+}
385+
386+fn read_delivered(connection: &Connection, peer: &str) -> Result<i64> {
387+ Ok(connection
388+ .query_row(
389+ "SELECT delivered FROM vineyard_peers WHERE peer = ?1",
390+ [peer],
391+ |row| row.get(0),
392+ )
393+ .optional()?
394+ .unwrap_or(0))
395+}
396+
397+fn acknowledge_delivery(connection: &mut Connection, peer: &str, sequence: u64) -> Result<()> {
398+ let stored_sequence = integer(sequence)?;
399+ let transaction = connection.transaction()?;
400+ transaction.execute(
401+ "DELETE FROM vineyard_retries WHERE peer = ?1 AND sequence = ?2",
402+ params![peer, stored_sequence],
403+ )?;
404+ advance_delivery(&transaction, peer, stored_sequence)?;
405+ transaction.commit()?;
406+ Ok(())
407+}
408+
409+fn defer_delivery(
410+ connection: &mut Connection,
411+ peer: &str,
412+ sequence: u64,
413+ error: &str,
414+ retry_at: u64,
415+) -> Result<()> {
416+ let stored_sequence = integer(sequence)?;
417+ let stored_retry_at = integer(retry_at)?;
418+ let transaction = connection.transaction()?;
419+ advance_delivery(&transaction, peer, stored_sequence)?;
420+ transaction.execute(
421+ "
422+ INSERT INTO vineyard_retries(peer, sequence, retry_at, error)
423+ VALUES (?1, ?2, ?3, ?4)
424+ ON CONFLICT(peer, sequence) DO UPDATE SET
425+ retry_at = excluded.retry_at,
426+ error = excluded.error
427+ ",
428+ params![peer, stored_sequence, stored_retry_at, error],
429+ )?;
430+ transaction.commit()?;
431+ Ok(())
432+}
433+
434+fn read_checkpoint(connection: &Connection, peer: &str, scope: &str) -> Result<Option<u64>> {
435+ let checkpoint = connection
436+ .query_row(
437+ "SELECT until FROM vineyard_checkpoints WHERE peer = ?1 AND scope = ?2",
438+ params![peer, scope],
439+ |row| row.get::<_, i64>(0),
440+ )
441+ .optional()?;
442+
443+ checkpoint
444+ .map(|value| u64::try_from(value).context("negative checkpoint"))
445+ .transpose()
446+}
447+
448+fn write_checkpoint(connection: &Connection, peer: &str, scope: &str, until: u64) -> Result<()> {
449+ connection.execute(
450+ "
451+ INSERT INTO vineyard_checkpoints(peer, scope, until)
452+ VALUES (?1, ?2, ?3)
453+ ON CONFLICT(peer, scope) DO UPDATE SET
454+ until = max(vineyard_checkpoints.until, excluded.until)
455+ ",
456+ params![peer, scope, integer(until)?],
457+ )?;
458+ Ok(())
459+}
460+
461+fn list_repositories(connection: &Connection, limit: usize) -> Result<Vec<(String, String)>> {
462+ let mut statement = connection.prepare(REPOSITORIES)?;
463+ let mut rows = statement.query([])?;
464+ let mut repositories = Vec::new();
465+ let mut seen = BTreeSet::new();
466+ while let Some(row) = rows.next()? {
467+ let tags = serde_json::from_str::<Tags>(&row.get::<_, String>(2)?)?;
468+ if tags
469+ .expiration()
470+ .is_some_and(|expiration| expiration < Timestamp::now())
471+ {
472+ continue;
473+ }
474+ let repository = (row.get(0)?, row.get(1)?);
475+ if !seen.insert(repository.clone()) {
476+ continue;
477+ }
478+ repositories.push(repository);
479+ if repositories.len() == limit {
480+ break;
481+ }
482+ }
483+ Ok(repositories)
484+}
485+
486+fn event_status(connection: &Connection, event_id: &EventId) -> Result<DatabaseEventStatus> {
487+ let deleted = connection.query_row(
488+ "SELECT EXISTS(SELECT 1 FROM deleted_ids WHERE event_id = ?1)",
489+ [event_id.as_bytes().as_slice()],
490+ |row| row.get::<_, bool>(0),
491+ )?;
492+ if deleted {
493+ return Ok(DatabaseEventStatus::Deleted);
494+ }
495+ let saved = connection.query_row(
496+ "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
497+ [event_id.as_bytes().as_slice()],
498+ |row| row.get::<_, bool>(0),
499+ )?;
500+ Ok(if saved {
501+ DatabaseEventStatus::Saved
502+ } else {
503+ DatabaseEventStatus::NotExistent
504+ })
505+}
506+
507+fn event_by_id(connection: &Connection, event_id: &EventId) -> Result<Option<Event>> {
508+ connection
509+ .query_row(
510+ "
511+ SELECT id, pubkey, created_at, kind, content, tags, sig
512+ FROM events
513+ WHERE id = ?1
514+ ",
515+ [event_id.as_bytes().as_slice()],
516+ StoredEvent::read,
517+ )
518+ .optional()?
519+ .map(StoredEvent::into_event)
520+ .transpose()
521+}
522+
523+fn save_event(connection: &mut Connection, event: &Event) -> Result<SaveEventStatus> {
524+ if event.kind.is_ephemeral() {
525+ return Ok(SaveEventStatus::Rejected(RejectedReason::Ephemeral));
526+ }
527+
528+ let transaction = connection.transaction()?;
529+ if event_exists(&transaction, &event.id)? {
530+ return Ok(SaveEventStatus::Rejected(RejectedReason::Duplicate));
531+ }
532+ if event_deleted(&transaction, &event.id)? || coordinate_deleted(&transaction, event)? {
533+ return Ok(SaveEventStatus::Rejected(RejectedReason::Deleted));
534+ }
535+ if replacement_rejected(&transaction, event)? {
536+ return Ok(SaveEventStatus::Rejected(RejectedReason::Replaced));
537+ }
538+ if event.kind == Kind::EventDeletion && !apply_deletion(&transaction, event)? {
539+ return Ok(SaveEventStatus::Rejected(RejectedReason::InvalidDelete));
540+ }
541+
542+ transaction.execute(
543+ "
544+ INSERT INTO events(id, pubkey, created_at, kind, content, tags, sig)
545+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
546+ ",
547+ params![
548+ event.id.as_bytes().as_slice(),
549+ event.pubkey.as_bytes().as_slice(),
550+ integer(event.created_at.as_secs())?,
551+ i64::from(event.kind.as_u16()),
552+ &event.content,
553+ serde_json::to_string(&event.tags)?,
554+ event.sig.as_bytes().as_slice(),
555+ ],
556+ )?;
557+ for tag in event.tags.iter() {
558+ let (Some(name), Some(value)) = (tag.single_letter_tag(), tag.content()) else {
559+ continue;
560+ };
561+ transaction.execute(
562+ "
563+ INSERT OR IGNORE INTO event_tags(event_id, tag_name, tag_value)
564+ VALUES (?1, ?2, ?3)
565+ ",
566+ params![event.id.as_bytes().as_slice(), name.to_string(), value],
567+ )?;
568+ }
569+ transaction.commit()?;
570+ Ok(SaveEventStatus::Success)
571+}
572+
573+fn event_exists(transaction: &Transaction<'_>, event_id: &EventId) -> Result<bool> {
574+ Ok(transaction.query_row(
575+ "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
576+ [event_id.as_bytes().as_slice()],
577+ |row| row.get(0),
578+ )?)
579+}
580+
581+fn event_deleted(transaction: &Transaction<'_>, event_id: &EventId) -> Result<bool> {
582+ Ok(transaction.query_row(
583+ "SELECT EXISTS(SELECT 1 FROM deleted_ids WHERE event_id = ?1)",
584+ [event_id.as_bytes().as_slice()],
585+ |row| row.get(0),
586+ )?)
587+}
588+
589+fn coordinate_deleted(transaction: &Transaction<'_>, event: &Event) -> Result<bool> {
590+ let Some(coordinate) = event.coordinate() else {
591+ return Ok(false);
592+ };
593+ let deleted_at = transaction
594+ .query_row(
595+ "
596+ SELECT deleted_at
597+ FROM deleted_coordinates
598+ WHERE pubkey = ?1 AND kind = ?2 AND identifier = ?3
599+ ",
600+ params![
601+ coordinate.public_key.as_bytes().as_slice(),
602+ i64::from(coordinate.kind.as_u16()),
603+ coordinate.identifier,
604+ ],
605+ |row| row.get::<_, i64>(0),
606+ )
607+ .optional()?;
608+ Ok(deleted_at.is_some_and(|stored_deleted_at| {
609+ u64::try_from(stored_deleted_at).map_or(true, |value| event.created_at.as_secs() <= value)
610+ }))
611+}
612+
613+fn replacement_rejected(transaction: &Transaction<'_>, event: &Event) -> Result<bool> {
614+ let existing = if event.kind.is_replaceable() {
615+ transaction
616+ .query_row(
617+ "
618+ SELECT id, created_at
619+ FROM events
620+ WHERE pubkey = ?1 AND kind = ?2
621+ LIMIT 1
622+ ",
623+ params![
624+ event.pubkey.as_bytes().as_slice(),
625+ i64::from(event.kind.as_u16()),
626+ ],
627+ |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
628+ )
629+ .optional()?
630+ } else if event.kind.is_addressable() {
631+ let Some(identifier) = event.tags.identifier() else {
632+ return Ok(false);
633+ };
634+ transaction
635+ .query_row(
636+ "
637+ SELECT event.id, event.created_at
638+ FROM events AS event
639+ JOIN event_tags AS tag ON tag.event_id = event.id
640+ WHERE event.pubkey = ?1
641+ AND event.kind = ?2
642+ AND tag.tag_name = 'd'
643+ AND tag.tag_value = ?3
644+ LIMIT 1
645+ ",
646+ params![
647+ event.pubkey.as_bytes().as_slice(),
648+ i64::from(event.kind.as_u16()),
649+ identifier,
650+ ],
651+ |row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
652+ )
653+ .optional()?
654+ } else {
655+ return Ok(false);
656+ };
657+
658+ let Some((stored_id, stored_at)) = existing else {
659+ return Ok(false);
660+ };
661+ let incoming_at = integer(event.created_at.as_secs())?;
662+ match stored_at.cmp(&incoming_at) {
663+ Ordering::Greater => Ok(true),
664+ Ordering::Equal if stored_id.as_slice() < event.id.as_bytes().as_slice() => Ok(true),
665+ Ordering::Equal | Ordering::Less => {
666+ transaction.execute("DELETE FROM events WHERE id = ?1", [stored_id])?;
667+ Ok(false)
668+ }
669+ }
670+}
671+
672+fn apply_deletion(transaction: &Transaction<'_>, event: &Event) -> Result<bool> {
673+ for event_id in event.tags.event_ids() {
674+ let owner = transaction
675+ .query_row(
676+ "SELECT pubkey FROM events WHERE id = ?1",
677+ [event_id.as_bytes().as_slice()],
678+ |row| row.get::<_, Vec<u8>>(0),
679+ )
680+ .optional()?;
681+ if owner.is_some_and(|saved_owner| saved_owner.as_slice() != event.pubkey.as_bytes()) {
682+ return Ok(false);
683+ }
684+ transaction.execute(
685+ "INSERT OR IGNORE INTO deleted_ids(event_id) VALUES (?1)",
686+ [event_id.as_bytes().as_slice()],
687+ )?;
688+ transaction.execute(
689+ "DELETE FROM events WHERE id = ?1 AND pubkey = ?2",
690+ params![
691+ event_id.as_bytes().as_slice(),
692+ event.pubkey.as_bytes().as_slice(),
693+ ],
694+ )?;
695+ }
696+
697+ for coordinate in event.tags.coordinates() {
698+ if coordinate.public_key != event.pubkey {
699+ return Ok(false);
700+ }
701+ transaction.execute(
702+ "
703+ INSERT INTO deleted_coordinates(pubkey, kind, identifier, deleted_at)
704+ VALUES (?1, ?2, ?3, ?4)
705+ ON CONFLICT(pubkey, kind, identifier) DO UPDATE SET
706+ deleted_at = max(deleted_coordinates.deleted_at, excluded.deleted_at)
707+ ",
708+ params![
709+ coordinate.public_key.as_bytes().as_slice(),
710+ i64::from(coordinate.kind.as_u16()),
711+ &coordinate.identifier,
712+ integer(event.created_at.as_secs())?,
713+ ],
714+ )?;
715+ if coordinate.kind.is_replaceable() {
716+ transaction.execute(
717+ "
718+ DELETE FROM events
719+ WHERE pubkey = ?1 AND kind = ?2 AND created_at <= ?3
720+ ",
721+ params![
722+ coordinate.public_key.as_bytes().as_slice(),
723+ i64::from(coordinate.kind.as_u16()),
724+ integer(event.created_at.as_secs())?,
725+ ],
726+ )?;
727+ } else {
728+ transaction.execute(
729+ "
730+ DELETE FROM events
731+ WHERE id IN (
732+ SELECT stored.id
733+ FROM events AS stored
734+ JOIN event_tags AS tag ON tag.event_id = stored.id
735+ WHERE stored.pubkey = ?1
736+ AND stored.kind = ?2
737+ AND stored.created_at <= ?3
738+ AND tag.tag_name = 'd'
739+ AND tag.tag_value = ?4
740+ )
741+ ",
742+ params![
743+ coordinate.public_key.as_bytes().as_slice(),
744+ i64::from(coordinate.kind.as_u16()),
745+ integer(event.created_at.as_secs())?,
746+ &coordinate.identifier,
747+ ],
748+ )?;
749+ }
750+ }
751+ Ok(true)
752+}
753+
754+fn query_events(
755+ connection: &Connection,
756+ filter: &Filter,
757+ max_bytes: usize,
758+) -> Result<BTreeSet<Event>> {
759+ let query = EventQuery::new(filter)?;
760+ if query.limit == 0 {
761+ return Ok(BTreeSet::new());
762+ }
763+ let mut statement = connection.prepare(&query.sql)?;
764+ let mut rows = statement.query(params_from_iter(query.parameters))?;
765+ let mut events = BTreeSet::new();
766+ let mut bytes = 0_usize;
767+
768+ while let Some(row) = rows.next()? {
769+ let event = StoredEvent::read(row)?.into_event()?;
770+ if event.is_expired() {
771+ continue;
772+ }
773+ bytes = bytes
774+ .checked_add(event.as_json().len())
775+ .context("history byte count overflow")?;
776+ if bytes > max_bytes {
777+ anyhow::bail!("history byte limit exceeded");
778+ }
779+ events.insert(event);
780+ if events.len() == query.limit {
781+ break;
782+ }
783+ }
784+
785+ Ok(events)
786+}
787+
788+fn replace_discovered(
789+ connection: &mut Connection,
790+ signer: &[u8],
791+ urls: Vec<String>,
792+ max: usize,
793+) -> Result<()> {
794+ let transaction = connection.transaction()?;
795+ transaction.execute(
796+ "DELETE FROM vineyard_discovered_peers WHERE signer = ?1",
797+ [signer],
798+ )?;
799+ let stored_count = transaction.query_row(
800+ "SELECT count(DISTINCT url) FROM vineyard_discovered_peers",
801+ [],
802+ |row| row.get::<_, i64>(0),
803+ )?;
804+ let count = usize::try_from(stored_count).context("negative discovered peer count")?;
805+
806+ for url in urls.into_iter().take(max.saturating_sub(count)) {
807+ transaction.execute(
808+ "
809+ INSERT OR IGNORE INTO vineyard_discovered_peers(signer, url, discovered_at)
810+ VALUES (?1, ?2, unixepoch())
811+ ",
812+ params![signer, url],
813+ )?;
814+ }
815+
816+ transaction.commit()?;
817+ Ok(())
818+}
819+
820+fn list_discovered(connection: &Connection, limit: usize) -> Result<Vec<String>> {
821+ let mut statement = connection
822+ .prepare("SELECT DISTINCT url FROM vineyard_discovered_peers ORDER BY url LIMIT ?1")?;
823+ let mut rows = statement.query([integer(limit)?])?;
824+ let mut urls = Vec::new();
825+ while let Some(row) = rows.next()? {
826+ urls.push(row.get(0)?);
827+ }
828+ Ok(urls)
829+}
830+
831+fn forget_discovered(connection: &mut Connection, urls: Vec<String>) -> Result<()> {
832+ let transaction = connection.transaction()?;
833+ for url in urls {
834+ transaction.execute(
835+ "DELETE FROM vineyard_discovered_peers WHERE url = ?1",
836+ [url],
837+ )?;
838+ }
839+ transaction.commit()?;
840+ Ok(())
841+}
842+
843+impl StoredEvent {
844+ fn read(row: &Row<'_>) -> rusqlite::Result<Self> {
845+ Ok(Self {
846+ id: row.get(0)?,
847+ pubkey: row.get(1)?,
848+ created_at: row.get(2)?,
849+ kind: row.get(3)?,
850+ content: row.get(4)?,
851+ tags: row.get(5)?,
852+ signature: row.get(6)?,
853+ })
854+ }
855+
856+ fn into_event(self) -> Result<Event> {
857+ let id = EventId::from_slice(&self.id).context("invalid stored event id")?;
858+ let pubkey = PublicKey::from_slice(&self.pubkey).context("invalid stored public key")?;
859+ let created_at: Timestamp = self
860+ .created_at
861+ .try_into()
862+ .context("invalid event timestamp")?;
863+ let kind = Kind::from_u16(self.kind.try_into().context("invalid stored event kind")?);
864+ let tags: Tags = serde_json::from_str(&self.tags).context("invalid stored event tags")?;
865+ let signature =
866+ Signature::from_slice(&self.signature).context("invalid stored event signature")?;
867+ Ok(Event::new(
868+ id,
869+ pubkey,
870+ created_at,
871+ kind,
872+ tags,
873+ self.content,
874+ signature,
875+ ))
876+ }
877+}
878+
879+fn configure(connection: &Connection, max_database_bytes: u64) -> Result<()> {
880+ connection.busy_timeout(Duration::from_secs(5))?;
881+ connection.pragma_update(None, "journal_mode", "WAL")?;
882+ let journal_mode: String =
883+ connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?;
884+ if !journal_mode.eq_ignore_ascii_case("wal") {
885+ anyhow::bail!("journal mode is not wal");
886+ }
887+
888+ connection.pragma_update(None, "synchronous", "FULL")?;
889+ connection.pragma_update(None, "foreign_keys", "ON")?;
890+
891+ let stored_page_size =
892+ connection.query_row("PRAGMA page_size", [], |row| row.get::<_, i64>(0))?;
893+ let page_size = u64::try_from(stored_page_size).context("invalid database page size")?;
894+ let page_budget = max_database_bytes / page_size;
895+ if page_budget == 0 {
896+ anyhow::bail!("database budget is smaller than one page");
897+ }
898+ let max_pages = integer(page_budget)?;
899+ let page_count = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0))?;
900+ if page_count > max_pages {
901+ anyhow::bail!("database exceeds size budget");
902+ }
903+ connection.pragma_update(None, "max_page_count", max_pages)?;
904+ Ok(())
905+}
906+
907+fn migrate(
908+ connection: &mut Connection,
909+ max_discovered_peers: usize,
910+ excluded_peers: &[String],
911+) -> Result<()> {
912+ let transaction = connection.transaction()?;
913+ migrate_legacy_tables(&transaction)?;
914+ transaction.execute_batch(SCHEMA)?;
915+ transaction.execute(BACKFILL_DELIVERY, [])?;
916+ transaction.commit()?;
917+ backfill_discovered(connection, max_discovered_peers, excluded_peers)?;
918+ Ok(())
919+}
920+
921+fn migrate_legacy_tables(transaction: &Transaction<'_>) -> Result<()> {
922+ for (old, new) in [
923+ ("sanctum_delivery", "vineyard_delivery"),
924+ ("sanctum_peers", "vineyard_peers"),
925+ ("sanctum_checkpoints", "vineyard_checkpoints"),
926+ ("sanctum_retries", "vineyard_retries"),
927+ ("sanctum_discovered_peers", "vineyard_discovered_peers"),
928+ ] {
929+ let old_exists = table_exists(transaction, old)?;
930+ let new_exists = table_exists(transaction, new)?;
931+ if old_exists && new_exists {
932+ anyhow::bail!("database contains both {old} and {new}");
933+ }
934+ if old_exists {
935+ transaction.execute_batch(&format!("ALTER TABLE {old} RENAME TO {new}"))?;
936+ }
937+ }
938+ transaction.execute_batch(
939+ "
940+ DROP TRIGGER IF EXISTS sanctum_delivery_after_insert;
941+ DROP INDEX IF EXISTS sanctum_retries_due;
942+ DROP INDEX IF EXISTS sanctum_discovered_urls;
943+ ",
944+ )?;
945+ Ok(())
946+}
947+
948+fn table_exists(transaction: &Transaction<'_>, name: &str) -> Result<bool> {
949+ Ok(transaction.query_row(
950+ "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
951+ [name],
952+ |row| row.get(0),
953+ )?)
954+}
955+
956+fn backfill_discovered(
957+ connection: &mut Connection,
958+ max_discovered_peers: usize,
959+ excluded_peers: &[String],
960+) -> Result<()> {
961+ let excluded = excluded_peers.iter().cloned().collect::<HashSet<_>>();
962+ let relay_tags = {
963+ let mut statement = connection.prepare(DISCOVERED_RELAY_TAGS)?;
964+ let mut rows = statement.query([])?;
965+ let mut relay_tags = Vec::new();
966+ while let Some(row) = rows.next()? {
967+ relay_tags.push((
968+ row.get::<_, Vec<u8>>(0)?,
969+ row.get::<_, String>(1)?,
970+ row.get::<_, i64>(2)?,
971+ ));
972+ }
973+ relay_tags
974+ };
975+
976+ let transaction = connection.transaction()?;
977+ transaction.execute("DELETE FROM vineyard_discovered_peers", [])?;
978+ let mut urls = HashSet::new();
979+ for (signer, raw_url, discovered_at) in relay_tags {
980+ let Some(url) = relay_url(&raw_url).map(|url| url.to_string()) else {
981+ continue;
982+ };
983+ if excluded.contains(&url) || (!urls.contains(&url) && urls.len() >= max_discovered_peers) {
984+ continue;
985+ }
986+ urls.insert(url.clone());
987+ transaction.execute(
988+ "
989+ INSERT OR IGNORE INTO vineyard_discovered_peers(signer, url, discovered_at)
990+ VALUES (?1, ?2, ?3)
991+ ",
992+ params![signer, url, discovered_at],
993+ )?;
994+ }
995+ transaction.commit()?;
996+ Ok(())
997+}
998+
999+fn advance_delivery(transaction: &Transaction<'_>, peer: &str, sequence: i64) -> Result<()> {
1000+ transaction.execute(
1001+ "
1002+ INSERT INTO vineyard_peers(peer, delivered)
1003+ VALUES (?1, ?2)
1004+ ON CONFLICT(peer) DO UPDATE SET
1005+ delivered = max(vineyard_peers.delivered, excluded.delivered)
1006+ ",
1007+ params![peer, sequence],
1008+ )?;
1009+ Ok(())
1010+}
1011+
1012+fn integer<T>(value: T) -> Result<i64>
1013+where
1014+ T: TryInto<i64>,
1015+ T::Error: Error + Send + Sync + 'static,
1016+{
1017+ value
1018+ .try_into()
1019+ .context("value exceeds sqlite integer range")
1020+}
1021+
1022+fn unix_seconds() -> i64 {
1023+ SystemTime::now()
1024+ .duration_since(UNIX_EPOCH)
1025+ .map_or(0, |duration| {
1026+ i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
1027+ })
1028+}
Added file: src/lib.rs+8 additions
@@ -0,0 +1,8 @@
1+pub mod config;
2+mod federation;
3+mod journal;
4+pub mod policy;
5+mod query;
6+mod relay;
7+pub mod server;
8+pub mod store;
Added file: src/main.rs+61 additions
@@ -0,0 +1,61 @@
1+use std::io::{Write as _, stdout};
2+use std::path::PathBuf;
3+
4+use anyhow::Context as _;
5+use clap::{Parser, Subcommand};
6+use tokio::signal::ctrl_c;
7+#[cfg(unix)]
8+use tokio::signal::unix::{SignalKind, signal};
9+use vineyard::config::Config;
10+use vineyard::server::spawn;
11+
12+#[derive(Parser)]
13+#[command(version, about = "a persistent nostr relay for git forges")]
14+struct Cli {
15+ #[command(subcommand)]
16+ command: Command,
17+}
18+
19+#[derive(Subcommand)]
20+enum Command {
21+ Serve {
22+ #[arg(long, default_value = "vineyard.toml")]
23+ config: PathBuf,
24+ },
25+ CheckConfig {
26+ #[arg(long, default_value = "vineyard.toml")]
27+ config: PathBuf,
28+ },
29+}
30+
31+#[tokio::main(worker_threads = 2)]
32+async fn main() -> anyhow::Result<()> {
33+ tracing_subscriber::fmt()
34+ .with_env_filter(
35+ tracing_subscriber::EnvFilter::try_from_default_env()
36+ .unwrap_or_else(|_| "vineyard=info".into()),
37+ )
38+ .init();
39+ match Cli::parse().command {
40+ Command::CheckConfig { config } => {
41+ Config::load(&config)?;
42+ writeln!(stdout().lock(), "configuration is valid")?;
43+ }
44+ Command::Serve { config } => {
45+ let relay = spawn(Config::load(&config)?).await?;
46+ #[cfg(unix)]
47+ {
48+ let mut terminate =
49+ signal(SignalKind::terminate()).context("installing termination handler")?;
50+ tokio::select! {
51+ result = ctrl_c() => result?,
52+ _ = terminate.recv() => {},
53+ }
54+ }
55+ #[cfg(not(unix))]
56+ ctrl_c().await?;
57+ relay.shutdown().await?;
58+ }
59+ }
60+ Ok(())
61+}
Added file: src/policy.rs+450 additions
@@ -0,0 +1,450 @@
1+use std::borrow::Cow;
2+use std::collections::{HashMap, HashSet};
3+use std::net::{IpAddr, SocketAddr};
4+use std::sync::{Arc, Mutex, PoisonError};
5+use std::time::{Duration, Instant};
6+
7+use nostr::event::{Event, EventId, Kind};
8+use nostr::filter::Filter;
9+use nostr::key::PublicKey;
10+use nostr::message::MachineReadablePrefix;
11+use nostr::nips::nip65::extract_relay_list;
12+use nostr::types::Timestamp;
13+use nostr_database::{DatabaseEventStatus, NostrDatabase, error::Error};
14+use serde::Deserialize;
15+
16+use crate::config::{Config, Limits};
17+
18+#[derive(Debug)]
19+struct Usage {
20+ window: Instant,
21+ events: u32,
22+ queries: u32,
23+}
24+
25+#[derive(Debug, Clone)]
26+pub struct Policy {
27+ database: Arc<dyn NostrDatabase>,
28+ limits: Limits,
29+ forge_keys: HashSet<PublicKey>,
30+ peers: Arc<Mutex<HashMap<IpAddr, Usage>>>,
31+}
32+
33+#[derive(Deserialize)]
34+#[serde(deny_unknown_fields)]
35+struct Binding {
36+ version: u64,
37+ action: String,
38+ fingerprint: String,
39+ certificate: Option<String>,
40+ signature: Option<String>,
41+}
42+
43+pub(crate) enum WritePolicyResult {
44+ Accept,
45+ Respond {
46+ prefix: MachineReadablePrefix,
47+ message: Cow<'static, str>,
48+ status: bool,
49+ },
50+}
51+
52+impl WritePolicyResult {
53+ const fn accepted(prefix: MachineReadablePrefix, message: &'static str) -> Self {
54+ Self::Respond {
55+ prefix,
56+ message: Cow::Borrowed(message),
57+ status: true,
58+ }
59+ }
60+
61+ const fn reject(prefix: MachineReadablePrefix, message: &'static str) -> Self {
62+ Self::Respond {
63+ prefix,
64+ message: Cow::Borrowed(message),
65+ status: false,
66+ }
67+ }
68+}
69+
70+pub(crate) enum QueryPolicyResult {
71+ Accept,
72+ Reject {
73+ prefix: MachineReadablePrefix,
74+ message: Cow<'static, str>,
75+ },
76+}
77+
78+impl QueryPolicyResult {
79+ const fn reject(prefix: MachineReadablePrefix, message: &'static str) -> Self {
80+ Self::Reject {
81+ prefix,
82+ message: Cow::Borrowed(message),
83+ }
84+ }
85+}
86+
87+impl Policy {
88+ pub fn new(database: Arc<dyn NostrDatabase>, config: &Config) -> anyhow::Result<Self> {
89+ config.validate()?;
90+ Ok(Self {
91+ database,
92+ limits: config.limits.clone(),
93+ forge_keys: config.forge_keys()?,
94+ peers: Arc::new(Mutex::new(HashMap::new())),
95+ })
96+ }
97+
98+ fn charge(&self, ip: IpAddr, publish: bool) -> bool {
99+ let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);
100+ peers.retain(|_, usage| usage.window.elapsed() < Duration::from_secs(60));
101+ if peers.len() >= 4096 && !peers.contains_key(&ip) {
102+ return false;
103+ }
104+ let usage = peers.entry(ip).or_insert_with(|| Usage {
105+ window: Instant::now(),
106+ events: 0,
107+ queries: 0,
108+ });
109+ let (count, limit) = if publish {
110+ (&mut usage.events, self.limits.events_per_minute)
111+ } else {
112+ (&mut usage.queries, self.limits.queries_per_minute)
113+ };
114+ if *count >= limit {
115+ return false;
116+ }
117+ *count += 1;
118+ drop(peers);
119+ true
120+ }
121+
122+ pub(crate) async fn admit_event(
123+ &self,
124+ event: &Event,
125+ address: &SocketAddr,
126+ ) -> WritePolicyResult {
127+ if !self.charge(address.ip(), true) {
128+ return WritePolicyResult::reject(
129+ MachineReadablePrefix::RateLimited,
130+ "publish rate exceeded",
131+ );
132+ }
133+ self.admit(event).await.unwrap_or_else(|error| {
134+ tracing::error!(%error, "event admission database failure");
135+ WritePolicyResult::reject(MachineReadablePrefix::Error, "database unavailable")
136+ })
137+ }
138+
139+ pub(crate) async fn admit_peer(&self, event: &Event) -> WritePolicyResult {
140+ match self.admit(event).await {
141+ Ok(result) => result,
142+ Err(error) => {
143+ tracing::error!(%error, "event admission database failure");
144+ WritePolicyResult::reject(MachineReadablePrefix::Error, "database unavailable")
145+ }
146+ }
147+ }
148+
149+ pub(crate) async fn admit_query(
150+ &self,
151+ filter: &Filter,
152+ address: &SocketAddr,
153+ ) -> QueryPolicyResult {
154+ if !self.charge(address.ip(), false) {
155+ return QueryPolicyResult::reject(
156+ MachineReadablePrefix::RateLimited,
157+ "query rate exceeded",
158+ );
159+ }
160+ if filter.as_json().len() > self.limits.max_filter_bytes {
161+ return QueryPolicyResult::reject(
162+ MachineReadablePrefix::RateLimited,
163+ "filter byte limit exceeded",
164+ );
165+ }
166+ if filter.search.is_some() {
167+ return QueryPolicyResult::reject(
168+ MachineReadablePrefix::Unsupported,
169+ "search is not supported",
170+ );
171+ }
172+ if let Some(limit) = filter.limit {
173+ return if limit > self.limits.max_query_results {
174+ QueryPolicyResult::reject(
175+ MachineReadablePrefix::RateLimited,
176+ "query result limit exceeded",
177+ )
178+ } else {
179+ QueryPolicyResult::Accept
180+ };
181+ }
182+ match self
183+ .database
184+ .count(filter.clone().limit(self.limits.max_query_results + 1))
185+ .await
186+ {
187+ Ok(count) if count <= self.limits.max_query_results => QueryPolicyResult::Accept,
188+ Ok(_) => QueryPolicyResult::reject(
189+ MachineReadablePrefix::RateLimited,
190+ "history result limit exceeded",
191+ ),
192+ Err(error) => {
193+ tracing::error!(%error, "query admission database failure");
194+ QueryPolicyResult::reject(MachineReadablePrefix::Error, "database unavailable")
195+ }
196+ }
197+ }
198+
199+ async fn admit(&self, event: &Event) -> Result<WritePolicyResult, Error> {
200+ if let Some(result) = self.check_event(event) {
201+ return Ok(result);
202+ }
203+ match self.database.check_id(&event.id).await? {
204+ DatabaseEventStatus::Saved => {
205+ return Ok(WritePolicyResult::accepted(
206+ MachineReadablePrefix::Duplicate,
207+ "already stored",
208+ ));
209+ }
210+ DatabaseEventStatus::Deleted => return Ok(blocked("event is deleted")),
211+ DatabaseEventStatus::NotExistent => {}
212+ }
213+ self.admit_kind(event).await
214+ }
215+
216+ fn check_event(&self, event: &Event) -> Option<WritePolicyResult> {
217+ if event.as_json().len() > self.limits.max_event_bytes
218+ || event.tags.len() > self.limits.max_tags
219+ {
220+ return Some(blocked("event exceeds size or tag limit"));
221+ }
222+ if event.created_at.as_secs() == 0
223+ || event.created_at.as_secs() > Timestamp::now().as_secs().saturating_add(900)
224+ {
225+ return Some(blocked("event timestamp is outside the supported range"));
226+ }
227+ if event.is_expired() || event.is_protected() {
228+ return Some(blocked("expired or protected events are not accepted"));
229+ }
230+ None
231+ }
232+
233+ async fn admit_kind(&self, event: &Event) -> Result<WritePolicyResult, Error> {
234+ match event.kind.as_u16() {
235+ 10002 => {
236+ if !self.forge_keys.contains(&event.pubkey)
237+ && !self.registered_forge(event.pubkey).await?
238+ {
239+ return Ok(blocked("relay list signer has no registered repository"));
240+ }
241+ let relay_tags = event
242+ .tags
243+ .iter()
244+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "r"))
245+ .count();
246+ if relay_tags > 16 || extract_relay_list(event).count() != relay_tags {
247+ return Ok(blocked("relay list is invalid or too large"));
248+ }
249+ }
250+ 30617 => {
251+ let Some(identifier) = identifier(event) else {
252+ return Ok(blocked("one valid repository identifier is required"));
253+ };
254+ if !self.registered(event.pubkey, identifier).await? {
255+ let count = self
256+ .database
257+ .query(
258+ Filter::new()
259+ .kind(Kind::from(30617))
260+ .limit(self.limits.max_repositories + 1),
261+ )
262+ .await?
263+ .len();
264+ if count >= self.limits.max_repositories {
265+ return Ok(WritePolicyResult::reject(
266+ MachineReadablePrefix::RateLimited,
267+ "repository directory is full",
268+ ));
269+ }
270+ }
271+ }
272+ 30618 => {
273+ let Some(identifier) = identifier(event) else {
274+ return Ok(blocked("one valid repository identifier is required"));
275+ };
276+ if !self.registered(event.pubkey, identifier).await? {
277+ return Ok(blocked("repository is not registered"));
278+ }
279+ }
280+ 1617 | 1621 | 1630..=1632 => {
281+ let mut addresses = 0_usize;
282+ for tag in event
283+ .tags
284+ .iter()
285+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "a"))
286+ {
287+ let Some((kind, author, identifier)) =
288+ tag.as_slice().get(1).and_then(|value| coordinate(value))
289+ else {
290+ return Ok(blocked("invalid repository address"));
291+ };
292+ if kind != 30617 || !self.registered(author, identifier).await? {
293+ return Ok(blocked("repository is not registered"));
294+ }
295+ if matches!(event.kind.as_u16(), 1630..=1632) && author != event.pubkey {
296+ return Ok(blocked("status signer does not own the repository"));
297+ }
298+ addresses += 1;
299+ }
300+ if addresses != 1 {
301+ return Ok(blocked("exactly one repository address is required"));
302+ }
303+ }
304+ 30382 => {
305+ if !valid_binding(event) {
306+ return Ok(blocked("invalid openpgp binding envelope"));
307+ }
308+ }
309+ 5 => return self.deletion(event).await,
310+ _ => {
311+ return Ok(WritePolicyResult::reject(
312+ MachineReadablePrefix::Unsupported,
313+ "event kind is not supported",
314+ ));
315+ }
316+ }
317+ Ok(WritePolicyResult::Accept)
318+ }
319+
320+ async fn registered(&self, author: PublicKey, identifier: &str) -> Result<bool, Error> {
321+ Ok(self
322+ .database
323+ .query(
324+ Filter::new()
325+ .kind(Kind::from(30617))
326+ .author(author)
327+ .identifier(identifier)
328+ .limit(1),
329+ )
330+ .await?
331+ .iter()
332+ .any(|event| !event.is_expired()))
333+ }
334+
335+ async fn registered_forge(&self, author: PublicKey) -> Result<bool, Error> {
336+ Ok(self
337+ .database
338+ .query(
339+ Filter::new()
340+ .kind(Kind::from(30617))
341+ .author(author)
342+ .limit(1),
343+ )
344+ .await?
345+ .iter()
346+ .any(|event| !event.is_expired()))
347+ }
348+
349+ async fn deletion(&self, event: &Event) -> Result<WritePolicyResult, Error> {
350+ let mut targets = 0_usize;
351+ for tag in event.tags.iter() {
352+ let values = tag.as_slice();
353+ match values.first().map(String::as_str) {
354+ Some("e") => {
355+ let Some(id) = values
356+ .get(1)
357+ .filter(|id| id.len() == 64)
358+ .and_then(|id| id.parse::<EventId>().ok())
359+ else {
360+ return Ok(blocked("invalid deletion target"));
361+ };
362+ if let Some(target) = self.database.event_by_id(&id).await? {
363+ if target.pubkey != event.pubkey || target.kind == Kind::EventDeletion {
364+ return Ok(blocked("deletion target is not removable by this author"));
365+ }
366+ }
367+ targets += 1;
368+ }
369+ Some("a") => {
370+ let Some((kind, author, _)) = values.get(1).and_then(|value| coordinate(value))
371+ else {
372+ return Ok(blocked("invalid deletion address"));
373+ };
374+ if author != event.pubkey || !matches!(kind, 30617 | 30618 | 30382) {
375+ return Ok(blocked("deletion address is not owned by this author"));
376+ }
377+ targets += 1;
378+ }
379+ _ => {}
380+ }
381+ }
382+ Ok(if targets == 0 {
383+ blocked("deletion target is required")
384+ } else {
385+ WritePolicyResult::Accept
386+ })
387+ }
388+}
389+
390+const fn blocked(message: &'static str) -> WritePolicyResult {
391+ WritePolicyResult::reject(MachineReadablePrefix::Blocked, message)
392+}
393+
394+fn valid_identifier(identifier: &str) -> bool {
395+ !identifier.is_empty() && identifier.len() <= 512 && !identifier.chars().any(char::is_control)
396+}
397+
398+fn identifier(event: &Event) -> Option<&str> {
399+ let mut tags = event
400+ .tags
401+ .iter()
402+ .filter(|tag| tag.as_slice().first().is_some_and(|name| name == "d"));
403+ let tag = tags.next()?.as_slice();
404+ if tags.next().is_some() || tag.len() != 2 || !valid_identifier(&tag[1]) {
405+ return None;
406+ }
407+ Some(&tag[1])
408+}
409+
410+pub(crate) fn coordinate(value: &str) -> Option<(u16, PublicKey, &str)> {
411+ let mut parts = value.splitn(3, ':');
412+ let kind = parts.next()?.parse().ok()?;
413+ let author_hex = parts.next()?;
414+ if author_hex.len() != 64 || !author_hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
415+ return None;
416+ }
417+ let author = author_hex.parse().ok()?;
418+ let identifier = parts.next()?;
419+ valid_identifier(identifier).then_some((kind, author, identifier))
420+}
421+
422+fn valid_binding(event: &Event) -> bool {
423+ if event.content.len() > 192 * 1024 {
424+ return false;
425+ }
426+ let Ok(body) = serde_json::from_str::<Binding>(&event.content) else {
427+ return false;
428+ };
429+ if body.version != 1
430+ || !matches!(body.fingerprint.len(), 40 | 64)
431+ || !body
432+ .fingerprint
433+ .bytes()
434+ .all(|byte| byte.is_ascii_hexdigit())
435+ || identifier(event) != Some(body.fingerprint.as_str())
436+ {
437+ return false;
438+ }
439+ match body.action.as_str() {
440+ "bind" => {
441+ body.certificate
442+ .is_some_and(|certificate| !certificate.trim().is_empty())
443+ && body
444+ .signature
445+ .is_some_and(|signature| !signature.trim().is_empty())
446+ }
447+ "revoke" => body.certificate.is_none() && body.signature.is_none(),
448+ _ => false,
449+ }
450+}
Added file: src/query.rs+131 additions
@@ -0,0 +1,131 @@
1+use anyhow::{Context as _, Result};
2+use nostr::filter::Filter;
3+use rusqlite::types::Value;
4+
5+const DEFAULT_LIMIT: usize = 10_000;
6+
7+const SELECT_EVENTS: &str = "
8+SELECT e.id, e.pubkey, e.created_at, e.kind, e.content, e.tags, e.sig
9+FROM events AS e
10+WHERE 1 = 1
11+";
12+
13+const TAG_VALUES: &str = "
14+ AND EXISTS (
15+ SELECT 1
16+ FROM event_tags AS tag
17+ WHERE tag.event_id = e.id
18+ AND tag.tag_name = ?
19+ AND tag.tag_value IN (
20+";
21+
22+pub struct EventQuery {
23+ pub(crate) sql: String,
24+ pub(crate) parameters: Vec<Value>,
25+ pub(crate) limit: usize,
26+}
27+
28+impl EventQuery {
29+ pub(crate) fn new(filter: &Filter) -> Result<Self> {
30+ if filter.search.is_some() {
31+ anyhow::bail!("search is not supported");
32+ }
33+
34+ let mut query = Self {
35+ sql: SELECT_EVENTS.to_owned(),
36+ parameters: Vec::new(),
37+ limit: filter.limit.unwrap_or(DEFAULT_LIMIT),
38+ };
39+
40+ if let Some(ids) = filter.ids.as_ref() {
41+ query.add_values(
42+ "e.id",
43+ ids.iter()
44+ .map(|id| Value::Blob(id.as_bytes().to_vec()))
45+ .collect(),
46+ );
47+ }
48+ if let Some(authors) = filter.authors.as_ref() {
49+ query.add_values(
50+ "e.pubkey",
51+ authors
52+ .iter()
53+ .map(|author| Value::Blob(author.as_bytes().to_vec()))
54+ .collect(),
55+ );
56+ }
57+ if let Some(kinds) = filter.kinds.as_ref() {
58+ query.add_values(
59+ "e.kind",
60+ kinds
61+ .iter()
62+ .map(|kind| Value::Integer(i64::from(kind.as_u16())))
63+ .collect(),
64+ );
65+ }
66+ if let Some(since) = filter.since {
67+ query.add_condition(
68+ "e.created_at >= ?",
69+ Value::Integer(timestamp(since.as_secs())?),
70+ );
71+ }
72+ if let Some(until) = filter.until {
73+ query.add_condition(
74+ "e.created_at <= ?",
75+ Value::Integer(timestamp(until.as_secs())?),
76+ );
77+ }
78+ for (tag, values) in &filter.generic_tags {
79+ if values.is_empty() {
80+ query.exclude_all();
81+ break;
82+ }
83+ query.sql.push_str(TAG_VALUES);
84+ query.parameters.push(Value::Text(tag.to_string()));
85+ query.add_placeholders(values.len());
86+ query.sql.push_str("))");
87+ query
88+ .parameters
89+ .extend(values.iter().cloned().map(Value::Text));
90+ }
91+
92+ query.sql.push_str(" ORDER BY e.created_at DESC, e.id");
93+ Ok(query)
94+ }
95+
96+ fn add_values(&mut self, column: &str, values: Vec<Value>) {
97+ if values.is_empty() {
98+ self.exclude_all();
99+ return;
100+ }
101+ self.sql.push_str(" AND ");
102+ self.sql.push_str(column);
103+ self.sql.push_str(" IN (");
104+ self.add_placeholders(values.len());
105+ self.sql.push(')');
106+ self.parameters.extend(values);
107+ }
108+
109+ fn add_condition(&mut self, condition: &str, value: Value) {
110+ self.sql.push_str(" AND ");
111+ self.sql.push_str(condition);
112+ self.parameters.push(value);
113+ }
114+
115+ fn add_placeholders(&mut self, count: usize) {
116+ for index in 0..count {
117+ if index > 0 {
118+ self.sql.push_str(", ");
119+ }
120+ self.sql.push('?');
121+ }
122+ }
123+
124+ fn exclude_all(&mut self) {
125+ self.sql.push_str(" AND 0");
126+ }
127+}
128+
129+fn timestamp(value: u64) -> Result<i64> {
130+ value.try_into().context("timestamp exceeds sqlite range")
131+}
Added file: src/relay.rs+319 additions
@@ -0,0 +1,319 @@
1+use std::collections::{BTreeSet, HashMap};
2+use std::net::SocketAddr;
3+use std::sync::Arc;
4+use std::time::Duration;
5+
6+use anyhow::{Context as _, Result};
7+use futures_util::{Sink, SinkExt as _, StreamExt as _};
8+use nostr::event::Event;
9+use nostr::filter::{Filter, MatchEventOptions};
10+use nostr_database::{NostrDatabase as _, RejectedReason, SaveEventStatus};
11+use serde_json::{Value, json};
12+use tokio::io::{AsyncRead, AsyncWrite};
13+use tokio::sync::broadcast;
14+use tokio::time::timeout;
15+use tokio_tungstenite::WebSocketStream;
16+use tokio_tungstenite::tungstenite::{
17+ Error as WebSocketError, Message,
18+ protocol::{Role, WebSocketConfig},
19+};
20+use tokio_util::sync::CancellationToken;
21+
22+use crate::config::Limits;
23+use crate::policy::{Policy, QueryPolicyResult, WritePolicyResult};
24+use crate::store::Store;
25+
26+#[derive(Clone)]
27+pub struct Relay {
28+ store: Arc<Store>,
29+ policy: Policy,
30+ limits: Limits,
31+ events: broadcast::Sender<Event>,
32+ stop: CancellationToken,
33+}
34+
35+struct Subscription {
36+ filters: Vec<Filter>,
37+ bytes: usize,
38+}
39+
40+#[derive(Default)]
41+struct Session {
42+ subscriptions: HashMap<String, Subscription>,
43+}
44+
45+impl Relay {
46+ pub(crate) fn new(
47+ store: Arc<Store>,
48+ policy: Policy,
49+ limits: Limits,
50+ stop: CancellationToken,
51+ ) -> Self {
52+ let (events, _) = broadcast::channel(limits.max_connections.max(32));
53+ Self {
54+ store,
55+ policy,
56+ limits,
57+ events,
58+ stop,
59+ }
60+ }
61+
62+ pub(crate) async fn add_event(&self, event: Event) -> Result<SaveEventStatus> {
63+ event.verify().context("invalid event")?;
64+ let status = self.store.save_event(&event).await?;
65+ if status.is_success() {
66+ match self.events.send(event) {
67+ Ok(_) | Err(_) => {}
68+ }
69+ }
70+ Ok(status)
71+ }
72+
73+ pub(crate) async fn take_connection<S>(&self, stream: S, address: SocketAddr) -> Result<()>
74+ where
75+ S: AsyncRead + AsyncWrite + Unpin,
76+ {
77+ let websocket = WebSocketStream::from_raw_socket(
78+ stream,
79+ Role::Server,
80+ Some(
81+ WebSocketConfig::default()
82+ .max_message_size(Some(self.limits.max_message_bytes))
83+ .max_frame_size(Some(self.limits.max_message_bytes)),
84+ ),
85+ )
86+ .await;
87+ let (mut sink, mut source) = websocket.split();
88+ let mut events = self.events.subscribe();
89+ let mut session = Session::default();
90+
91+ loop {
92+ tokio::select! {
93+ biased;
94+ () = self.stop.cancelled() => return Ok(()),
95+ received = source.next() => {
96+ let Some(message) = received else { return Ok(()); };
97+ match message? {
98+ Message::Text(text) => {
99+ for response in self.handle_text(&mut session, address, &text).await {
100+ self.send(&mut sink, response).await?;
101+ }
102+ }
103+ Message::Ping(payload) => {
104+ self.send_message(&mut sink, Message::Pong(payload)).await?;
105+ }
106+ Message::Close(_) => return Ok(()),
107+ Message::Binary(_) | Message::Pong(_) | Message::Frame(_) => {}
108+ }
109+ }
110+ received = events.recv() => {
111+ let event = received.context("live event stream lagged")?;
112+ let ids = session.subscriptions.iter()
113+ .filter(|entry| entry.1.filters.iter().any(|filter| filter.match_event(&event, MatchEventOptions::new())))
114+ .map(|entry| entry.0.clone())
115+ .collect::<Vec<_>>();
116+ for id in ids {
117+ self.send(&mut sink, json!(["EVENT", id, event])).await?;
118+ }
119+ }
120+ }
121+ }
122+ }
123+
124+ async fn send<S>(&self, sink: &mut S, value: Value) -> Result<()>
125+ where
126+ S: Sink<Message, Error = WebSocketError> + Unpin,
127+ {
128+ self.send_message(sink, Message::Text(value.to_string().into()))
129+ .await
130+ }
131+
132+ async fn send_message<S>(&self, sink: &mut S, message: Message) -> Result<()>
133+ where
134+ S: Sink<Message, Error = WebSocketError> + Unpin,
135+ {
136+ timeout(
137+ Duration::from_secs(self.limits.write_timeout_seconds),
138+ sink.send(message),
139+ )
140+ .await
141+ .context("websocket write timed out")??;
142+ Ok(())
143+ }
144+
145+ async fn handle_text(
146+ &self,
147+ session: &mut Session,
148+ address: SocketAddr,
149+ text: &str,
150+ ) -> Vec<Value> {
151+ let Ok(value) = serde_json::from_str::<Value>(text) else {
152+ return vec![json!(["NOTICE", "invalid json"])];
153+ };
154+ let Some(frame) = value.as_array() else {
155+ return vec![json!(["NOTICE", "message must be an array"])];
156+ };
157+ match frame.first().and_then(Value::as_str) {
158+ Some("EVENT") => vec![self.handle_event(frame, address).await],
159+ Some("REQ") => self.handle_req(session, frame, address, text.len()).await,
160+ Some("CLOSE") => {
161+ if let Some(id) = frame.get(1).and_then(Value::as_str) {
162+ session.subscriptions.remove(id);
163+ }
164+ Vec::new()
165+ }
166+ _ => vec![json!(["NOTICE", "unsupported message"])],
167+ }
168+ }
169+
170+ async fn handle_event(&self, frame: &[Value], address: SocketAddr) -> Value {
171+ let Some(value) = frame.get(1) else {
172+ return json!(["NOTICE", "EVENT requires an event"]);
173+ };
174+ let id = value
175+ .get("id")
176+ .and_then(Value::as_str)
177+ .unwrap_or_default()
178+ .to_owned();
179+ let Ok(event) = serde_json::from_value::<Event>(value.clone()) else {
180+ return json!(["OK", id, false, "invalid: malformed event"]);
181+ };
182+ if event.verify().is_err() {
183+ return json!(["OK", event.id, false, "invalid: bad id or signature"]);
184+ }
185+ match self.policy.admit_event(&event, &address).await {
186+ WritePolicyResult::Accept => {}
187+ WritePolicyResult::Respond {
188+ prefix,
189+ message,
190+ status,
191+ } => return json!(["OK", event.id, status, format!("{prefix}: {message}")]),
192+ }
193+ let event_id = event.id;
194+ match self.add_event(event).await {
195+ Ok(SaveEventStatus::Success) => json!(["OK", event_id, true, ""]),
196+ Ok(SaveEventStatus::Rejected(RejectedReason::Duplicate)) => {
197+ json!(["OK", event_id, true, "duplicate: already stored"])
198+ }
199+ Ok(SaveEventStatus::Rejected(reason)) => {
200+ json!(["OK", event_id, false, format!("blocked: {reason:?}")])
201+ }
202+ Err(error) => {
203+ tracing::error!(event = %event_id, %error, "event storage failed");
204+ json!(["OK", event_id, false, "error: database unavailable"])
205+ }
206+ }
207+ }
208+
209+ async fn handle_req(
210+ &self,
211+ session: &mut Session,
212+ frame: &[Value],
213+ address: SocketAddr,
214+ request_bytes: usize,
215+ ) -> Vec<Value> {
216+ let Some(id) = frame.get(1).and_then(Value::as_str) else {
217+ return vec![json!(["NOTICE", "REQ requires a subscription id"])];
218+ };
219+ if id.len() > 64 {
220+ return vec![closed(id, "blocked: subscription id is too long")];
221+ }
222+ if frame.len() < 3 {
223+ return vec![closed(id, "blocked: REQ requires a filter")];
224+ }
225+ if frame.len() - 2 > self.limits.max_filters {
226+ return vec![closed(id, "rate-limited: too many filters")];
227+ }
228+ if session.subscriptions.len() >= self.limits.max_subscriptions
229+ && !session.subscriptions.contains_key(id)
230+ {
231+ return vec![closed(id, "rate-limited: too many subscriptions")];
232+ }
233+ let old_bytes = session.subscriptions.get(id).map_or(0, |entry| entry.bytes);
234+ let active_bytes = session
235+ .subscriptions
236+ .values()
237+ .map(|entry| entry.bytes)
238+ .sum::<usize>()
239+ .saturating_sub(old_bytes);
240+ let max_subscription_bytes = self
241+ .limits
242+ .max_filter_bytes
243+ .saturating_mul(self.limits.max_subscriptions);
244+ if active_bytes.saturating_add(request_bytes) > max_subscription_bytes {
245+ return vec![closed(id, "rate-limited: subscription byte limit exceeded")];
246+ }
247+
248+ let mut filters = Vec::with_capacity(frame.len() - 2);
249+ for value in &frame[2..] {
250+ let Ok(filter) = serde_json::from_value::<Filter>(value.clone()) else {
251+ return vec![closed(id, "blocked: invalid filter")];
252+ };
253+ match self.policy.admit_query(&filter, &address).await {
254+ QueryPolicyResult::Accept => filters.push(filter),
255+ QueryPolicyResult::Reject { prefix, message } => {
256+ return vec![closed(id, &format!("{prefix}: {message}"))];
257+ }
258+ }
259+ }
260+
261+ let mut events = BTreeSet::new();
262+ for filter in &filters {
263+ let implicit = filter.limit.is_none();
264+ let limit = filter.limit.unwrap_or(self.limits.max_query_results);
265+ let mut query = filter.clone();
266+ query.limit = Some(if implicit {
267+ limit.saturating_add(1)
268+ } else {
269+ limit
270+ });
271+ let found = match self.store.query(query).await {
272+ Ok(found) => found,
273+ Err(error) => {
274+ let message = if error.to_string().contains("history byte limit exceeded") {
275+ "rate-limited: history byte limit exceeded"
276+ } else {
277+ tracing::error!(%error, "history query failed");
278+ "error: database unavailable"
279+ };
280+ return vec![closed(id, message)];
281+ }
282+ };
283+ if implicit && found.len() > limit {
284+ return vec![closed(id, "rate-limited: history result limit exceeded")];
285+ }
286+ events.extend(found);
287+ if events.len() > self.limits.max_query_results {
288+ return vec![closed(id, "rate-limited: too many results")];
289+ }
290+ }
291+ let bytes = events
292+ .iter()
293+ .map(|event| event.as_json().len())
294+ .sum::<usize>();
295+ if bytes > self.limits.max_query_bytes {
296+ return vec![closed(id, "rate-limited: history byte limit exceeded")];
297+ }
298+
299+ let mut response = Vec::with_capacity(events.len() + 1);
300+ for event in events {
301+ if !event.is_expired() {
302+ response.push(json!(["EVENT", id, event]));
303+ }
304+ }
305+ response.push(json!(["EOSE", id]));
306+ session.subscriptions.insert(
307+ id.to_owned(),
308+ Subscription {
309+ filters,
310+ bytes: request_bytes,
311+ },
312+ );
313+ response
314+ }
315+}
316+
317+fn closed(id: &str, message: &str) -> Value {
318+ json!(["CLOSED", id, message])
319+}
Added file: src/server.rs+299 additions
@@ -0,0 +1,299 @@
1+use std::collections::HashMap;
2+use std::convert::Infallible;
3+use std::net::{IpAddr, SocketAddr};
4+use std::sync::{Arc, Mutex, PoisonError};
5+use std::time::Duration;
6+
7+use anyhow::Context as _;
8+use http_body_util::Full;
9+use hyper::body::{Bytes, Incoming};
10+use hyper::header::{ACCEPT, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, HeaderValue, UPGRADE};
11+use hyper::server::conn::http1::Builder as Http1Builder;
12+use hyper::service::service_fn;
13+use hyper::upgrade::on as upgrade;
14+use hyper::{Method, Request, Response, StatusCode};
15+use hyper_util::rt::{TokioIo, TokioTimer};
16+use nostr_database::NostrDatabase;
17+use serde_json::json;
18+use tokio::net::TcpListener;
19+use tokio::sync::{OwnedSemaphorePermit, Semaphore};
20+use tokio::task::{JoinHandle, spawn as spawn_task};
21+use tokio::time::timeout;
22+use tokio_tungstenite::tungstenite::handshake::server::create_response;
23+use tokio_util::sync::CancellationToken;
24+use tokio_util::task::TaskTracker;
25+
26+use crate::config::Config;
27+use crate::federation::spawn as spawn_federation;
28+use crate::policy::Policy;
29+use crate::relay::Relay;
30+use crate::store::Store;
31+
32+type Body = Full<Bytes>;
33+
34+struct ConnectionLease {
35+ ip: IpAddr,
36+ peers: Arc<Mutex<HashMap<IpAddr, usize>>>,
37+ _permit: OwnedSemaphorePermit,
38+}
39+
40+impl Drop for ConnectionLease {
41+ fn drop(&mut self) {
42+ let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner);
43+ if let Some(count) = peers.get_mut(&self.ip) {
44+ *count -= 1;
45+ if *count == 0 {
46+ peers.remove(&self.ip);
47+ }
48+ }
49+ }
50+}
51+
52+#[derive(Clone)]
53+struct HttpState {
54+ relay: Relay,
55+ store: Arc<Store>,
56+ config: Arc<Config>,
57+ websocket_path: String,
58+ tasks: TaskTracker,
59+ stop: CancellationToken,
60+}
61+
62+pub struct RunningRelay {
63+ address: SocketAddr,
64+ path: String,
65+ state: HttpState,
66+ listener: JoinHandle<anyhow::Result<()>>,
67+}
68+
69+impl RunningRelay {
70+ #[must_use]
71+ pub fn url(&self) -> String {
72+ format!("ws://{}{}", self.address, self.path)
73+ }
74+
75+ pub async fn shutdown(self) -> anyhow::Result<()> {
76+ self.state.stop.cancel();
77+ self.listener.await.context("joining listener")??;
78+ self.state.tasks.close();
79+ timeout(Duration::from_secs(30), self.state.tasks.wait())
80+ .await
81+ .context("relay connections did not drain")?;
82+ Ok(())
83+ }
84+}
85+
86+pub async fn spawn(config: Config) -> anyhow::Result<RunningRelay> {
87+ config.validate()?;
88+ let store = Store::open(&config).await?;
89+ let policy_database: Arc<dyn NostrDatabase> = Arc::<Store>::clone(&store);
90+ let policy = Policy::new(policy_database, &config)?;
91+ let listener = TcpListener::bind(config.listen)
92+ .await
93+ .context("binding relay listener")?;
94+ let address = listener.local_addr()?;
95+ let path = config.websocket_path()?;
96+ let stop = CancellationToken::new();
97+ let relay = Relay::new(
98+ Arc::clone(&store),
99+ policy.clone(),
100+ config.limits.clone(),
101+ stop.clone(),
102+ );
103+ let state = HttpState {
104+ relay,
105+ store,
106+ config: Arc::new(config),
107+ websocket_path: path.clone(),
108+ tasks: TaskTracker::new(),
109+ stop,
110+ };
111+ let task_state = state.clone();
112+ spawn_federation(
113+ Arc::clone(&state.config),
114+ Arc::clone(&state.store),
115+ state.relay.clone(),
116+ policy,
117+ &state.tasks,
118+ state.stop.clone(),
119+ );
120+ let listener_task = spawn_task(async move { accept_connections(listener, task_state).await });
121+ tracing::info!(%address, %path, "relay is listening");
122+ Ok(RunningRelay {
123+ address,
124+ path,
125+ state,
126+ listener: listener_task,
127+ })
128+}
129+
130+async fn accept_connections(listener: TcpListener, state: HttpState) -> anyhow::Result<()> {
131+ let slots = Arc::new(Semaphore::new(state.config.limits.max_connections));
132+ let peers = Arc::new(Mutex::new(HashMap::<IpAddr, usize>::new()));
133+ loop {
134+ let (stream, address) = tokio::select! {
135+ biased;
136+ () = state.stop.cancelled() => return Ok(()),
137+ accepted = listener.accept() => accepted?,
138+ };
139+ let Ok(permit) = Arc::clone(&slots).try_acquire_owned() else {
140+ continue;
141+ };
142+ let mut peer_counts = peers.lock().unwrap_or_else(PoisonError::into_inner);
143+ let count = peer_counts.entry(address.ip()).or_default();
144+ if *count >= state.config.limits.max_connections_per_ip {
145+ continue;
146+ }
147+ *count += 1;
148+ drop(peer_counts);
149+ let lease = Arc::new(ConnectionLease {
150+ ip: address.ip(),
151+ peers: Arc::clone(&peers),
152+ _permit: permit,
153+ });
154+ let handler_state = state.clone();
155+ let stop = state.stop.clone();
156+ state.tasks.spawn(async move {
157+ let handler = service_fn(move |request| {
158+ handle_request(
159+ request,
160+ address,
161+ handler_state.clone(),
162+ Arc::clone(&lease),
163+ )
164+ });
165+ let mut builder = Http1Builder::new();
166+ builder.timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(10));
167+ let connection = builder.serve_connection(TokioIo::new(stream), handler).with_upgrades();
168+ tokio::pin!(connection);
169+ tokio::select! {
170+ result = &mut connection => {
171+ if let Err(error) = result { tracing::debug!(%address, %error, "http connection ended"); }
172+ }
173+ () = stop.cancelled() => {}
174+ }
175+ });
176+ }
177+}
178+
179+async fn handle_request(
180+ mut request: Request<Incoming>,
181+ address: SocketAddr,
182+ state: HttpState,
183+ lease: Arc<ConnectionLease>,
184+) -> Result<Response<Body>, Infallible> {
185+ if state.stop.is_cancelled() {
186+ return Ok(response(
187+ StatusCode::SERVICE_UNAVAILABLE,
188+ "text/plain",
189+ "relay is stopping",
190+ ));
191+ }
192+ if request.method() != Method::GET {
193+ return Ok(response(
194+ StatusCode::METHOD_NOT_ALLOWED,
195+ "text/plain",
196+ "GET required",
197+ ));
198+ }
199+ if request.uri().path() == "/healthz" {
200+ let healthy = timeout(Duration::from_secs(2), state.store.healthy())
201+ .await
202+ .unwrap_or(false);
203+ return Ok(response(
204+ if healthy {
205+ StatusCode::OK
206+ } else {
207+ StatusCode::SERVICE_UNAVAILABLE
208+ },
209+ "text/plain",
210+ if healthy {
211+ "ok"
212+ } else {
213+ "database unavailable"
214+ },
215+ ));
216+ }
217+ if request.uri().path() != state.websocket_path {
218+ return Ok(response(StatusCode::NOT_FOUND, "text/plain", "not found"));
219+ }
220+ if !request.headers().contains_key(UPGRADE) {
221+ let wants_information = request
222+ .headers()
223+ .get(ACCEPT)
224+ .and_then(|value| value.to_str().ok())
225+ .is_some_and(|value| {
226+ value.split(',').any(|media_type| {
227+ media_type.trim().split(';').next() == Some("application/nostr+json")
228+ })
229+ });
230+ return Ok(if wants_information {
231+ response(
232+ StatusCode::OK,
233+ "application/nostr+json",
234+ &information(&state.config),
235+ )
236+ } else {
237+ response(
238+ StatusCode::OK,
239+ "text/plain",
240+ "vineyard\na persistent nostr relay for git forges\n",
241+ )
242+ });
243+ }
244+ let upgrade = upgrade(&mut request);
245+ let Ok(handshake) = create_response(&request.map(|_body| ())) else {
246+ return Ok(response(
247+ StatusCode::BAD_REQUEST,
248+ "text/plain",
249+ "invalid WebSocket handshake",
250+ ));
251+ };
252+ let relay = state.relay.clone();
253+ state.tasks.spawn(async move {
254+ let _lease = lease;
255+ if let Ok(Ok(stream)) = timeout(Duration::from_secs(10), upgrade).await {
256+ if let Err(error) = relay.take_connection(TokioIo::new(stream), address).await {
257+ tracing::debug!(%address, %error, "websocket connection ended");
258+ }
259+ } else {
260+ tracing::debug!(%address, "websocket upgrade did not complete");
261+ }
262+ });
263+ Ok(handshake.map(|()| Full::new(Bytes::new())))
264+}
265+
266+fn response(status: StatusCode, content_type: &str, body: &str) -> Response<Body> {
267+ let mut response = Response::new(Full::new(Bytes::copy_from_slice(body.as_bytes())));
268+ *response.status_mut() = status;
269+ if let Ok(value) = HeaderValue::from_str(content_type) {
270+ response.headers_mut().insert(CONTENT_TYPE, value);
271+ }
272+ response
273+ .headers_mut()
274+ .insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
275+ response
276+}
277+
278+fn information(config: &Config) -> String {
279+ json!({
280+ "name": "vineyard",
281+ "description": "a persistent nostr relay for git forges",
282+ "software": "vineyard",
283+ "version": env!("CARGO_PKG_VERSION"),
284+ "supported_nips": [1, 9, 11, 34, 65],
285+ "forge_pubkeys": config.forge_pubkeys,
286+ "limitation": {
287+ "max_message_length": config.limits.max_message_bytes,
288+ "max_event_tags": config.limits.max_tags,
289+ "max_subscriptions": config.limits.max_subscriptions,
290+ "max_filters": config.limits.max_filters,
291+ "max_limit": config.limits.max_query_results,
292+ "max_subid_length": 64,
293+ "auth_required": false,
294+ "payment_required": false,
295+ "restricted_writes": true
296+ }
297+ })
298+ .to_string()
299+}
Added file: src/store.rs+244 additions
@@ -0,0 +1,244 @@
1+use std::collections::{BTreeSet, HashSet};
2+use std::fs::{File, OpenOptions, create_dir_all};
3+use std::future::Future;
4+use std::io::Error as IoError;
5+use std::iter::once;
6+use std::path::PathBuf;
7+use std::pin::Pin;
8+use std::sync::Arc;
9+
10+use anyhow::Context as _;
11+use fs2::FileExt as _;
12+use nostr::event::{Event, EventId, Kind};
13+use nostr::filter::Filter;
14+use nostr::nips::nip65::extract_relay_list;
15+use nostr_database::error::Error;
16+use nostr_database::{DatabaseEventStatus, Features, NostrDatabase, SaveEventStatus};
17+use tokio::sync::Mutex;
18+
19+use crate::config::{Config, relay_url};
20+use crate::journal::Journal;
21+
22+type DatabaseFuture<'database, T> =
23+ Pin<Box<dyn Future<Output = Result<T, Error>> + Send + 'database>>;
24+
25+#[derive(Debug)]
26+pub struct Store {
27+ pub(crate) journal: Journal,
28+ path: PathBuf,
29+ min_free_bytes: u64,
30+ max_query_bytes: usize,
31+ max_repositories: usize,
32+ max_discovered_peers: usize,
33+ excluded_peers: HashSet<String>,
34+ writer: Arc<Mutex<()>>,
35+ _lock: File,
36+}
37+
38+impl Store {
39+ pub async fn open(config: &Config) -> anyhow::Result<Arc<Self>> {
40+ if let Some(parent) = config
41+ .database
42+ .parent()
43+ .filter(|parent| !parent.as_os_str().is_empty())
44+ {
45+ create_dir_all(parent).context("creating database directory")?;
46+ }
47+ let mut lock_name = config.database.as_os_str().to_os_string();
48+ lock_name.push(".lock");
49+ let mut lock_options = OpenOptions::new();
50+ lock_options
51+ .read(true)
52+ .write(true)
53+ .create(true)
54+ .truncate(false);
55+ #[cfg(unix)]
56+ {
57+ use std::os::unix::fs::OpenOptionsExt as _;
58+ lock_options.mode(0o600)
59+ };
60+ let lock = lock_options
61+ .open(PathBuf::from(lock_name))
62+ .context("opening database lock")?;
63+ lock.try_lock_exclusive()
64+ .context("database is already in use")?;
65+
66+ let excluded_peers = once(&config.public_url)
67+ .chain(config.peers.iter().map(|peer| &peer.url))
68+ .map(|url| relay_url(url).map(|parsed| parsed.to_string()))
69+ .collect::<Option<HashSet<_>>>()
70+ .context("validated relay url became invalid")?;
71+ let writer = Arc::new(Mutex::new(()));
72+ let journal = Journal::open(
73+ config.database.clone(),
74+ config.limits.max_database_bytes,
75+ config
76+ .limits
77+ .max_discovered_peers
78+ .saturating_add(excluded_peers.len()),
79+ excluded_peers.iter().cloned().collect(),
80+ Arc::clone(&writer),
81+ )
82+ .await?;
83+ journal
84+ .forget_discovered(excluded_peers.iter().cloned().collect())
85+ .await?;
86+ tracing::info!(path = %config.database.display(), "opened event database");
87+ Ok(Arc::new(Self {
88+ journal,
89+ path: config.database.clone(),
90+ min_free_bytes: config.limits.min_free_bytes,
91+ max_query_bytes: config.limits.max_query_bytes,
92+ max_repositories: config.limits.max_repositories,
93+ max_discovered_peers: config.limits.max_discovered_peers,
94+ excluded_peers,
95+ writer,
96+ _lock: lock,
97+ }))
98+ }
99+
100+ pub async fn healthy(&self) -> bool {
101+ self.journal.healthy().await
102+ }
103+
104+ async fn query_events(&self, filter: Filter) -> Result<BTreeSet<Event>, Error> {
105+ self.journal
106+ .query(filter, self.max_query_bytes)
107+ .await
108+ .map_err(storage_error)
109+ }
110+}
111+
112+fn storage_error(error: anyhow::Error) -> Error {
113+ Error::storage(IoError::other(error))
114+}
115+
116+impl NostrDatabase for Store {
117+ fn backend(&self) -> &'static str {
118+ "vineyard-sqlite"
119+ }
120+
121+ fn features(&self) -> Features {
122+ Features {
123+ persistent: true,
124+ event_expiration: false,
125+ full_text_search: false,
126+ request_to_vanish: false,
127+ }
128+ }
129+
130+ fn save_event<'database>(
131+ &'database self,
132+ event: &'database Event,
133+ ) -> DatabaseFuture<'database, SaveEventStatus> {
134+ Box::pin(async move {
135+ let writer = self.writer.lock().await;
136+ if event.kind.as_u16() == 30617 {
137+ if let Some(identifier) = event.tags.identifier() {
138+ let registered = self
139+ .query_events(
140+ Filter::new()
141+ .kind(event.kind)
142+ .author(event.pubkey)
143+ .identifier(identifier)
144+ .limit(1),
145+ )
146+ .await?
147+ .len();
148+ if registered == 0
149+ && self
150+ .query_events(
151+ Filter::new()
152+ .kind(event.kind)
153+ .limit(self.max_repositories + 1),
154+ )
155+ .await?
156+ .len()
157+ >= self.max_repositories
158+ {
159+ return Err(Error::storage(IoError::other(
160+ "repository directory is full",
161+ )));
162+ }
163+ }
164+ }
165+ if self.journal.status(event.id).await.map_err(storage_error)?
166+ != DatabaseEventStatus::Saved
167+ {
168+ let free = fs2::available_space(&self.path).map_err(Error::io)?;
169+ let event_bytes = u64::try_from(event.as_json().len())
170+ .map_err(|error| Error::storage(IoError::other(error.to_string())))?;
171+ if free < self.min_free_bytes.saturating_add(event_bytes) {
172+ return Err(Error::storage(IoError::other("storage reserve reached")));
173+ }
174+ }
175+ let status = self
176+ .journal
177+ .save_locked(event.clone(), &writer)
178+ .await
179+ .map_err(storage_error)?;
180+ if status.is_success() && event.kind == Kind::RelayList {
181+ let urls = extract_relay_list(event)
182+ .map(|(url, _)| url.to_string())
183+ .filter(|url| !self.excluded_peers.contains(url))
184+ .collect();
185+ if let Err(error) = self
186+ .journal
187+ .replace_discovered_locked(
188+ event.pubkey,
189+ urls,
190+ self.max_discovered_peers,
191+ &writer,
192+ )
193+ .await
194+ {
195+ tracing::error!(event = %event.id, %error, "saving discovered peers failed");
196+ }
197+ }
198+ drop(writer);
199+ Ok(status)
200+ })
201+ }
202+
203+ fn check_id<'database>(
204+ &'database self,
205+ event_id: &'database EventId,
206+ ) -> DatabaseFuture<'database, DatabaseEventStatus> {
207+ let requested_id = *event_id;
208+ Box::pin(async move {
209+ self.journal
210+ .status(requested_id)
211+ .await
212+ .map_err(storage_error)
213+ })
214+ }
215+
216+ fn event_by_id<'database>(
217+ &'database self,
218+ event_id: &'database EventId,
219+ ) -> DatabaseFuture<'database, Option<Event>> {
220+ let requested_id = *event_id;
221+ Box::pin(async move {
222+ self.journal
223+ .event(requested_id)
224+ .await
225+ .map_err(storage_error)
226+ })
227+ }
228+
229+ fn count(&self, filter: Filter) -> DatabaseFuture<'_, usize> {
230+ Box::pin(async move { self.journal.count(filter).await.map_err(storage_error) })
231+ }
232+
233+ fn query(&self, filter: Filter) -> DatabaseFuture<'_, BTreeSet<Event>> {
234+ Box::pin(self.query_events(filter))
235+ }
236+
237+ fn delete(&self, _filter: Filter) -> DatabaseFuture<'_, ()> {
238+ Box::pin(async { Err(Error::unsupported("use signed deletion requests")) })
239+ }
240+
241+ fn wipe(&self) -> DatabaseFuture<'_, ()> {
242+ Box::pin(async { Err(Error::unsupported("database wipe is disabled")) })
243+ }
244+}
Added file: vineyard.example.toml+25 additions
@@ -0,0 +1,25 @@
1+public_url = "wss://relay.example.org/"
2+listen = "127.0.0.1:7777"
3+database = "vineyard.sqlite"
4+forge_pubkeys = ["79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"]
5+peers = []
6+allow_private_discovery = false
7+
8+[limits]
9+max_repositories = 4096
10+max_discovered_peers = 32
11+max_connections = 32
12+max_connections_per_ip = 8
13+max_subscriptions = 16
14+max_event_bytes = 2096896
15+max_message_bytes = 2097152
16+max_tags = 128
17+max_filters = 8
18+max_filter_bytes = 32768
19+max_query_results = 500
20+max_query_bytes = 16777216
21+events_per_minute = 120
22+queries_per_minute = 120
23+max_database_bytes = 1073741824
24+min_free_bytes = 67108864
25+write_timeout_seconds = 10