<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>BigBrotr | Blog</title><description/><link>https://bigbrotr.com/</link><language>en</language><item><title>Dead Keys: The Unsolved Problem of Marking Compromised Nostr Identities</title><link>https://bigbrotr.com/blog/dead-keys/</link><guid isPermaLink="true">https://bigbrotr.com/blog/dead-keys/</guid><description>Once a Nostr private key leaks, how do you mark the identity as dead — permanently, verifiably, and in a way the attacker cannot undo? We examined every existing mechanism in the protocol. None of them work. Here is what could.

</description><pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Once a Nostr private key leaks, how do you mark the identity as dead?&lt;/p&gt;
&lt;p&gt;Not temporarily. Not probabilistically. &lt;em&gt;Permanently&lt;/em&gt; — in a way the attacker cannot undo, and that every client on the network can verify.&lt;/p&gt;
&lt;p&gt;We went through every mechanism currently available in the protocol. None of them solve it. Here is why — and what could actually work.&lt;/p&gt;
&lt;p&gt;This article is about &lt;em&gt;remediation&lt;/em&gt; — what to do after a key has already leaked. Prevention is a separate problem with a good existing answer: NIP-46 (Nostr Connect) lets users delegate signing to a remote bunker without ever exposing the raw &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt;. If your key has not been compromised, adopt NIP-46. But for accounts already operating on a leaked key — like the &lt;a href=&quot;https://bigbrotr.com/blog/exposed-nsec-analysis/&quot;&gt;38 we identified in our nsec exposure analysis&lt;/a&gt; — prevention has already failed. The question is what happens now.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-constraint-nobody-can-work-around&quot;&gt;The Constraint Nobody Can Work Around&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Before examining specific solutions, there is one principle that rules out most approaches immediately.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Any event signed by key A can be replaced or repudiated by key A.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is not a flaw. It is intentional. Your key is your identity and you have sovereign control over your own event stream. The moment an attacker holds your &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt;, they inherit that sovereignty. They can publish, delete, and replace anything in your name with equal authority.&lt;/p&gt;
&lt;p&gt;Any warning broadcast using the compromised key can be overwritten with the same key. A &lt;code dir=&quot;auto&quot;&gt;kind:0&lt;/code&gt; profile update announcing compromise can be replaced by a newer &lt;code dir=&quot;auto&quot;&gt;kind:0&lt;/code&gt; without the flag. A warning note can be targeted by a &lt;code dir=&quot;auto&quot;&gt;kind:5&lt;/code&gt; deletion request. It does not matter that NIP-09 says relays &lt;em&gt;SHOULD&lt;/em&gt; honor deletions rather than &lt;em&gt;MUST&lt;/em&gt; — even if some relays ignore the deletion, the attacker can always publish a newer replaceable event that supersedes the warning. The result is a network where some clients see the warning and others do not. You cannot build a trust mechanism on a signal that is visible to some users sometimes.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The tombstone cannot live in the event stream of the compromised key.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Everything else follows from this.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;why-every-obvious-solution-fails&quot;&gt;Why Every Obvious Solution Fails&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;self-reporting-with-the-leaked-key&quot;&gt;Self-reporting with the leaked key&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The intuitive first move: publish a note or update your profile announcing the key is compromised. But in the cases that matter most — like the keys in our dataset, published in plaintext on Nostr — the attacker typically has the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; before the owner even knows it leaked. There is no race condition to win. The attacker is already watching.&lt;/p&gt;
&lt;p&gt;Even in the best case, where the owner discovers the leak first and broadcasts a warning to hundreds of relays simultaneously, the attacker can overwrite the &lt;code dir=&quot;auto&quot;&gt;kind:0&lt;/code&gt; profile with a clean version moments later. The warning becomes inconsistent across the network — present on some relays, gone on others. A security mechanism that depends on who queries which relay first is not a mechanism. It is a bet.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;kind-5-against-kind-5&quot;&gt;Kind 5 against kind 5&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;NIP-09 is explicit: publishing a deletion request against another deletion request has no effect. You cannot make an event undeletable by chaining deletion mechanics. The model does not support it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;a-compromise-tag-on-kind-0&quot;&gt;A compromise tag on kind 0&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;kind:0&lt;/code&gt; is a replaceable event. Clients always adopt the most recent version. An attacker publishes a new &lt;code dir=&quot;auto&quot;&gt;kind:0&lt;/code&gt; without the compromise tag — it becomes the canonical profile. The flag is gone.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;nip-56-reporting-kind-1984&quot;&gt;NIP-56 Reporting (kind 1984)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A &lt;code dir=&quot;auto&quot;&gt;kind:1984&lt;/code&gt; report is an external declaration from a third party. It carries a report type — including &lt;code dir=&quot;auto&quot;&gt;impersonation&lt;/code&gt; — but no proof that the reporter actually holds the private key. Anyone can report anyone. It is a claim, not a demonstration. The signal-to-noise ratio makes it useless as a trust mechanism for this specific use case.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;nip-32-labeling-kind-1985&quot;&gt;NIP-32 Labeling (kind 1985)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;More flexible than NIP-56, but the same fundamental weakness: arbitrary labels from arbitrary keys with no embedded proof of &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; possession. A &lt;code dir=&quot;auto&quot;&gt;compromised&lt;/code&gt; label means nothing if anyone can attach it to anyone.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;nip-58-badges-kind-8&quot;&gt;NIP-58 Badges (kind 8)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Badge awards are non-transferable by spec — which sounds promising. But the fundamental problem is the same as NIP-56 and NIP-32: anyone can define a &lt;code dir=&quot;auto&quot;&gt;compromised&lt;/code&gt; badge and award it to any account without proving they hold the private key. It is still just a claim. The issuer can also delete their own &lt;code dir=&quot;auto&quot;&gt;kind:8&lt;/code&gt; with a &lt;code dir=&quot;auto&quot;&gt;kind:5&lt;/code&gt; at any time, so the award is not even reliably persistent. No proof of key possession, no guarantee of permanence — NIP-58 does not help here.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;nip-62-request-to-vanish-kind-62&quot;&gt;NIP-62 Request to Vanish (kind 62)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This NIP actually worsens the threat model. An attacker holding the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; can issue a &lt;code dir=&quot;auto&quot;&gt;kind:62&lt;/code&gt; requesting all relays to permanently delete every event from that pubkey — including received DMs. The entire account history is erased before the victim knows anything happened.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;what-the-solutions-have-in-common&quot;&gt;What the Solutions Have in Common&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every failed approach shares one of two flaws:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The warning lives on the compromised key’s event stream&lt;/strong&gt; — repudiable by the attacker.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The warning is a claim with no cryptographic proof&lt;/strong&gt; — gameable by anyone.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A real solution requires both properties simultaneously: signed by an external key (so the attacker cannot touch it) &lt;em&gt;and&lt;/em&gt; containing proof of &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; possession (so it cannot be faked).&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;what-could-actually-work&quot;&gt;What Could Actually Work&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;nip-85-kind-30382--embedded-proof-tag&quot;&gt;NIP-85 (kind 30382) + embedded &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; tag&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;NIP-85 defines Trusted Assertions — addressable events published by external service keys that attest facts about other pubkeys. The infrastructure is already there. What is missing is a mechanism to attach a cryptographic proof of key possession.&lt;/p&gt;
&lt;p&gt;The proposal is a minimal extension to &lt;code dir=&quot;auto&quot;&gt;kind:30382&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;kind&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;30382&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;pubkey&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&amp;#x3C;discoverer key B&gt;&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;tags&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&amp;#x3C;compromised pubkey A&gt;&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;rank&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;compromised&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;proof&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&amp;#x3C;sig of key A over canonical message&gt;&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;content&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; tag contains a valid Schnorr signature produced with the leaked key over a canonical message defined independently of the attestation event itself — for example, the fixed string &lt;code dir=&quot;auto&quot;&gt;&quot;compromised:&amp;#x3C;pubkey A&gt;&quot;&lt;/code&gt;. Using the event’s own &lt;code dir=&quot;auto&quot;&gt;id&lt;/code&gt; would create a circular dependency: the &lt;code dir=&quot;auto&quot;&gt;id&lt;/code&gt; is a hash of the full serialized event including the &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; tag, so it cannot exist before the signature, and the signature cannot be produced without it. A pre-defined canonical message breaks the cycle and keeps verification straightforward. Anyone who encounters this event can perform two independent verifications:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The outer event signature is valid → key B authored this.&lt;/li&gt;
&lt;li&gt;The &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; field is a valid signature of key A → whoever wrote this possessed the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The attacker controls key A but not key B. They cannot delete or modify this event. Clients that implement NIP-85 can zero out the WoT score and display a permanent warning banner on the profile — regardless of what the compromised key subsequently publishes or deletes.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;why-false-flags-are-not-a-real-risk&quot;&gt;Why false flags are not a real risk&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A reasonable concern: what stops a malicious actor from falsely flagging an account as compromised?&lt;/p&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; tag makes this impossible. To produce a valid &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt;, you must hold the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; of the target key. If you have the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt;, the account &lt;em&gt;is&lt;/em&gt; compromised — by definition, regardless of how you obtained it. In Nostr, your key is your identity. Not your keys, not your account. If a custodial service or a signer app holds the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt;, the account was never fully sovereign to begin with — and reporting it is the least harmful thing anyone could do with that key.&lt;/p&gt;
&lt;p&gt;Key B is also permanently linked to the attestation. A false flag would be a public, on-chain declaration that you hold someone’s private key. The reputational cost is significant. The incentive structure self-selects for honest reporting.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-discovery-problem&quot;&gt;The discovery problem&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;For this mechanism to work, clients need a way to find compromise attestations. When loading a profile, a client must answer: “does a verified compromise attestation exist for this pubkey?”&lt;/p&gt;
&lt;p&gt;NIP-85 events are addressable by their &lt;code dir=&quot;auto&quot;&gt;d&lt;/code&gt; tag, which in this proposal is the compromised pubkey. A client can query any relay for &lt;code dir=&quot;auto&quot;&gt;kind:30382&lt;/code&gt; events with a matching &lt;code dir=&quot;auto&quot;&gt;d&lt;/code&gt; tag. But &lt;em&gt;which&lt;/em&gt; relays? And &lt;em&gt;whose&lt;/em&gt; attestations should it trust?&lt;/p&gt;
&lt;p&gt;The practical path is the same one NIP-85 already assumes: clients maintain a list of trusted attestation services — similar to how browsers ship with a list of trusted certificate authorities. A client might trust attestations from BigBrotr, from a well-known WoT scoring service, or from keys in the user’s own follow graph. These services earn trust over time through consistent, verifiable reporting. The trust model is explicit, not implicit.&lt;/p&gt;
&lt;p&gt;This is a centralization pressure point. A future extension could define a relay-level index — a dedicated &lt;code dir=&quot;auto&quot;&gt;kind&lt;/code&gt; that aggregates verified compromise attestations across multiple attestors — but that is beyond the scope of this proposal. For now, the CA-like model is the realistic starting point, and the &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; tag ensures that even a centralized attestor cannot fabricate compromise claims — they still need the &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;what-needs-to-happen-next&quot;&gt;What Needs to Happen Next&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is a proposal, not a standard. For it to become useful, three things need to happen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Protocol.&lt;/strong&gt; The &lt;code dir=&quot;auto&quot;&gt;proof&lt;/code&gt; tag and &lt;code dir=&quot;auto&quot;&gt;compromised&lt;/code&gt; field need to be formally specified — either as an extension to NIP-85 or as a new NIP — with a defined canonical message format for the embedded signature.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Clients.&lt;/strong&gt; At least two clients need to implement verification and rendering of the warning banner for the proposal to meet NIP acceptance criteria. Until a client implements this check, its users remain exposed to compromised keys with no warning. Implementing this is not optional overhead — it is a duty of care toward the people who use the client.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Relays.&lt;/strong&gt; Relays should index &lt;code dir=&quot;auto&quot;&gt;kind:30382&lt;/code&gt; events by their &lt;code dir=&quot;auto&quot;&gt;d&lt;/code&gt; tag (already required by NIP-85) so that clients can efficiently query “does a verified compromise attestation exist for pubkey X?” at profile load time.&lt;/p&gt;
&lt;p&gt;The 38 accounts in our dataset — and the dozens more that will surface as relay archives grow — deserve a protocol that can tell their followers the truth. Right now, there is no way to do that.&lt;/p&gt;
&lt;p&gt;There should be.&lt;/p&gt;</content:encoded><category>protocol</category><category>nostr</category><category>security</category><category>nip</category></item><item><title>Self-Hosting BigBrotr: From Bare Metal to Production in One Afternoon</title><link>https://bigbrotr.com/blog/self-hosting-bigbrotr/</link><guid isPermaLink="true">https://bigbrotr.com/blog/self-hosting-bigbrotr/</guid><description>A complete walkthrough for deploying BigBrotr on your own hardware — Proxmox, ZFS storage pools tuned for PostgreSQL, Docker Compose orchestration, Cloudflare Tunnel for zero-port API exposure, and production hardening. No cloud, no monthly bill, full control.

</description><pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Running a Nostr relay observatory means storing a lot of data. Events, metadata, health checks, materialized views — the database grows by gigabytes per day once all eight services are humming. Cloud hosting works, but the storage costs add up fast, and you’re always one &lt;code dir=&quot;auto&quot;&gt;terraform destroy&lt;/code&gt; away from losing your dataset.&lt;/p&gt;
&lt;p&gt;Self-hosting solves that. A dedicated machine with a few terabytes of SSD storage, a properly tuned PostgreSQL, and a Cloudflare Tunnel for secure API exposure gives you everything a cloud deployment would — minus the recurring bill.&lt;/p&gt;
&lt;p&gt;This post walks through the entire deployment: from a fresh Proxmox install to a fully operational BigBrotr instance serving &lt;code dir=&quot;auto&quot;&gt;api.yourdomain.com&lt;/code&gt; over HTTPS with zero inbound ports. It’s the same process we used for our own production deployment.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The full step-by-step guide with every command is available at &lt;a href=&quot;https://bigbrotr.com/docs/guides/self-hosting/&quot;&gt;Self-Hosting Guide&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-hardware&quot;&gt;The Hardware&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You don’t need anything exotic. A used server or a mini PC with enough RAM and a few SSDs is plenty. Here’s the minimum and what we actually used:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Component&lt;/th&gt;&lt;th&gt;Minimum&lt;/th&gt;&lt;th&gt;Our Setup&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;CPU&lt;/td&gt;&lt;td&gt;8 cores&lt;/td&gt;&lt;td&gt;16 cores / 32 threads&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;RAM&lt;/td&gt;&lt;td&gt;32 GB&lt;/td&gt;&lt;td&gt;96 GB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Boot&lt;/td&gt;&lt;td&gt;1x SSD (any size)&lt;/td&gt;&lt;td&gt;2x NVMe 1TB (ZFS mirror)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;2x SSD, 1TB+ (mirror)&lt;/td&gt;&lt;td&gt;4x 4TB SATA SSD (ZFS RAID10)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Backups&lt;/td&gt;&lt;td&gt;Any spare disk&lt;/td&gt;&lt;td&gt;2x 4TB SATA SSD (ZFS stripe)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The CPU isn’t the bottleneck — PostgreSQL and the services are mostly I/O-bound. RAM matters a lot (it’s PostgreSQL’s buffer cache), and SSD IOPS matter for write-heavy workloads like event archiving.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;why-zfs&quot;&gt;Why ZFS?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every disk in the system runs ZFS. It solves specific problems for a database workload that other filesystems don’t:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Data integrity&lt;/strong&gt;. ZFS checksums every block. A silent bit flip on a SATA disk doesn’t corrupt your &lt;code dir=&quot;auto&quot;&gt;relay&lt;/code&gt; table — ZFS detects it and repairs from the mirror.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compression&lt;/strong&gt;. LZ4 compression is practically free on modern CPUs and PostgreSQL data compresses well (~1.5-2x). That 4TB disk effectively holds 6-8TB of database pages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tunable record size&lt;/strong&gt;. PostgreSQL uses 8KB pages. We set &lt;code dir=&quot;auto&quot;&gt;recordsize=8K&lt;/code&gt; on the database pool, so every ZFS block maps exactly to one PostgreSQL page. No read-modify-write amplification, no wasted space.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Metadata-only caching&lt;/strong&gt;. PostgreSQL manages its own buffer cache via &lt;code dir=&quot;auto&quot;&gt;shared_buffers&lt;/code&gt;. Having ZFS also cache the same data in ARC wastes RAM. Setting &lt;code dir=&quot;auto&quot;&gt;primarycache=metadata&lt;/code&gt; tells ZFS to only cache filesystem metadata, leaving the actual data caching to PostgreSQL where it belongs.&lt;/p&gt;
&lt;p&gt;The database pool is RAID10 (two mirrored pairs striped) — fast reads, fast writes, survives a disk failure in each pair. The backup pool is a stripe (no redundancy) because dump files are reproducible.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-vm-architecture&quot;&gt;The VM Architecture&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr runs in a single Proxmox VM with three virtual disks, each backed by a different ZFS pool:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;┌──────────────────────────────────────────────────┐&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│              Proxmox Host (NVMe)                 │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│                                                  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  ┌────────────────────────────────────────────┐  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │         VM &quot;bigbrotr&quot; (Debian 13)          │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │                                            │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │  scsi0  →  NVMe pool  →  OS (50 GiB)       │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │  scsi1  →  datapool   →  /mnt/pgdata       │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │  scsi2  →  workpool   →  /mnt/work         │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │                                            │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │  Docker Compose (15 containers)            │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  │  cloudflared (Cloudflare Tunnel)           │  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│  └────────────────────────────────────────────┘  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;└──────────────────────────────────────────────────┘&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The OS disk lives on fast NVMe (for Docker image pulls, container startups, and general OS operations). The database data directory is symlinked to &lt;code dir=&quot;auto&quot;&gt;/mnt/pgdata&lt;/code&gt;, which is an XFS filesystem on a dedicated virtual disk backed by the RAID10 pool. The work disk holds dump files and research exports.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why a VM instead of bare metal?&lt;/strong&gt; Proxmox lets you snapshot the entire VM before risky operations, easily resize disks, and run additional VMs (like a research environment) on the same hardware. The overhead of KVM virtualization with VirtIO paravirtualized devices is negligible for this workload.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why XFS inside the VM?&lt;/strong&gt; The virtual disk already sits on ZFS (which handles checksumming, compression, and mirroring). Inside the VM, XFS adds journal tuning options (&lt;code dir=&quot;auto&quot;&gt;logbufs=8,logbsize=256k&lt;/code&gt;) that improve PostgreSQL write throughput. It’s one of the few filesystems that performs well under sustained sequential writes from WAL and checkpoint I/O.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;postgresql-tuning--its-mostly-about-ram&quot;&gt;PostgreSQL Tuning — It’s Mostly About RAM&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The shipped &lt;code dir=&quot;auto&quot;&gt;postgresql.conf&lt;/code&gt; is tuned for a small 4GB development environment. For production, the most impactful changes are memory-related:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# 64GB RAM example&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;shared_buffers&lt;/span&gt;&lt;span&gt; = 16GB          &lt;/span&gt;&lt;span&gt;# 25% of RAM&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;effective_cache_size&lt;/span&gt;&lt;span&gt; = 48GB    &lt;/span&gt;&lt;span&gt;# 75% of RAM (includes OS page cache)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;work_mem&lt;/span&gt;&lt;span&gt; = 64MB                &lt;/span&gt;&lt;span&gt;# per-sort/hash, generous for analytics&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;maintenance_work_mem&lt;/span&gt;&lt;span&gt; = 2GB     &lt;/span&gt;&lt;span&gt;# fast VACUUM and CREATE INDEX&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Setting &lt;code dir=&quot;auto&quot;&gt;shared_buffers&lt;/code&gt; to 25% of RAM is the standard starting point — going higher rarely helps because the OS page cache handles the rest, and &lt;code dir=&quot;auto&quot;&gt;effective_cache_size&lt;/code&gt; tells the query planner how much total cache (PostgreSQL + OS) is available. Scale to your hardware:&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;RAM&lt;/th&gt;&lt;th&gt;shared_buffers&lt;/th&gt;&lt;th&gt;effective_cache_size&lt;/th&gt;&lt;th&gt;work_mem&lt;/th&gt;&lt;th&gt;maintenance_work_mem&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;32 GB&lt;/td&gt;&lt;td&gt;8 GB&lt;/td&gt;&lt;td&gt;24 GB&lt;/td&gt;&lt;td&gt;32 MB&lt;/td&gt;&lt;td&gt;1 GB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;64 GB&lt;/td&gt;&lt;td&gt;16 GB&lt;/td&gt;&lt;td&gt;48 GB&lt;/td&gt;&lt;td&gt;64 MB&lt;/td&gt;&lt;td&gt;2 GB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;96 GB&lt;/td&gt;&lt;td&gt;24 GB&lt;/td&gt;&lt;td&gt;72 GB&lt;/td&gt;&lt;td&gt;128 MB&lt;/td&gt;&lt;td&gt;4 GB&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;128 GB&lt;/td&gt;&lt;td&gt;32 GB&lt;/td&gt;&lt;td&gt;96 GB&lt;/td&gt;&lt;td&gt;128 MB&lt;/td&gt;&lt;td&gt;4 GB&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;For a write-heavy workload like BigBrotr (the Synchronizer can insert thousands of events per cycle), these settings matter:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;synchronous_commit&lt;/span&gt;&lt;span&gt; = off       &lt;/span&gt;&lt;span&gt;# async commits, ~10ms data risk&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;checkpoint_timeout&lt;/span&gt;&lt;span&gt; = 15min     &lt;/span&gt;&lt;span&gt;# less frequent checkpoints&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;max_wal_size&lt;/span&gt;&lt;span&gt; = 8GB             &lt;/span&gt;&lt;span&gt;# more WAL before forced checkpoint&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;synchronous_commit = off&lt;/code&gt; is the single biggest performance lever. It means PostgreSQL acknowledges commits before the WAL is flushed to disk. You could lose up to ~10ms of recent transactions in a crash. For Nostr event archiving, this is perfectly acceptable — events can be re-fetched from relays.&lt;/p&gt;
&lt;p&gt;The autovacuum settings are also aggressive because BigBrotr tables see constant inserts and updates:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;autovacuum_naptime&lt;/span&gt;&lt;span&gt; = 30s&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;autovacuum_vacuum_scale_factor&lt;/span&gt;&lt;span&gt; = 0.05&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;autovacuum_vacuum_cost_delay&lt;/span&gt;&lt;span&gt; = 2ms&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;autovacuum_vacuum_cost_limit&lt;/span&gt;&lt;span&gt; = 2000&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This tells PostgreSQL: check for dead tuples every 30 seconds, vacuum after just 5% of rows are dead, and don’t throttle vacuum I/O. On a dedicated server, there’s no reason to hold back.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;zero-port-api-exposure-with-cloudflare-tunnel&quot;&gt;Zero-Port API Exposure with Cloudflare Tunnel&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The traditional way to expose a web service is: open a port, get a certificate, configure a reverse proxy. Each step is a potential attack surface.&lt;/p&gt;
&lt;p&gt;Cloudflare Tunnel flips this. Instead of opening inbound ports, a daemon (&lt;code dir=&quot;auto&quot;&gt;cloudflared&lt;/code&gt;) inside the VM creates an outbound HTTPS connection to Cloudflare’s edge network. Cloudflare then routes incoming requests for &lt;code dir=&quot;auto&quot;&gt;api.yourdomain.com&lt;/code&gt; through this tunnel to your local API.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;User  ──HTTPS──▶  Cloudflare Edge  ──encrypted tunnel──▶  cloudflared  ──HTTP──▶  localhost:8080&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(TLS, WAF, DDoS)                        (inside VM)              (FastAPI)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The result:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero open inbound ports&lt;/strong&gt; on the server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free TLS certificates&lt;/strong&gt; managed by Cloudflare&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DDoS protection&lt;/strong&gt; on the free tier&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No firewall rules to maintain&lt;/strong&gt; for the API&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last hop — cloudflared to localhost:8080 — is plain HTTP, which is fine because it never leaves the VM. Setting HTTPS here would just add unnecessary TLS overhead for a loopback connection.&lt;/p&gt;
&lt;p&gt;Setup is three steps: add your domain to Cloudflare, create a tunnel in the Zero Trust dashboard, install &lt;code dir=&quot;auto&quot;&gt;cloudflared&lt;/code&gt; with the provided token. Five minutes, and your API is live on &lt;code dir=&quot;auto&quot;&gt;https://api.yourdomain.com&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;security-layers&quot;&gt;Security Layers&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Defense in depth, not a single point of trust:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; — No inbound ports, DDoS protection, WAF&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UFW Firewall&lt;/strong&gt; — Default deny incoming, allow only SSH (custom port) and local-network services&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSH hardening&lt;/strong&gt; — Key-only authentication, non-standard port, fail2ban (24-hour ban after 3 failures)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL roles&lt;/strong&gt; — Four database roles with least-privilege access (admin, writer, reader, refresher)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PGBouncer&lt;/strong&gt; — Connection pooling with SCRAM-SHA-256 authentication&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker isolation&lt;/strong&gt; — All services run as non-root users inside containers&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The firewall allows PostgreSQL, Grafana, and Prometheus only from the local subnet — for research queries and monitoring. Everything else is denied.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-production-deployment-folder&quot;&gt;The Production Deployment Folder&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;One lesson from deploying: keep your production configuration completely separate from the repository. You don’t even need git on the server. BigBrotr ships deployment templates in each release — you download just the deployment folder and run it standalone:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;/opt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;VARIANT&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;bigbrotr&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;# or lilbrotr&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;RELEASE&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;$(&lt;/span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-s&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;https://api.github.com/repos/BigBrotr/bigbrotr/releases/latest&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;grep&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tarball_url&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cut&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-f&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;4&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-sL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;$RELEASE&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tar&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;xz&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;mv&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BigBrotr-bigbrotr-&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;/deployments/&lt;/span&gt;&lt;span&gt;$VARIANT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;VARIANT&lt;/span&gt;&lt;span&gt;}-production&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;rm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-rf&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BigBrotr-bigbrotr-&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This gives you a standalone folder at &lt;code dir=&quot;auto&quot;&gt;/opt/bigbrotr-production/&lt;/code&gt; with everything: Docker Compose config, PostgreSQL tuning, PGBouncer settings, monitoring stack, SQL init scripts, and backup script. No repository checkout needed.&lt;/p&gt;
&lt;p&gt;Then swap the &lt;code dir=&quot;auto&quot;&gt;build:&lt;/code&gt; blocks in &lt;code dir=&quot;auto&quot;&gt;docker-compose.yaml&lt;/code&gt; for pre-built Docker Hub images:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Replace every build: block with:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;image&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;vincenzoimp/bigbrotr:6&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# or vincenzoimp/lilbrotr:6&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;:6&lt;/code&gt; tag always points to the latest 6.x.x release. Updating becomes a single command:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;/opt/bigbrotr-production&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compose&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;pull&lt;/span&gt;&lt;span&gt; &amp;#x26;&amp;#x26; &lt;/span&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compose&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;up&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No git, no builds, no merge conflicts. Your local configuration (&lt;code dir=&quot;auto&quot;&gt;.env&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;postgresql.conf&lt;/code&gt;, port bindings) is never touched by updates.&lt;/p&gt;
&lt;p&gt;If you don’t need full event storage (tags, content, signatures), LilBrotr is a lightweight alternative that uses the same codebase but stores only event metadata — roughly 60% less disk usage. Same deployment process, just swap &lt;code dir=&quot;auto&quot;&gt;bigbrotr&lt;/code&gt; for &lt;code dir=&quot;auto&quot;&gt;lilbrotr&lt;/code&gt; in the variant and image name. Both can even run on the same hardware with isolated databases.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-looks-like-running&quot;&gt;What It Looks Like Running&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;After starting all services with &lt;code dir=&quot;auto&quot;&gt;docker compose up -d&lt;/code&gt;, you get 15 containers (14 running at steady state — the Seeder exits after its one-shot run):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL 16&lt;/strong&gt; — the shared database, tuned for your hardware&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PGBouncer&lt;/strong&gt; — connection pooling in transaction mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tor&lt;/strong&gt; — SOCKS5 proxy for .onion relay monitoring&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;8 BigBrotr services&lt;/strong&gt; — seeder, finder, validator, monitor, synchronizer, refresher, api, dvm&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prometheus + Alertmanager + Grafana&lt;/strong&gt; — full observability stack&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;postgres-exporter&lt;/strong&gt; — database metrics&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Seeder runs once and inserts ~7,500 known relay URLs as candidates. The Validator starts checking them via WebSocket — clearnet relays at 50 concurrent connections, Tor relays at 10 (through the SOCKS5 proxy). Within 30 minutes, validated relays start appearing in the database. The Monitor begins health-checking them, the Synchronizer starts archiving events, and the Refresher keeps the materialized views up to date.&lt;/p&gt;
&lt;p&gt;The API is reachable at &lt;code dir=&quot;auto&quot;&gt;https://api.yourdomain.com/health&lt;/code&gt; — no port forwarding, no NGINX config, no Let’s Encrypt renewal scripts.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;lessons-from-the-first-boot&quot;&gt;Lessons From the First Boot&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;No deployment survives first contact without surprises. Here’s what bit us on the actual production bring-up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The vanishing DNS resolver.&lt;/strong&gt; We configured a static IP in &lt;code dir=&quot;auto&quot;&gt;/etc/network/interfaces&lt;/code&gt; and everything worked — until the first reboot. Turns out, when you switch from DHCP to static, nothing populates &lt;code dir=&quot;auto&quot;&gt;/etc/resolv.conf&lt;/code&gt; anymore. It was empty after boot. Cloudflared couldn’t resolve its SRV records and crashed in a restart loop, &lt;code dir=&quot;auto&quot;&gt;apt&lt;/code&gt; couldn’t reach any repository, and the services that needed to reach external APIs quietly failed. The fix is dead simple: write your nameservers manually and lock the file with &lt;code dir=&quot;auto&quot;&gt;chattr +i /etc/resolv.conf&lt;/code&gt; so nothing overwrites it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Docker’s 64MB shared memory trap.&lt;/strong&gt; We tuned PostgreSQL with &lt;code dir=&quot;auto&quot;&gt;shared_buffers = 16GB&lt;/code&gt; and everything looked fine — until the Refresher tried to refresh materialized views. Three out of eleven views failed with &lt;code dir=&quot;auto&quot;&gt;could not resize shared memory segment: No space left on device&lt;/code&gt;. Confusing, because the data disk had 7TB free. The problem is Docker’s default &lt;code dir=&quot;auto&quot;&gt;/dev/shm&lt;/code&gt; size: 64MB. PostgreSQL parallel workers need shared memory segments proportional to &lt;code dir=&quot;auto&quot;&gt;shared_buffers&lt;/code&gt;. The fix is one line in docker-compose: &lt;code dir=&quot;auto&quot;&gt;shm_size: 16g&lt;/code&gt; on the postgres service. Match it to your &lt;code dir=&quot;auto&quot;&gt;shared_buffers&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GeoLite2 permission denied.&lt;/strong&gt; The Monitor downloads MaxMind GeoIP databases on first run to geolocate relay IPs. It tried to write them to the &lt;code dir=&quot;auto&quot;&gt;static/&lt;/code&gt; directory — which is bind-mounted from the host and owned by root. The container runs as uid 1000. Permission denied, five consecutive failures, service stops. A &lt;code dir=&quot;auto&quot;&gt;chown -R 1000:1000&lt;/code&gt; on the host directory fixed it permanently.&lt;/p&gt;
&lt;p&gt;None of these are BigBrotr bugs — they’re infrastructure gotchas that apply to any Docker + PostgreSQL deployment on self-hosted hardware. But they’re the kind of thing that wastes hours if you don’t know to look for them.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;backups&quot;&gt;Backups&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The deployment folder includes a &lt;code dir=&quot;auto&quot;&gt;backup.sh&lt;/code&gt; script that dumps the database (compressed with gzip) and keeps the 7 most recent dumps. If you have a dedicated backup disk, symlink the &lt;code dir=&quot;auto&quot;&gt;dumps/&lt;/code&gt; directory to it. For automated daily backups:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;echo&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;0 4 * * * root /opt/bigbrotr-production/backup.sh &gt;&gt; /var/log/bigbrotr-backup.log 2&gt;&amp;#x26;1&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;/etc/cron.d/bigbrotr-backup&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Since BigBrotr data is re-fetchable from Nostr relays, backups are more about convenience than disaster recovery — restoring from a dump is faster than re-syncing from scratch.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;whats-next&quot;&gt;What’s Next&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you’re interested in running your own instance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check the &lt;a href=&quot;https://bigbrotr.com/docs/guides/self-hosting/&quot;&gt;Self-Hosting Guide&lt;/a&gt; for every command, line by line&lt;/li&gt;
&lt;li&gt;The &lt;a href=&quot;https://bigbrotr.com/docs/blog/inside-bigbrotr/&quot;&gt;Architecture Deep Dive&lt;/a&gt; explains the service design&lt;/li&gt;
&lt;li&gt;Open an issue on &lt;a href=&quot;https://github.com/BigBrotr/bigbrotr&quot;&gt;GitHub&lt;/a&gt; if you run into problems&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;BigBrotr is designed to run unattended. Once deployed, the services handle their own lifecycle — retrying failed connections, rotating through relays, refreshing views on schedule. Your main ongoing tasks are checking Grafana occasionally, running &lt;code dir=&quot;auto&quot;&gt;docker compose logs&lt;/code&gt; if an alert fires, and doing a &lt;code dir=&quot;auto&quot;&gt;docker compose pull &amp;#x26;&amp;#x26; docker compose up -d&lt;/code&gt; when a new release drops.&lt;/p&gt;
&lt;p&gt;The Nostr network is growing. The more independent observers running, the more complete the picture. If you’ve got spare hardware and a domain, this is a weekend project that keeps paying off.&lt;/p&gt;</content:encoded><category>tutorial</category><category>self-hosting</category><category>infrastructure</category><category>postgresql</category></item><item><title>Uncovering Exposed Private Keys Across the Nostr Network</title><link>https://bigbrotr.com/blog/exposed-nsec-analysis/</link><guid isPermaLink="true">https://bigbrotr.com/blog/exposed-nsec-analysis/</guid><description>We searched 41 million Nostr events for private keys published in plaintext. After filtering out noise, 38 real accounts with over 21,000 combined followers are actively using a compromised key with no signs of awareness.

</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Your Nostr identity is a key pair. The &lt;code dir=&quot;auto&quot;&gt;npub&lt;/code&gt; is public — share it everywhere. The &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; is private — leak it and anyone can impersonate you. There is no password reset, no support ticket, no recovery. The nsec &lt;em&gt;is&lt;/em&gt; the account.&lt;/p&gt;
&lt;p&gt;So what happens when private keys end up published in plaintext on the very network they grant access to?&lt;/p&gt;
&lt;p&gt;We set out to answer this question using BigBrotr’s event archive — over &lt;strong&gt;41 million events&lt;/strong&gt; collected from &lt;strong&gt;1,085 relays&lt;/strong&gt; across clearnet, Tor, and I2P networks over 48 hours of continuous synchronization.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;searching-for-nsec-strings&quot;&gt;Searching for nsec Strings&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We searched every archived event — both the &lt;code dir=&quot;auto&quot;&gt;content&lt;/code&gt; field and &lt;code dir=&quot;auto&quot;&gt;tags&lt;/code&gt; — for strings matching the &lt;code dir=&quot;auto&quot;&gt;nsec1&lt;/code&gt; Bech32 prefix. Each match was extracted via regex and validated by attempting to derive a public key from it using the secp256k1 curve.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Metric&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Count&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Events containing &lt;code dir=&quot;auto&quot;&gt;nsec1&lt;/code&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;17,956&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Unique nsec strings found&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;16,941&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Valid private keys&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;16,599&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Invalid (truncated or fake)&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;342&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;16,599 valid Nostr private keys, published in plaintext, recoverable by anyone with access to a relay archive. But that number is misleading.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-headline-number-is-noise&quot;&gt;The Headline Number Is Noise&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Anyone can publish a private key — including bots that mass-produce throwaway accounts. A single bot, which we call “Mr.nsec,” accounts for &lt;strong&gt;15,285&lt;/strong&gt; of those 16,599 keys (92%). It creates throwaway accounts named &lt;code dir=&quot;auto&quot;&gt;Mr.{nsec}&lt;/code&gt; with the bio &lt;code dir=&quot;auto&quot;&gt;&quot;Just your average nostr enjoyer&quot;&lt;/code&gt; — each publishing a single profile event exposing a different private key, each from a unique throwaway pubkey that is never used again.&lt;/p&gt;
&lt;p&gt;By key count, the bot dominates the dataset. But the accounts whose keys it republishes have almost no followers. By social impact, the bot is irrelevant.&lt;/p&gt;
&lt;p&gt;The real question is not &lt;em&gt;how many keys are exposed&lt;/em&gt; but &lt;strong&gt;how many real identities are at risk&lt;/strong&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;from-16599-to-38&quot;&gt;From 16,599 to 38&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We applied successive filters to narrow down to the accounts that actually matter:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;From raw key count to real impact&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;4798&quot; height=&quot;1530&quot; src=&quot;https://bigbrotr.com/_astro/fig_01_impact_funnel.Z5-Fci_k_Z2pzxp9.webp&quot;&gt;&lt;/p&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Filter&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Keys&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;%&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;nsec1 strings found&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;16,941&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;100%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Valid private keys&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;16,599&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;98.0%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;With at least 1 follower&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;463&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2.7%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Real identity at risk&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;86&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.5%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Still active (last 90 days)&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;40&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;0.24%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;No signs of compromise&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;38&lt;/strong&gt;&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;0.22%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;A “real identity at risk” is defined as: at least 10 followers, a profile with a name, at least 10 events published, and the nsec was not published by the account itself (excluding intentional sharing and self-leaks). Of those 86, we checked which ones were still active and manually inspected each profile for signs of unauthorized use.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;38 accounts are actively using a compromised key with no visible signs of awareness.&lt;/strong&gt; They collectively have over &lt;strong&gt;21,000 followers&lt;/strong&gt;. Two additional accounts show clear signs of unauthorized use (profile defaced, spam content).&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;who-leaked-the-keys&quot;&gt;Who Leaked the Keys?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Excluding the bot, 1,314 organically leaked keys remain. Two questions matter: &lt;strong&gt;who published them&lt;/strong&gt;, and &lt;strong&gt;how&lt;/strong&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;authorship&quot;&gt;Authorship&lt;/h3&gt;&lt;/div&gt;


























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Authorship&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Keys&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;% of keys&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Followers exposed&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;% of followers&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Self-leak&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;742&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;56.5%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;26,924&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;29.6%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Third-party&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;572&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;43.5%&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;63,945&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;&lt;strong&gt;70.4%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The split is nearly even by key count — &lt;strong&gt;56% self-leak, 44% third-party.&lt;/strong&gt; But the follower distribution is heavily skewed: third-party leaks account for &lt;strong&gt;70% of all follower exposure&lt;/strong&gt;, because they disproportionately target accounts with social presence. Self-leaks are mostly users pasting their nsec into a profile field, confusing it with their npub — typically new accounts with few followers.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;leak-vectors-weighted-by-social-reach&quot;&gt;Leak vectors weighted by social reach&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;img alt=&quot;By key count vs by follower exposure&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;4793&quot; height=&quot;1590&quot; src=&quot;https://bigbrotr.com/_astro/fig_02_categories.BBH0DQ_x_Z1TImPy.webp&quot;&gt;&lt;/p&gt;
&lt;p&gt;By follower exposure — the metric that actually matters — profile-field leaks dominate at 74%. The real damage is done by users who accidentally pasted their nsec into a profile field. Smaller categories include nsec strings in contact list relay fields, bare nsec posts, and AI agents publishing credentials in operational logs.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;how-many-leaked-keys-have-social-presence&quot;&gt;How many leaked keys have social presence?&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;When we exclude bot-generated keys, &lt;strong&gt;35% of organically leaked keys&lt;/strong&gt; belong to accounts with at least one follower:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Follower distribution — all keys vs non-bot keys&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;2654&quot; height=&quot;1731&quot; src=&quot;https://bigbrotr.com/_astro/fig_05_follower_cdf.DsiiqeWy_1mUN2x.webp&quot;&gt;&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-organic-leak-rate&quot;&gt;The Organic Leak Rate&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Removing the bot reveals the underlying trend of organic key exposure:&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Monthly timeline of leak events with bot excluded&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;3548&quot; height=&quot;1445&quot; src=&quot;https://bigbrotr.com/_astro/fig_03_timeline.CtflOygM_Z2sOht2.webp&quot;&gt;&lt;/p&gt;
&lt;p&gt;The bot operated in a single burst. The organic rate is steady and ongoing — &lt;strong&gt;a persistent UX problem, not a one-time attack.&lt;/strong&gt; As long as clients allow users to paste nsec strings into profile fields without warning, new keys will continue to be exposed.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;relay-spread&quot;&gt;Relay Spread&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;How many relays serve each leaked key?&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;CDF of relay spread by follower band&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;2653&quot; height=&quot;1715&quot; src=&quot;https://bigbrotr.com/_astro/fig_04_relay_spread.CNNSx3UK_yOj7v.webp&quot;&gt;&lt;/p&gt;
&lt;p&gt;The median leaked key is available on just 1 relay. But accounts with more followers tend to see their leak events on more relays — up to 30+ for the most popular ones. This is expected: popular accounts’ events are replicated more widely in general, not just leak events. Regardless of the cause, the practical implication is the same: for accounts with wide relay distribution, deleting the event from one relay is insufficient. &lt;strong&gt;Key rotation is the only reliable remediation.&lt;/strong&gt;&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-38-accounts-that-need-to-know&quot;&gt;The 38 Accounts That Need to Know&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Of the 86 real identities with exposed keys, 40 have posted in the last 90 days. We manually inspected each profile to determine whether the account showed signs of compromise (spam, defaced profile, incoherent content) or appeared to be used normally.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Tier 1 at-risk identities: followers vs events, active vs inactive&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; fetchpriority=&quot;auto&quot; width=&quot;2674&quot; height=&quot;2175&quot; src=&quot;https://bigbrotr.com/_astro/fig_06_tier1.B51Z-S-9_Z15Kp8b.webp&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;38 accounts show no signs of compromise&lt;/strong&gt; — they appear to be used normally by the original owner, who likely does not know their private key is publicly available. Two accounts show clear signs of unauthorized use.&lt;/p&gt;


















































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th align=&quot;right&quot;&gt;Followers&lt;/th&gt;&lt;th align=&quot;right&quot;&gt;Events&lt;/th&gt;&lt;th&gt;Leak date&lt;/th&gt;&lt;th&gt;Last active&lt;/th&gt;&lt;th&gt;Status&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;18,900&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;5,288&lt;/td&gt;&lt;td&gt;2024-12-27&lt;/td&gt;&lt;td&gt;2026-03-15&lt;/td&gt;&lt;td&gt;compromised&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;13,657&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;1,525&lt;/td&gt;&lt;td&gt;2023-11-18&lt;/td&gt;&lt;td&gt;2026-02-10&lt;/td&gt;&lt;td&gt;compromised&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;3,995&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;4,485&lt;/td&gt;&lt;td&gt;2023-08-24&lt;/td&gt;&lt;td&gt;2026-03-15&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;2,543&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;3,195&lt;/td&gt;&lt;td&gt;2024-11-18&lt;/td&gt;&lt;td&gt;2026-03-16&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1,887&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;102&lt;/td&gt;&lt;td&gt;2023-12-01&lt;/td&gt;&lt;td&gt;2026-01-24&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1,800&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;16,050&lt;/td&gt;&lt;td&gt;2023-12-11&lt;/td&gt;&lt;td&gt;2026-03-15&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1,337&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;525&lt;/td&gt;&lt;td&gt;2024-04-12&lt;/td&gt;&lt;td&gt;2026-03-15&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1,295&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;14,069&lt;/td&gt;&lt;td&gt;2023-12-05&lt;/td&gt;&lt;td&gt;2026-03-16&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1,034&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;8,949&lt;/td&gt;&lt;td&gt;2024-11-03&lt;/td&gt;&lt;td&gt;2026-03-16&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td align=&quot;right&quot;&gt;1,006&lt;/td&gt;&lt;td align=&quot;right&quot;&gt;2,349&lt;/td&gt;&lt;td&gt;2023-09-17&lt;/td&gt;&lt;td&gt;2026-03-16&lt;/td&gt;&lt;td&gt;no signs&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The remaining 30 accounts (12–851 followers) all show no signs of compromise.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;check-if-your-key-is-exposed&quot;&gt;Check If Your Key Is Exposed&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We deployed an &lt;a href=&quot;https://github.com/BigBrotr/nsec-leak-checker&quot;&gt;nsec-leak-checker&lt;/a&gt; — a Nostr DVM (Data Vending Machine) that lets you check if your private key has been found in public events. Send a Kind 5300 event signed with your keys (with a &lt;code dir=&quot;auto&quot;&gt;p&lt;/code&gt; tag pointing to the DVM’s pubkey), and it responds with a NIP-44 encrypted message that only you can read.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-clients-and-relay-operators-can-do&quot;&gt;What Clients and Relay Operators Can Do&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This isn’t a protocol vulnerability — Nostr’s key-based identity model is sound. The leaks are user errors, client oversights, and AI agent carelessness.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Clients&lt;/strong&gt; should reject nsec strings in profile fields before signing the event. A regex check on Kind 0 content before broadcast would prevent the most damaging leak category entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Relay operators&lt;/strong&gt; could reject events containing valid nsec strings. This is more aggressive and has trade-offs (false positives on educational content, shared accounts), but it would neutralize the bot completely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;AI agent developers&lt;/strong&gt; should sanitize logs before publishing them as events. Private keys and credentials have no place in broadcast messages.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Users&lt;/strong&gt; should understand the difference: &lt;code dir=&quot;auto&quot;&gt;npub&lt;/code&gt; is your address, &lt;code dir=&quot;auto&quot;&gt;nsec&lt;/code&gt; is your password. If you’ve ever pasted an nsec into a profile field, &lt;strong&gt;generate a new key pair immediately&lt;/strong&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;limitations&quot;&gt;Limitations&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This analysis covers 1,085 of 2,009 known relays over approximately 48 hours of synchronization. The actual number of exposed keys across the full network is likely higher. We search only for the literal string &lt;code dir=&quot;auto&quot;&gt;nsec1&lt;/code&gt; — keys in hexadecimal format or obfuscated in any way are not detected. Follower counts reflect the current state of the network, not the state at the time of the leak. Account lifespan calculations are based on the earliest and latest events in our archive, which may not correspond to the account’s true creation date. The self-leak exclusion filter is conservative: it removes all keys published by the account itself, including accidental self-leaks by users who are genuine victims of UX confusion.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;methodology&quot;&gt;Methodology&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;All data was collected by BigBrotr’s Synchronizer service, archiving events from 1,085 relays using cursor-based pagination with binary-split windowing. nsec validation was performed using the &lt;code dir=&quot;auto&quot;&gt;nostr-sdk&lt;/code&gt; Python library. Follower counts were computed from the latest Kind 3 (contact list) event per pubkey. Profile data was extracted from the latest Kind 0 event. Recent activity was defined as any event published in the last 90 days. The 40 active at-risk accounts were manually inspected via their public profiles.&lt;/p&gt;
&lt;p&gt;The full dataset is available for researchers upon request.&lt;/p&gt;</content:encoded><category>analysis</category><category>nostr</category><category>security</category><category>research</category></item><item><title>BigBrotr vs Pensieve: Two Approaches to Indexing the Nostr Network</title><link>https://bigbrotr.com/blog/bigbrotr-vs-pensieve/</link><guid isPermaLink="true">https://bigbrotr.com/blog/bigbrotr-vs-pensieve/</guid><description>A deep technical comparison of two open-source projects that index the Nostr network — BigBrotr (Python/PostgreSQL relay observatory) and Pensieve (Rust/ClickHouse event archive). Same network, different questions, radically different architectures.

</description><pubDate>Sun, 15 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The Nostr network has no central authority, no canonical event store, no official list of relays. If you want to understand what’s happening on the network — how many relays exist, what events are flowing through them, who’s publishing, what’s growing — you have to build your own observation infrastructure.&lt;/p&gt;
&lt;p&gt;Two open-source projects tackle this problem from fundamentally different angles: &lt;strong&gt;BigBrotr&lt;/strong&gt; and &lt;strong&gt;Pensieve&lt;/strong&gt;. Both connect to Nostr relays, both store events, both produce analytics. But they ask different questions, make different trade-offs, and arrive at architectures that barely resemble each other.&lt;/p&gt;
&lt;p&gt;This post is a detailed technical comparison — not a “which is better” piece, but an honest look at what each does, how, and why.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-core-question&quot;&gt;The Core Question&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;BigBrotr&lt;/strong&gt; asks: &lt;em&gt;What relays exist on the Nostr network, how healthy are they, and what events are they publishing?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pensieve&lt;/strong&gt; asks: &lt;em&gt;What events exist on the Nostr network, and how fast can we archive all of them?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;BigBrotr is a &lt;strong&gt;relay observatory&lt;/strong&gt; — relay health, relay metadata, relay distribution, with events as one dimension of relay analysis. Pensieve is an &lt;strong&gt;event archive&lt;/strong&gt; — capture every event as fast as possible, with relay management as a means to maximize coverage.&lt;/p&gt;
&lt;p&gt;This distinction shapes everything that follows.&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;BigBrotr&lt;/th&gt;&lt;th&gt;Pensieve&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Language&lt;/td&gt;&lt;td&gt;Python 3.11+ (asyncio)&lt;/td&gt;&lt;td&gt;Rust 2024 edition (tokio)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Codebase&lt;/td&gt;&lt;td&gt;~18,000 lines&lt;/td&gt;&lt;td&gt;~12,000 lines&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;License&lt;/td&gt;&lt;td&gt;MIT&lt;/td&gt;&lt;td&gt;PolyForm Noncommercial 1.0.0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Primary database&lt;/td&gt;&lt;td&gt;PostgreSQL 16&lt;/td&gt;&lt;td&gt;ClickHouse + notepack archive&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Event fetching&lt;/td&gt;&lt;td&gt;Cursor-based crawler with completeness verification&lt;/td&gt;&lt;td&gt;Live subscription (firehose)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;how-they-fetch-events&quot;&gt;How They Fetch Events&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the most fundamental architectural difference — not what they store, but how they get events from relays.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;bigbrotr-systematic-crawling&quot;&gt;BigBrotr: Systematic Crawling&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr’s Synchronizer does not subscribe to live events. It operates as a &lt;strong&gt;systematic crawler&lt;/strong&gt;: for each validated relay, it opens a connection, requests a precise time window &lt;code dir=&quot;auto&quot;&gt;[since, until]&lt;/code&gt;, fetches events, and then &lt;strong&gt;verifies the response is complete&lt;/strong&gt; before advancing the cursor.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Request events in window &lt;code dir=&quot;auto&quot;&gt;[cursor_timestamp, end_time]&lt;/code&gt; with a limit (default 500)&lt;/li&gt;
&lt;li&gt;Receive the events — verify Ed25519 signatures, match NIP-01 filters, deduplicate in memory&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify completeness&lt;/strong&gt;: re-fetch at the boundary timestamp and confirm no events were missed. This catches relay truncation that would otherwise be invisible&lt;/li&gt;
&lt;li&gt;If the relay truncated: &lt;strong&gt;binary split&lt;/strong&gt; — halve the time window and retry each half recursively, down to single-second granularity if needed&lt;/li&gt;
&lt;li&gt;Only after verification: yield the events, record which relay had them, advance the cursor&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each relay has an independent cursor stored in the database. A relay that has never been synced starts at timestamp 0 — BigBrotr will crawl its &lt;em&gt;entire history&lt;/em&gt;. On restart, each relay resumes from its last verified position. Intentionally, the Synchronizer stays one day behind real-time (&lt;code dir=&quot;auto&quot;&gt;end_lag=86400&lt;/code&gt;) to let events settle on relays before crawling.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;pensieve-live-subscription&quot;&gt;Pensieve: Live Subscription&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Pensieve opens a WebSocket subscription with &lt;code dir=&quot;auto&quot;&gt;Filter::new()&lt;/code&gt; — “send me everything.” The relay pushes events in real time. For catch-up after restart, a &lt;code dir=&quot;auto&quot;&gt;since&lt;/code&gt; filter is applied based on the last checkpoint. Events flow through the pipeline as they arrive: dedupe → notepack → gzip → ClickHouse.&lt;/p&gt;
&lt;p&gt;Fast and simple. But the relay decides what to send and how much. If a relay silently truncates, enforces an internal limit, or drops events under load, Pensieve has no way to detect the gap.&lt;/p&gt;
&lt;p&gt;NIP-77 (Negentropy) is the recovery mechanism — periodically, Pensieve performs set reconciliation with trusted relays to identify missing events. But this is separate from live ingestion, works only with relays that support NIP-77 (very few currently), and covers only a configurable lookback window (default 14 days). A separate &lt;code dir=&quot;auto&quot;&gt;--catchup&lt;/code&gt; mode uses checkpoint-based resumption, though most relays don’t efficiently serve historical data.&lt;/p&gt;
&lt;p&gt;When a new relay is added, Pensieve only receives events published &lt;em&gt;after&lt;/em&gt; the connection. There is no historical backfill.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-trade-off&quot;&gt;The Trade-off&lt;/h3&gt;&lt;/div&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;BigBrotr&lt;/th&gt;&lt;th&gt;Pensieve&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Completeness&lt;/td&gt;&lt;td&gt;Verified per time window, per relay&lt;/td&gt;&lt;td&gt;Best-effort (relay decides what to send)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Historical coverage&lt;/td&gt;&lt;td&gt;Full history from epoch, per relay&lt;/td&gt;&lt;td&gt;From connection time + 14-day negentropy lookback&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;New relay added&lt;/td&gt;&lt;td&gt;Synchronized from the beginning&lt;/td&gt;&lt;td&gt;Only future events&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Truncation detection&lt;/td&gt;&lt;td&gt;Binary-split fallback, automatic&lt;/td&gt;&lt;td&gt;Not detected&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Throughput&lt;/td&gt;&lt;td&gt;Lower (verification overhead)&lt;/td&gt;&lt;td&gt;Higher (passive reception)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Latency&lt;/td&gt;&lt;td&gt;Minutes to hours behind real-time&lt;/td&gt;&lt;td&gt;Real-time&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Pensieve optimizes for &lt;strong&gt;volume and speed&lt;/strong&gt;. BigBrotr optimizes for &lt;strong&gt;completeness and accuracy&lt;/strong&gt;. Everything else — storage engines, analytics, APIs — follows from this choice.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;bigbrotr-eight-independent-services-one-database&quot;&gt;BigBrotr: Eight Independent Services, One Database&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Seeder    Finder    Validator    Monitor    Synchronizer    Refresher    API    DVM&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│         │          │           │            │              │          │      │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;└─────────┴──────────┴───────────┴────────────┴──────────────┴──────────┴──────┘&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                                        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                                   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;PostgreSQL&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;All eight services are independent processes — no imports between them, no message queues, no shared state outside the database. Each reads what it needs from PostgreSQL, does its work, writes results back. A service can crash, restart, or scale without affecting the others.&lt;/p&gt;
&lt;p&gt;Each service is a separate Docker container. You start only what you need — &lt;code dir=&quot;auto&quot;&gt;docker compose up -d seeder monitor refresher api&lt;/code&gt; is a valid deployment. This modularity, combined with deep YAML configurability, means the same codebase serves very different purposes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Full observatory&lt;/strong&gt;: all services, all health checks, all events. The default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relay health only&lt;/strong&gt;: Seeder + Finder + Validator + Monitor + Refresher + API. No Synchronizer means no events stored, no event_relay tracking. Minimal storage, maximum relay intelligence.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event archive only&lt;/strong&gt;: Seeder + Validator + Synchronizer + Refresher + API. No Monitor, no health checks. Just events and their relay distribution.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kind-specific archive&lt;/strong&gt;: NIP-01 filters on the Synchronizer — archive only specific event kinds, authors, or tag patterns. The same filter syntax relays understand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No junction tracking&lt;/strong&gt;: override the &lt;code dir=&quot;auto&quot;&gt;event_relay_insert_cascade&lt;/code&gt; stored procedure to skip the &lt;code dir=&quot;auto&quot;&gt;event_relay&lt;/code&gt; table. All mutations flow through stored procedures, so this is a SQL-level change — no Python modifications.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lightweight mode (LilBrotr)&lt;/strong&gt;: a separate deployment variant that stores only event metadata (id, pubkey, created_at, kind, tagvalues) without tags, content, or signatures — ~60% disk savings.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The internal architecture follows a strict &lt;strong&gt;diamond DAG&lt;/strong&gt;: services at the top, core/nips/utils in the middle, pure frozen dataclass models at the bottom. Imports flow strictly downward, enforced by linter rules.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;pensieve-pipeline-with-archive-as-source-of-truth&quot;&gt;Pensieve: Pipeline with Archive as Source of Truth&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Sources (Relays | JSONL | Protobuf)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ DedupeIndex (RocksDB)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ SegmentWriter (notepack binary files)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ Compression (gzip, background thread)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ ClickHouseIndexer (background thread)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;→ rclone sync (offsite backup)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The notepack archive (compressed binary segments on disk) is the &lt;strong&gt;source of truth&lt;/strong&gt;. ClickHouse is a derived index — if corrupted, it can be rebuilt from the archive. This “archive-first” design means Pensieve can survive database loss, something PostgreSQL-based BigBrotr cannot do without backups.&lt;/p&gt;
&lt;p&gt;Four Rust crates: &lt;code dir=&quot;auto&quot;&gt;pensieve-core&lt;/code&gt; (shared types, event validation, notepack encoding), &lt;code dir=&quot;auto&quot;&gt;pensieve-ingest&lt;/code&gt; (the pipeline), &lt;code dir=&quot;auto&quot;&gt;pensieve-serve&lt;/code&gt; (analytics API), and &lt;code dir=&quot;auto&quot;&gt;pensieve-preview&lt;/code&gt; (Open Graph previews and JSON API for Nostr events).&lt;/p&gt;
&lt;p&gt;Beyond live relay ingestion, Pensieve supports &lt;strong&gt;JSONL and Protobuf backfill&lt;/strong&gt; from external archives (including S3 with resumable progress), and ships &lt;strong&gt;maintenance utilities&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;relay-cleanup&lt;/code&gt; (URL normalization, duplicate merging, dry-run mode) and &lt;code dir=&quot;auto&quot;&gt;repair-dedupe&lt;/code&gt; (RocksDB repair, integrity checks, data recovery).&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;storage&quot;&gt;Storage&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;bigbrotr-one-database-full-relational-model&quot;&gt;BigBrotr: One Database, Full Relational Model&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Everything lives in PostgreSQL 16: 6 tables, 25 stored procedures, 11 materialized views, 31 indexes, 4 least-privilege database roles. PGBouncer handles connection pooling. All mutations go through stored procedures for atomicity — cascade functions insert across multiple tables in a single SQL call.&lt;/p&gt;
&lt;p&gt;The most distinctive structure is the &lt;strong&gt;event_relay junction table&lt;/strong&gt;: a many-to-many relationship between events and relays. When the Synchronizer fetches an event from a relay, it records &lt;code dir=&quot;auto&quot;&gt;(event_id, relay_url, seen_at)&lt;/code&gt;. The same event on 50 relays produces 50 junction rows. This enables queries no other Nostr indexer can answer: replication factor per event, exclusive content per relay, distribution patterns by kind or network.&lt;/p&gt;
&lt;p&gt;Metadata is &lt;strong&gt;content-addressed&lt;/strong&gt; — SHA-256 hashed, so identical health check results across time or relays deduplicate automatically. The relay_metadata junction is time-series: the same relay accumulates metadata records over time, building a complete health history.&lt;/p&gt;
&lt;p&gt;The trade-off: PostgreSQL gives ACID, arbitrary JOINs, a massive ecosystem (Grafana, psql, pandas, any language with a PostgreSQL driver). But it’s a row store — full scans over hundreds of millions of events are slower than columnar, and storage is less compact.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;pensieve-five-storage-engines&quot;&gt;Pensieve: Five Storage Engines&lt;/h3&gt;&lt;/div&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Engine&lt;/th&gt;&lt;th&gt;Purpose&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;RocksDB (dedupe)&lt;/td&gt;&lt;td&gt;Event ID → status. Bloom filters (10 bits/key) for fast “not seen” checks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;RocksDB (sync state)&lt;/td&gt;&lt;td&gt;NIP-77 Negentropy state. Timestamp-keyed for range scans&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Notepack segments&lt;/td&gt;&lt;td&gt;Gzipped binary archive. Immutable, ~128 bytes smaller per event than JSON&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;ClickHouse&lt;/td&gt;&lt;td&gt;Columnar analytics. ReplacingMergeTree, 3 projections, ZSTD(3) on content&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SQLite&lt;/td&gt;&lt;td&gt;Relay quality metrics. Hourly/daily stats with automatic rollup&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Events are checked against the dedupe index &lt;em&gt;before&lt;/em&gt; notepack serialization — since ~90% of events from multiple relays are duplicates, this avoids wasting CPU on packing events that will be discarded.&lt;/p&gt;
&lt;p&gt;ClickHouse’s columnar storage excels at analytics: scanning only the &lt;code dir=&quot;auto&quot;&gt;kind&lt;/code&gt; column for a count-by-kind query, ZSTD compression on content, projections for pre-sorted alternate orderings. Materialized views use SummingMergeTree for incremental aggregation (reactions, comments, reposts) and AggregatingMergeTree for efficient unique counting — no full refresh needed.&lt;/p&gt;
&lt;p&gt;The trade-off: five storage engines means five failure modes and five backup strategies. If a segment fails to compress (fire-and-forget background thread) or ClickHouse indexing fails (no retry), data can be left inconsistent. The notepack archive doesn’t include checksums or format version headers.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;relay-management&quot;&gt;Relay Management&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;bigbrotr-deep-health-profiling&quot;&gt;BigBrotr: Deep Health Profiling&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Three dedicated services handle relays:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Finder&lt;/strong&gt; discovers relays from external HTTP APIs (nostr.watch by default, with JMESPath extraction and per-source cooldowns) and by scanning tagvalues of all archived events for relay URLs — not just kind:10002 NIP-65 events, but any tag in any event kind that contains a relay URL.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Validator&lt;/strong&gt; checks each candidate via WebSocket protocol handshake (including NIP-42 AUTH acceptance), promotes valid ones to the relay table, and tracks failures with exponential backoff. After 720 failures (~30 days at hourly checks), candidates are permanently removed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monitor&lt;/strong&gt; performs 7 health checks per relay per cycle, each with independent retry configuration (exponential backoff + jitter):&lt;/p&gt;





































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;/th&gt;&lt;th&gt;Data Collected&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;NIP-11&lt;/td&gt;&lt;td&gt;20+ fields: name, software, version, supported NIPs, limitations (13 subfields), fees, countries, languages&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;RTT&lt;/td&gt;&lt;td&gt;Three-phase: connection open, event read, event write with publish verification. Respects relay’s PoW difficulty&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SSL&lt;/td&gt;&lt;td&gt;Issuer, expiry, SANs, cipher, protocol, SHA-256 fingerprint. Two-connection methodology&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DNS&lt;/td&gt;&lt;td&gt;A/AAAA/CNAME/NS/PTR records, TTL&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Geolocation&lt;/td&gt;&lt;td&gt;Country, city, coordinates, timezone, geohash (precision 9) via GeoLite2 (auto-downloaded)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Network/ASN&lt;/td&gt;&lt;td&gt;IPv4/IPv6, ASN number and organization, network range&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;HTTP&lt;/td&gt;&lt;td&gt;Server and X-Powered-By headers from WebSocket upgrade&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Results are published to the Nostr network as &lt;strong&gt;Kind 30166&lt;/strong&gt; relay discovery events (with NIP-32 labels for ASN/country/timezone, relay type tags, requirement tags for auth/payment/PoW, geohash &lt;code dir=&quot;auto&quot;&gt;g&lt;/code&gt; tags for spatial indexing), &lt;strong&gt;Kind 10166&lt;/strong&gt; monitor announcements, and &lt;strong&gt;Kind 0&lt;/strong&gt; operator profiles. Each publishing type has independent relay lists and intervals.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;pensieve-coverage-optimized-relay-rotation&quot;&gt;Pensieve: Coverage-Optimized Relay Rotation&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Pensieve doesn’t profile relays for external consumption — it manages them to &lt;strong&gt;maximize unique event capture&lt;/strong&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;novel_normalized = min(novel_rate_7d / network_median_novel_rate, 2.0)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;score = (novel_normalized * 0.7) + (uptime_7d * 0.3)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every 5 minutes, the optimizer can swap up to 5% of connected relays, with 3 exploration slots reserved for untested relays. Seed relays get a minimum score floor and are never evicted. The SQLite-backed &lt;code dir=&quot;auto&quot;&gt;RelayManager&lt;/code&gt; tracks hourly and daily statistics with automatic rollup.&lt;/p&gt;
&lt;p&gt;A thorough &lt;code dir=&quot;auto&quot;&gt;ConnectionGuard&lt;/code&gt; provides security: SSRF protection (private IPs, CGNAT, documentation, and reserved ranges), port filtering (only standard web ports), per-IP deduplication (max 2 connections per IP), connection rate limiting, and URL blocklisting (including Umbrel detection for misconfigured home servers).&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;analytics-and-data-access&quot;&gt;Analytics and Data Access&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;bigbrotr-sql--rest-api--nostr-dvm&quot;&gt;BigBrotr: SQL + REST API + Nostr DVM&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Three access paths to the same data:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Direct SQL&lt;/strong&gt; — connect to PostgreSQL from any compatible client. Full JOINs, CTEs, window functions. The &lt;code dir=&quot;auto&quot;&gt;reader&lt;/code&gt; role has SELECT-only access.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;REST API&lt;/strong&gt; (FastAPI) — schema-aware route generation from database introspection. Every table and materialized view gets automatic endpoints with filtering, sorting, and pagination. 16 resources exposed.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;NIP-90 DVM&lt;/strong&gt; — native Nostr access. Clients query data by publishing Kind 5050 job requests; BigBrotr responds with Kind 6050 results. Per-table pricing in millisats. NIP-89 handler announcements for discoverability.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;11 materialized views cover: global event statistics with rolling windows (1h/24h/7d/30d), per-relay stats (event counts, unique pubkeys, average RTT), kind and pubkey distributions (global and per-relay), network-level aggregates, relay software and NIP support distributions, daily time-series counts, and the latest metadata snapshot per relay.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;pensieve-clickhouse--rest-api--preview-pages&quot;&gt;Pensieve: ClickHouse + REST API + Preview Pages&lt;/h3&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;ClickHouse SQL&lt;/strong&gt; — direct columnar queries. Excellent for aggregations over billions of rows.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;REST API&lt;/strong&gt; (Axum) — 30+ bearer-token authenticated endpoints: event/pubkey/kind totals, throughput (7-day rolling average), hourly activity patterns, per-kind breakdowns with content length and time windows, new users per period, &lt;strong&gt;retention cohort analysis&lt;/strong&gt; (weekly and monthly), DAU/WAU/MAU segmented by user quality (has profile, has follow list, has both — excluding throwaway keys), zap statistics with amount histograms, long-form content analytics, top publishers, relay distribution from NIP-65 lists. ETag-based caching.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Preview pages&lt;/strong&gt; — HTML pages for notes, profiles, articles, videos, reposts with inline quote cards. &lt;strong&gt;Dynamic OG image generation&lt;/strong&gt; (SVG-to-PNG with author avatar compositing). JSON API (append &lt;code dir=&quot;auto&quot;&gt;.json&lt;/code&gt; to any preview URL) with full event data, author profile, mentioned profiles, and engagement counts. &lt;code dir=&quot;auto&quot;&gt;llms.txt&lt;/code&gt; convention for AI agent discoverability.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;ClickHouse views include: flattened tag analytics (via ARRAY JOIN), incremental engagement counters (SummingMergeTree), NIP-09 deletion tracking, video content analytics (trending, hashtags, top creators), first-seen tracking for new user analysis, and scheduled cohort retention refreshes.&lt;/p&gt;
&lt;p&gt;The retention cohort analysis is the data source behind recent Nostr network reports — it answers “of users who first appeared in week X, what percentage returned in weeks X+1, X+2, etc.” with user quality segmentation to distinguish real users from throwaway keys.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;network-support&quot;&gt;Network Support&lt;/h2&gt;&lt;/div&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Network&lt;/th&gt;&lt;th&gt;BigBrotr&lt;/th&gt;&lt;th&gt;Pensieve&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Clearnet (wss://)&lt;/td&gt;&lt;td&gt;50 concurrent tasks&lt;/td&gt;&lt;td&gt;Up to 30 relays&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tor (.onion)&lt;/td&gt;&lt;td&gt;SOCKS5, 10 tasks&lt;/td&gt;&lt;td&gt;Supported via &lt;code dir=&quot;auto&quot;&gt;--tor-proxy&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;I2P (.i2p)&lt;/td&gt;&lt;td&gt;SOCKS5, 5 tasks&lt;/td&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lokinet (.loki)&lt;/td&gt;&lt;td&gt;SOCKS5, 5 tasks&lt;/td&gt;&lt;td&gt;Not supported&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;BigBrotr auto-detects network type from relay URLs using full RFC 3986 parsing (TLD for overlays, IP range checks against 27 IANA ranges). Per-network semaphores control concurrency. Clearnet relays with invalid SSL certificates are handled via a 15-pattern SSL error classifier that falls back to a custom insecure WebSocket transport — configurable per-service.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;engineering-and-operations&quot;&gt;Engineering and Operations&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;monitoring&quot;&gt;Monitoring&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr runs a full &lt;strong&gt;Prometheus + Grafana + Alertmanager&lt;/strong&gt; stack with per-service metrics, 7 alert rules, postgres-exporter with custom queries, and auto-provisioned dashboards. Pensieve exposes Prometheus metrics with 5-second rolling rate calculations and three Grafana dashboards (ingestion, backfill, user analytics).&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;deployment&quot;&gt;Deployment&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr runs entirely in Docker Compose (15 containers). Pensieve uses a hybrid model: Docker for infrastructure (ClickHouse, Grafana, Prometheus, Caddy), native Rust binaries with systemd for the ingestion and serving layers.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;testing-and-cicd&quot;&gt;Testing and CI/CD&lt;/h3&gt;&lt;/div&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;BigBrotr&lt;/th&gt;&lt;th&gt;Pensieve&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Tests&lt;/td&gt;&lt;td&gt;~2,955 (2,739 unit + 216 integration)&lt;/td&gt;&lt;td&gt;Minimal&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Coverage&lt;/td&gt;&lt;td&gt;80% branch minimum (enforced)&lt;/td&gt;&lt;td&gt;None&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;CI matrix&lt;/td&gt;&lt;td&gt;Python 3.11–3.14&lt;/td&gt;&lt;td&gt;Single Rust version&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Pre-commit&lt;/td&gt;&lt;td&gt;23 hooks (ruff, mypy, detect-secrets, hadolint, sqlfluff, codespell, …)&lt;/td&gt;&lt;td&gt;Standard Rust toolchain&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Security&lt;/td&gt;&lt;td&gt;Trivy scanning, CodeQL analysis, SBOM generation, dependency auditing&lt;/td&gt;&lt;td&gt;N/A&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Distribution&lt;/td&gt;&lt;td&gt;PyPI + multi-arch Docker (amd64 + arm64) to GHCR&lt;/td&gt;&lt;td&gt;Native binary&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;nip-support&quot;&gt;NIP Support&lt;/h3&gt;&lt;/div&gt;




























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;NIP&lt;/th&gt;&lt;th&gt;BigBrotr&lt;/th&gt;&lt;th&gt;Pensieve&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;NIP-01&lt;/td&gt;&lt;td&gt;Event model, validation, protocol&lt;/td&gt;&lt;td&gt;Event validation, signature verification&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-09&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;Deletion tracking (materialized view)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-11&lt;/td&gt;&lt;td&gt;Full info document fetch (20+ fields)&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-32&lt;/td&gt;&lt;td&gt;Labels in published events&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-42&lt;/td&gt;&lt;td&gt;Recognized during validation&lt;/td&gt;&lt;td&gt;Automatic auth with ephemeral keys&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-65&lt;/td&gt;&lt;td&gt;Relay URLs from event tags (Finder)&lt;/td&gt;&lt;td&gt;Relay discovery via nostr-sdk&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-66&lt;/td&gt;&lt;td&gt;6 health check types + event publishing&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-77&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;Negentropy set reconciliation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-89&lt;/td&gt;&lt;td&gt;DVM handler announcements&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;NIP-90&lt;/td&gt;&lt;td&gt;Data Vending Machine&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;trade-offs-and-limitations&quot;&gt;Trade-offs and Limitations&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every architectural choice has a cost. Here’s where each project pays.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BigBrotr’s costs:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL row store is less efficient than columnar for pure analytics over billions of rows&lt;/li&gt;
&lt;li&gt;No incremental materialized view refresh — full &lt;code dir=&quot;auto&quot;&gt;REFRESH CONCURRENTLY&lt;/code&gt; each cycle&lt;/li&gt;
&lt;li&gt;Single PostgreSQL instance — horizontal scaling requires read replicas or partitioning&lt;/li&gt;
&lt;li&gt;The completeness-first crawling approach means higher latency (events arrive minutes to hours after publication, not real-time) and lower raw throughput than a firehose&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Pensieve’s costs:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Event-relay attribution is lost in the archive (notepack doesn’t store relay source)&lt;/li&gt;
&lt;li&gt;If a segment write fails after the dedupe mark, the event is permanently lost. 8MB BufWriter buffer means up to 8MB lost on unclean shutdown&lt;/li&gt;
&lt;li&gt;The global dedupe mutex serializes all event processing — bottleneck above ~10K events/sec&lt;/li&gt;
&lt;li&gt;Failed ClickHouse indexing is not retried — recovery requires manual re-indexing from archive&lt;/li&gt;
&lt;li&gt;Fire-and-forget compression threads can leave corrupt files on crash&lt;/li&gt;
&lt;li&gt;No archive checksums or format version headers&lt;/li&gt;
&lt;li&gt;Five storage engines means five failure modes and five backup strategies&lt;/li&gt;
&lt;li&gt;Relay scoring cold start: new relays always score 0, takes ~11.5 days to try 10K discovered relays at 3 exploration slots per 5-minute cycle&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;complementary-not-competing&quot;&gt;Complementary, Not Competing&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;These projects answer different questions and would be most valuable running side by side.&lt;/p&gt;
&lt;p&gt;BigBrotr tells you about the &lt;strong&gt;infrastructure&lt;/strong&gt; of Nostr — which relays are healthy, what software they run, how fast they respond, what NIPs they support, which events they carry, how events are distributed across the network. It’s the tool for understanding the relay landscape, detecting outages, tracking network growth, and building relay recommendation systems.&lt;/p&gt;
&lt;p&gt;Pensieve tells you about the &lt;strong&gt;content&lt;/strong&gt; of Nostr — what events exist, who’s active, what kinds are trending, how users retain, how zaps flow. It’s the tool for understanding user behavior, measuring network health from a social perspective, and producing the analytics that appear in network reports.&lt;/p&gt;
&lt;p&gt;A BigBrotr instance could feed validated relay URLs to Pensieve. A Pensieve instance could feed event data back into BigBrotr’s analytics. BigBrotr’s event_relay junction answers distribution questions that Pensieve’s architecture cannot. Pensieve’s ClickHouse-powered retention cohorts answer behavioral questions that BigBrotr’s PostgreSQL views don’t address.&lt;/p&gt;
&lt;p&gt;Different tools. Different questions. Same network. The Nostr ecosystem benefits from having multiple independent observers with different perspectives — just like the protocol itself benefits from having multiple independent relays.&lt;/p&gt;
&lt;p&gt;Build what you need. Run what answers your questions. Or run both.&lt;/p&gt;</content:encoded><category>analysis</category><category>nostr</category><category>infrastructure</category><category>comparison</category></item><item><title>Building a Nostr Relay Monitor with BigBrotr</title><link>https://bigbrotr.com/blog/building-a-nostr-relay-monitor/</link><guid isPermaLink="true">https://bigbrotr.com/blog/building-a-nostr-relay-monitor/</guid><description>There&apos;s a wealth of data you can extract from a Nostr relay — why settle for less? Build a fully NIP-66 compliant monitor in under 80 lines of Python.

</description><pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There’s a surprising amount of information you can extract from a Nostr relay beyond just “is it online.” RTT latency across WebSocket open, read, and write. TLS certificate details and chain validity. DNS records, reverse lookups, nameservers. IP geolocation down to city level. ASN and network operator. The actual server software from HTTP headers. The relay’s self-reported capabilities, limitations, and supported NIPs.&lt;/p&gt;
&lt;p&gt;All of this data is useful — for relay operators debugging their infrastructure, for clients picking the best relay, for researchers mapping the Nostr network. Why leave any of it on the table?&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;everything-or-just-what-you-need&quot;&gt;Everything, or Just What You Need&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr lets you collect all of it. Or just the parts you care about. Both NIP-11 and NIP-66 checks are controlled by selection objects:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Run everything (default)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;nip66_selection &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Nip66Selection&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Or pick exactly what you need&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;nip66_selection &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Nip66Selection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;rtt&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;ssl&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;dns&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;geo&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;net&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;http&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Same for NIP-11 — &lt;code dir=&quot;auto&quot;&gt;Nip11Selection(info=True)&lt;/code&gt; fetches the relay information document, &lt;code dir=&quot;auto&quot;&gt;info=False&lt;/code&gt; skips it. Mix and match however you want. Checks you disable don’t run at all — no wasted time, no wasted bandwidth.&lt;/p&gt;
&lt;p&gt;Once you’ve collected the data, &lt;code dir=&quot;auto&quot;&gt;build_relay_discovery()&lt;/code&gt; packages exactly what you computed into a kind 30166 event fully compliant with NIP-66. No manual tag construction, no guessing at the spec. The announcement event (kind 10166) automatically declares which checks you have enabled via &lt;code dir=&quot;auto&quot;&gt;c&lt;/code&gt; (capability) tags and &lt;code dir=&quot;auto&quot;&gt;timeout&lt;/code&gt; tags per check type, so other clients know what to expect from your monitor.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-code&quot;&gt;The Code&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The full example is in &lt;a href=&quot;https://github.com/BigBrotr/examples/blob/main/monitor_relays.py&quot;&gt;examples/monitor_relays.py&lt;/a&gt; — under 80 lines for a complete, working monitor:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; geoip2.database&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; nostr_sdk &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Client, Keys, NostrSigner, RelayUrl&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.models.constants &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; NetworkType&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.models.relay &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Relay&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.nips.event_builders &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build_monitor_announcement,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build_profile_event,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build_relay_discovery,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.nips.nip11 &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Nip11, Nip11Selection&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.nips.nip66 &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Nip66, Nip66Dependencies, Nip66Selection&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# 1. Prepare dependencies (reused across all relays)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;city_reader &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; geoip2.database.&lt;/span&gt;&lt;span&gt;Reader&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;GeoLite2-City.mmdb&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;asn_reader &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; geoip2.database.&lt;/span&gt;&lt;span&gt;Reader&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;GeoLite2-ASN.mmdb&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;deps &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Nip66Dependencies&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;city_reader&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;city_reader&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;asn_reader&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;asn_reader&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# 2. Connect and announce&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;signer &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; NostrSigner.&lt;/span&gt;&lt;span&gt;keys&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Keys.&lt;/span&gt;&lt;span&gt;generate&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;client &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Client&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;signer&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;add_relay&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;RelayUrl.&lt;/span&gt;&lt;span&gt;parse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;wss://nos.lol&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;connect&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;send_event_builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;build_profile_event&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;My Monitor&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;send_event_builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;build_monitor_announcement&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;interval&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;3600&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;timeout_ms&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;10000&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;enabled_networks&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;NetworkType.CLEARNET&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;nip11_selection&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Nip11Selection&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;nip66_selection&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Nip66Selection&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# 3. Monitor and publish&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; url &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;wss://relay.damus.io&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;wss://nos.lol&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;wss://relay.nostr.band&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;relay &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Relay&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nip11 &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; Nip11.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;nip66 &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; Nip66.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;deps&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;deps&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;send_event_builder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;build_relay_discovery&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; nip11&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; nip66&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Three calls per relay. &lt;code dir=&quot;auto&quot;&gt;Nip11.create()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;Nip66.create()&lt;/code&gt; are async factory methods that &lt;strong&gt;never raise&lt;/strong&gt; — all errors are captured in their respective &lt;code dir=&quot;auto&quot;&gt;logs&lt;/code&gt; fields, so you can iterate over hundreds of relays without a single try/except. &lt;code dir=&quot;auto&quot;&gt;build_relay_discovery()&lt;/code&gt; takes whatever data was successfully collected and serializes it into a fully tagged NIP-66 event.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Nip66Dependencies&lt;/code&gt; bundles the GeoIP readers and a throwaway keypair used for the RTT write test, so they can be reused across all relays without repeated initialization.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;nip-11-relay-information&quot;&gt;NIP-11: Relay Information&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Nip11.create()&lt;/code&gt; fetches the relay’s information document over HTTP. It converts the WebSocket URL to its HTTP equivalent (&lt;code dir=&quot;auto&quot;&gt;wss://&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;https://&lt;/code&gt;) and sends a request with &lt;code dir=&quot;auto&quot;&gt;Accept: application/nostr+json&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The response is validated for correct Content-Type (&lt;code dir=&quot;auto&quot;&gt;application/nostr+json&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;application/json&lt;/code&gt;), maximum size (64 KB), and JSON structure (must be a dict, not a list or scalar). The resulting &lt;code dir=&quot;auto&quot;&gt;Nip11InfoData&lt;/code&gt; model captures the relay’s name, description, pubkey, contact, supported NIPs, software, version, and limitations.&lt;/p&gt;
&lt;p&gt;For relays with certificate issues, the &lt;code dir=&quot;auto&quot;&gt;allow_insecure&lt;/code&gt; option enables SSL fallback. Overlay networks (Tor, I2P, Lokinet) automatically use a non-validating SSL context since the proxy layer provides encryption.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;nip-66-six-concurrent-health-checks&quot;&gt;NIP-66: Six Concurrent Health Checks&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Nip66.create()&lt;/code&gt; runs up to six checks &lt;strong&gt;in parallel&lt;/strong&gt; via &lt;code dir=&quot;auto&quot;&gt;asyncio.gather()&lt;/code&gt;. Each check is independent — a failure in one doesn’t affect the others. Checks for which the required dependency is missing (e.g. no &lt;code dir=&quot;auto&quot;&gt;city_reader&lt;/code&gt; for geo) are silently skipped.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;rtt-round-trip-time&quot;&gt;RTT (Round-Trip Time)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Three sequential phases measuring actual WebSocket performance — not just a ping, but real protocol-level operations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Open&lt;/strong&gt; — measures connection establishment time via &lt;code dir=&quot;auto&quot;&gt;connect_relay()&lt;/code&gt;. If this fails, read and write are automatically marked as failed with the same reason (cascading failure).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read&lt;/strong&gt; — streams events with a &lt;code dir=&quot;auto&quot;&gt;limit(1)&lt;/code&gt; filter and measures time to first event. Times out if no events arrive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write&lt;/strong&gt; — publishes a kind 22456 ephemeral test event and &lt;strong&gt;verifies&lt;/strong&gt; it was stored by immediately re-querying. Reports the total time including verification. Distinguishes between rejection (relay refused), unverified (accepted but not retrievable), and success.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Output: &lt;code dir=&quot;auto&quot;&gt;rtt_open&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;rtt_read&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;rtt_write&lt;/code&gt; in milliseconds.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;ssl-certificate-inspection&quot;&gt;SSL (Certificate Inspection)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A two-connection strategy:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Extraction&lt;/strong&gt; — connects with &lt;code dir=&quot;auto&quot;&gt;ssl.CERT_NONE&lt;/code&gt; to read the certificate regardless of chain validity. Parses with &lt;code dir=&quot;auto&quot;&gt;cryptography.x509&lt;/code&gt; to extract subject CN, issuer, validity dates, SANs, serial number, fingerprint (&lt;code dir=&quot;auto&quot;&gt;SHA256:AA:BB:CC:...&lt;/code&gt;), TLS protocol version, and cipher details.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validation&lt;/strong&gt; — a separate connection with the system default SSL context to validate the certificate chain against the trust store.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both connections run in a thread pool to avoid blocking the event loop. Clearnet only — overlay networks get an immediate skip.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;dns-resolution&quot;&gt;DNS (Resolution)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Comprehensive lookups via &lt;code dir=&quot;auto&quot;&gt;dnspython&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A records&lt;/strong&gt; (IPv4 addresses + TTL)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AAAA records&lt;/strong&gt; (IPv6 addresses)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CNAME&lt;/strong&gt; (canonical name)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NS records&lt;/strong&gt; — resolved against the &lt;strong&gt;registered domain&lt;/strong&gt; (uses &lt;code dir=&quot;auto&quot;&gt;tldextract&lt;/code&gt; to identify the public suffix, so &lt;code dir=&quot;auto&quot;&gt;relay.damus.io&lt;/code&gt; queries NS for &lt;code dir=&quot;auto&quot;&gt;damus.io&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PTR&lt;/strong&gt; (reverse DNS from the first IPv4 address)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each record type is queried independently — a failure in one doesn’t prevent the others. Synchronous DNS operations run in a thread pool.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;geo-geolocation&quot;&gt;Geo (Geolocation)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Resolves the relay’s hostname to an IP (preferring IPv4, falling back to IPv6), then looks it up in the GeoLite2 City database. Extracts country (ISO 3166-1 alpha-2, preferring physical over registered country), continent, city, region, postal code, coordinates, accuracy radius, and timezone. Computes a &lt;a href=&quot;https://en.wikipedia.org/wiki/Geohash&quot;&gt;geohash&lt;/a&gt; at precision 9 (~5 m accuracy) from the coordinates.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;net-networkasn&quot;&gt;Net (Network/ASN)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Resolves both IPv4 and IPv6 addresses, then looks up the ASN in the GeoLite2 ASN database. IPv4 data takes priority — IPv6 ASN/org is used only as fallback when no IPv4 is available. Records the autonomous system number, organization name, and network CIDRs for both address families.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;http-header-capture&quot;&gt;HTTP (Header Capture)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Initiates a WebSocket connection and intercepts the HTTP upgrade response using &lt;code dir=&quot;auto&quot;&gt;aiohttp&lt;/code&gt;’s &lt;code dir=&quot;auto&quot;&gt;TraceConfig&lt;/code&gt; hooks. Captures the &lt;code dir=&quot;auto&quot;&gt;Server&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;X-Powered-By&lt;/code&gt; headers, then immediately closes. This reveals the relay’s actual software stack without relying on self-reported NIP-11 data.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;what-gets-published&quot;&gt;What Gets Published&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;kind-10166--monitor-announcement&quot;&gt;Kind 10166 — Monitor Announcement&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Declares what your monitor does: check interval, timeout, enabled networks, and &lt;code dir=&quot;auto&quot;&gt;c&lt;/code&gt; (capability) tags for each active check — &lt;code dir=&quot;auto&quot;&gt;open&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;read&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;write&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;nip11&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ssl&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;dns&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;geo&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;net&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;http&lt;/code&gt;. Clients reading your monitor events know exactly what data to expect.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;kind-30166--relay-discovery&quot;&gt;Kind 30166 — Relay Discovery&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;One event per relay. The content is the NIP-11 information document as canonical JSON. The tags carry all NIP-66 health check results:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RTT&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;rtt-open&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;rtt-read&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;rtt-write&lt;/code&gt; (milliseconds)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SSL&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;ssl&lt;/code&gt; (valid/invalid), &lt;code dir=&quot;auto&quot;&gt;ssl-expires&lt;/code&gt; (Unix timestamp), &lt;code dir=&quot;auto&quot;&gt;ssl-issuer&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNS&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;dns-ip&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;dns-ip6&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;dns-cname&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;dns-ttl&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Geo&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;g&lt;/code&gt; (geohash), &lt;code dir=&quot;auto&quot;&gt;geo-country&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;geo-city&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;geo-lat&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;geo-lon&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;geo-tz&lt;/code&gt;, plus NIP-32 &lt;code dir=&quot;auto&quot;&gt;l&lt;/code&gt; labels for country and continent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Net&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;net-ip&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;net-ipv6&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;net-asn&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;net-asn-org&lt;/code&gt;, plus NIP-32 &lt;code dir=&quot;auto&quot;&gt;l&lt;/code&gt; labels&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTP&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;http-server&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;http-powered-by&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NIP-11 derived&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;N&lt;/code&gt; tags for supported NIPs, &lt;code dir=&quot;auto&quot;&gt;R&lt;/code&gt; tags for requirements (auth, payment, pow), &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt; tags for relay type classification (Search, Community, Paid, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All tag names and semantics follow the NIP-66 specification — no custom extensions, full interoperability with any NIP-66 aware client.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The geo and net checks require &lt;a href=&quot;https://github.com/P3TERX/GeoLite.mmdb&quot;&gt;GeoLite2 databases&lt;/a&gt;. Download &lt;code dir=&quot;auto&quot;&gt;GeoLite2-City.mmdb&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;GeoLite2-ASN.mmdb&lt;/code&gt; into the examples directory. If they’re missing, those checks are silently skipped — the rest still run.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;running-it&quot;&gt;Running It&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;examples&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;python&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-m&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;venv&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;.venv&lt;/span&gt;&lt;span&gt; &amp;#x26;&amp;#x26; &lt;/span&gt;&lt;span&gt;source&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;.venv/bin/activate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pip&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;requirements.txt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;python&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;monitor_relays.py&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The script prints the full NIP-11 and NIP-66 results as JSON for each relay, and publishes kind 30166 events to the target relay. Here’s what a complete NIP-66 output looks like for &lt;code dir=&quot;auto&quot;&gt;wss://nos.lol&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;rtt&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;data&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;rtt_open&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;197&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;rtt_read&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;64&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;rtt_write&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;82&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;logs&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;open_success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;open_reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;read_success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;read_reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;write_success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;write_reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;ssl&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;data&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_valid&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_subject_cn&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;nos.lol&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_issuer&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Let&apos;s Encrypt&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_issuer_cn&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;R13&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_expires&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1777079086&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_not_before&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1769303087&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_san&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;nos.lol&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_serial&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;6CCA2CA89CFC918B9759546D124552CE936&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_version&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_fingerprint&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;SHA256:FB:0B:35:B5:03:B3:54:36:3C:DC:56:8F:E3:38:E5:61:A6:0E:BA:49:4E:A2:51:5B:E0:C8:50:97:B2:71:A9:A8&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_protocol&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;TLSv1.3&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_cipher&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;TLS_AES_256_GCM_SHA384&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;ssl_cipher_bits&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;256&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;logs&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;geo&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;data&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_country&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;DE&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_country_name&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Germany&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_continent&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;EU&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_continent_name&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Europe&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_is_eu&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_region&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Saxony&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_city&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Falkenstein&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_postal&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;08223&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_lat&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;50.4777&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_lon&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;12.3649&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_accuracy&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;20&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_tz&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Europe/Berlin&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_hash&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;u2bz1m5vg&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;geo_geoname_id&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;2927913&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;logs&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;net&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;data&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;net_ip&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;5.9.78.12&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;net_ipv6&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;::ffff:5.9.78.12&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;net_asn&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;24940&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;net_asn_org&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Hetzner Online GmbH&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;net_network&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;5.9.0.0/16&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;net_network_v6&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;::ffff:5.9.0.0/112&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;logs&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;dns&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;data&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;dns_ips&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;5.9.78.12&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;dns_ips_v6&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;dns_cname&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;dns_reverse&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;f56.nos.lol&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;dns_ns&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;kiki.bunny.net&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;coco.bunny.net&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;dns_ttl&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1105&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;logs&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;http&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;data&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;http_server&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;nginx/1.18.0 (Ubuntu)&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;http_powered_by&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;logs&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;success&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;reason&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every check carries both &lt;code dir=&quot;auto&quot;&gt;data&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;logs&lt;/code&gt; — when a check fails, &lt;code dir=&quot;auto&quot;&gt;logs.reason&lt;/code&gt; tells you exactly why while the rest of the checks still complete normally.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;going-further&quot;&gt;Going Further&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This example uses randomly generated keys. For a production monitor, use a persistent keypair so clients can verify and trust your results over time. BigBrotr’s Monitor service builds on the same primitives — adding scheduled execution, database persistence, configurable check selections, and overlay network support via SOCKS5 proxies.&lt;/p&gt;</content:encoded><category>tutorial</category><category>nostr</category><category>monitoring</category><category>nip-66</category></item><item><title>Streaming Events from Nostr Relays</title><link>https://bigbrotr.com/blog/streaming-events-from-nostr-relays/</link><guid isPermaLink="true">https://bigbrotr.com/blog/streaming-events-from-nostr-relays/</guid><description>Dumping every event from a Nostr relay should be simple — pick your filters, set a time window, iterate. With BigBrotr&apos;s stream_events, it is.

</description><pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Getting &lt;em&gt;all&lt;/em&gt; events from a Nostr relay for a given time window sounds like it should be simple. Pick a &lt;code dir=&quot;auto&quot;&gt;since&lt;/code&gt;, pick an &lt;code dir=&quot;auto&quot;&gt;until&lt;/code&gt;, send a &lt;code dir=&quot;auto&quot;&gt;REQ&lt;/code&gt;, done. In practice, it’s anything but.&lt;/p&gt;
&lt;p&gt;Relays cap how many events they return per request. Some enforce a hard &lt;code dir=&quot;auto&quot;&gt;LIMIT&lt;/code&gt; lower than what you ask for — silently. You get back 100 events with no indication of whether that’s everything or just a truncated slice. Multiple events can share the same &lt;code dir=&quot;auto&quot;&gt;created_at&lt;/code&gt; timestamp, so paginating by “last timestamp seen” risks skipping or duplicating events. And if you want results in chronological order, good luck — relays don’t guarantee any sort.&lt;/p&gt;
&lt;p&gt;Building a correct, complete, ordered dump from a relay means writing retry logic, deduplication, signature checks, boundary probing, and adaptive windowing. It’s a lot of code for something that should be straightforward.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;stream_events-zero-stress&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;stream_events&lt;/code&gt;: Zero Stress&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr’s &lt;code dir=&quot;auto&quot;&gt;stream_events&lt;/code&gt; reduces all of that to an async &lt;code dir=&quot;auto&quot;&gt;for&lt;/code&gt; loop:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; asyncio&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; time&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; nostr_sdk &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Client, Filter, RelayUrl&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.utils.streaming &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; stream_events&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;RELAY&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;wss://relay.damus.io&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;FILTERS&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Filter&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SINCE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;UNTIL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;time.&lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;LIMIT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;500&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;TIMEOUT_SECS&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;10.0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;main&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; -&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;client &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Client&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;add_relay&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;RelayUrl.&lt;/span&gt;&lt;span&gt;parse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;RELAY&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;connect&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; event &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;stream_events&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;client&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;client&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;filters&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;FILTERS&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;start_time&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;SINCE&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;end_time&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;UNTIL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;limit&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;LIMIT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;request_timeout&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;TIMEOUT_SECS&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;print&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;{event.&lt;/span&gt;&lt;span&gt;created_at&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;as_secs&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;:&gt;12&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;  &quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;{event.&lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;as_u16&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;:&gt;5&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;  &quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;{event.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;to_hex&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;  &quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;{event.&lt;/span&gt;&lt;span&gt;author&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;to_hex&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; client.&lt;/span&gt;&lt;span&gt;disconnect&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;asyncio.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;main&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. Every event matching your filters, for any time window, from any relay, yielded in ascending &lt;code dir=&quot;auto&quot;&gt;(created_at, event_id)&lt;/code&gt; order. No missing events, no duplicates, no manual pagination. The full example is in &lt;a href=&quot;https://github.com/BigBrotr/examples/blob/main/stream_events.py&quot;&gt;examples/stream_events.py&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You choose the filters — all events, specific kinds, specific authors, tag queries, whatever Nostr filters support. You choose the time window — the last hour, the last year, or the entire relay history since epoch. &lt;code dir=&quot;auto&quot;&gt;stream_events&lt;/code&gt; handles the rest.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;resumable-by-design&quot;&gt;Resumable by Design&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If the stream gets interrupted — network error, timeout, you hit Ctrl+C — just restart from the &lt;code dir=&quot;auto&quot;&gt;created_at&lt;/code&gt; of the last event you received. Since events are yielded in strict chronological order, there’s no ambiguity about where you left off. No checkpoint files, no cursor management, no state to persist. The function is stateless — your resume point is just the last timestamp you saw.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;parameters&quot;&gt;Parameters&lt;/h2&gt;&lt;/div&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Parameter&lt;/th&gt;&lt;th&gt;Description&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;RELAY&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The relay URL to connect to&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;FILTERS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Nostr filters — &lt;code dir=&quot;auto&quot;&gt;[Filter()]&lt;/code&gt; matches all events&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;SINCE&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;UNTIL&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Inclusive time window boundaries (Unix timestamps)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;LIMIT&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Page size per &lt;code dir=&quot;auto&quot;&gt;REQ&lt;/code&gt; — controls the split threshold, not the total output&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;TIMEOUT_SECS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;How long to wait for each relay response&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;LIMIT&lt;/code&gt; controls the page size per individual &lt;code dir=&quot;auto&quot;&gt;REQ&lt;/code&gt; to the relay, not the total number of events returned. The function will issue as many requests as needed to cover the entire window — you don’t set a cap on the output.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;how-it-works-under-the-hood&quot;&gt;How It Works Under the Hood&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The simplicity on the surface hides a careful algorithm underneath.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;data-driven-windowing-with-binary-split-fallback&quot;&gt;Data-Driven Windowing with Binary-Split Fallback&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The function maintains a stack of upper bounds and a moving lower bound, processing windows left-to-right in a depth-first traversal. For each window &lt;code dir=&quot;auto&quot;&gt;[current_since, current_until]&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fetch&lt;/strong&gt; — requests events with a three-layer validation pipeline: filter matching, cryptographic signature verification, and deduplication by event ID. Events are consumed via streaming (not bulk fetch) to prevent relay flooding.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Verify completeness&lt;/strong&gt; — if the fetch hits the limit, the function doesn’t immediately split. Instead, it runs a data-driven verification:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fetches all events at the minimum &lt;code dir=&quot;auto&quot;&gt;created_at&lt;/code&gt; timestamp from the original batch&lt;/li&gt;
&lt;li&gt;Checks that the boundary fetch is consistent (max timestamp equals the expected boundary)&lt;/li&gt;
&lt;li&gt;Probes for events &lt;em&gt;before&lt;/em&gt; that minimum timestamp — if any exist, the window was incomplete&lt;/li&gt;
&lt;li&gt;If all checks pass, the combined result is complete — no split needed&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Binary split&lt;/strong&gt; — only when verification fails (inconsistent boundary, or earlier events detected), the window is split at &lt;code dir=&quot;auto&quot;&gt;mid = current_since + (current_until - current_since) // 2&lt;/code&gt;. The left half is pushed to the front of the stack for depth-first processing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bottom out&lt;/strong&gt; — when &lt;code dir=&quot;auto&quot;&gt;current_since == current_until&lt;/code&gt; (a single-second window), no further splitting is possible. All events in that second are yielded regardless of count.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This means most windows resolve without splitting — the binary split only kicks in when the relay genuinely has more events than the page size allows. The algorithm converges in O(log N) splits in the worst case, with each split halving the time window.&lt;/p&gt;
&lt;p&gt;Events are always yielded in ascending &lt;code dir=&quot;auto&quot;&gt;(created_at, event_id)&lt;/code&gt; order, regardless of how many splits were needed.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;validation-pipeline&quot;&gt;Validation Pipeline&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Every event passes through three layers before being yielded:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Filter matching&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;Filter.match_event()&lt;/code&gt; verifies the event matches the requested kinds, authors, tags, and time range. This catches relays that return out-of-scope events.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signature verification&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;Event.verify()&lt;/code&gt; checks the cryptographic signature. Invalid signatures are silently skipped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deduplication&lt;/strong&gt; — events are tracked by ID across all filters, so the same event seen through different filter paths is only yielded once.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Events that fail domain conversion (null bytes in content/tags, timestamp overflow) are silently dropped with a debug log — the stream continues without interruption.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;running-it&quot;&gt;Running It&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;examples&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;python&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-m&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;venv&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;.venv&lt;/span&gt;&lt;span&gt; &amp;#x26;&amp;#x26; &lt;/span&gt;&lt;span&gt;source&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;.venv/bin/activate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pip&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;requirements.txt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;python&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;stream_events.py&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;created_at   kind  id                                                                pubkey&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-----------------------------------------------------------------------------------------------&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;1672531200       1  a1b2c3d4e5f6...                                                  e5f6a7b8c9d0...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;1672531201       0  c9d0e1f2a3b4...                                                  a3b4c5d6e7f8...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;use-cases&quot;&gt;Use Cases&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Complete event dumps open up a lot of possibilities: building search indexes, computing analytics, migrating data between relays, auditing what a relay has seen, or simply archiving everything for offline analysis. BigBrotr’s Synchronizer service uses the same &lt;code dir=&quot;auto&quot;&gt;stream_events&lt;/code&gt; function internally for its cursor-based archiving pipeline — if it’s reliable enough for production ingestion at scale, it’ll work for your scripts too.&lt;/p&gt;</content:encoded><category>tutorial</category><category>nostr</category><category>streaming</category></item><item><title>Inside BigBrotr: Building a Distributed Relay Observatory for the Nostr Network</title><link>https://bigbrotr.com/blog/inside-bigbrotr/</link><guid isPermaLink="true">https://bigbrotr.com/blog/inside-bigbrotr/</guid><description>A technical deep dive into BigBrotr&apos;s architecture — eight services, ~18,000 lines of Python, 25 stored procedures, 11 materialized views, and the design decisions behind a Nostr relay observatory.

</description><pubDate>Sat, 28 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you’ve spent any time on Nostr, you’ve probably wondered: how many relays are actually out there? Which ones are healthy? What kind of events are flowing through them? There’s no central registry, no dashboard, no authority you can ask. Relays come and go, some are solid, some are broken, some lie about their capabilities.&lt;/p&gt;
&lt;p&gt;BigBrotr exists to answer those questions. It’s a distributed system that discovers relays across clearnet, Tor, I2P, and Lokinet, monitors their health, archives the events they publish, and makes all of that data accessible through both a REST API and a native Nostr DVM interface.&lt;/p&gt;
&lt;p&gt;This post walks through the architecture — not as a sales pitch, but as a technical deep dive into how the system is put together, what problems we ran into, and why certain decisions were made. If you like distributed systems, async Python, or PostgreSQL internals, there’s probably something here for you.&lt;/p&gt;
&lt;p&gt;The project started in August 2025, funded by an &lt;a href=&quot;https://opensats.org/&quot;&gt;OpenSats&lt;/a&gt; grant. It’s about ~18,000 lines of Python across 88 modules, ~2,955 tests (~2,739 unit + ~216 integration), 8 independent services, and 13 Docker containers. One person, start to finish.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nostr is decentralized. Thousands of relays are scattered across the globe on four different networks: clearnet (public internet), Tor (.onion), I2P (.i2p), and Lokinet (.loki). Nobody knows for certain how many relays exist, how healthy they are, or what events are being published through them.&lt;/p&gt;
&lt;p&gt;BigBrotr tackles this through five pillars:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Relay Discovery&lt;/strong&gt; — Finding relays automatically from external APIs, seed files, and mining URLs from event tags.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relay Health Monitoring&lt;/strong&gt; — Running 7 health checks per relay: NIP-11 info fetch, RTT latency (WebSocket open/read/write), SSL certificate inspection, DNS records, IP geolocation, network/ASN info, and HTTP headers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event Archiving&lt;/strong&gt; — Connecting to relays via WebSocket and archiving events with cursor-based pagination, tracking progress per relay.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Analytics&lt;/strong&gt; — 11 PostgreSQL materialized views pre-computing aggregate statistics (event distribution by kind, per-relay stats, author counts, NIP adoption, software distribution, daily time series). These views are the foundation for present and future analysis of the Nostr network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Access&lt;/strong&gt; — Two parallel interfaces exposing the same data: a REST API (FastAPI) for traditional HTTP clients, and a DVM (NIP-90 Data Vending Machine) for native Nostr clients over WebSocket.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All of this with full observability: Prometheus metrics for every service, pre-provisioned Grafana dashboards, AlertManager for alerting, postgres-exporter for DB metrics, and structured key=value logging with JSON output.&lt;/p&gt;
&lt;p&gt;The system has to deal with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Thousands of relays on 4 different networks with different proxies (SOCKS5 for Tor)&lt;/li&gt;
&lt;li&gt;Relays with unpredictable behavior (malformed JSON, timeouts, expired certificates, unreliable self-reports)&lt;/li&gt;
&lt;li&gt;Efficient deduplication of identical metadata across different relays (content-addressed SHA-256)&lt;/li&gt;
&lt;li&gt;Bulk PostgreSQL operations (tens of thousands of inserts per cycle)&lt;/li&gt;
&lt;li&gt;Publishing results back to the Nostr protocol itself (the monitor publishes NIP-66 relay discovery events readable by other Nostr clients)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;high-level-architecture--8-services-zero-coupling&quot;&gt;High-Level Architecture — 8 Services, Zero Coupling&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;the-core-principle&quot;&gt;The Core Principle&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;All services share PostgreSQL as their &lt;strong&gt;sole communication channel&lt;/strong&gt;. No service calls another directly — no RPC, no message queue, no shared memory. The database acts as an implicit event log.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;┌───────────────────────────────────────────────┐&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│                PostgreSQL 16                  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│      6 tables · 11 views · 25 procedures      │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;└──┬───────────┬──────────┬───────────┬─────────┘&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                     &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│           │          │           │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;┌────────────┘           │          │           └──────────┐&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│                        │          │                      │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;┌───────┴────────┐  ┌────────────┴───┐  ┌───┴───────────┐  ┌───────┴──────────┐&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ relay          │  │ relay          │  │ event         │  │ analysis &amp;#x26;       │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ discovery      │  │ monitoring     │  │ archive       │  │ access           │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│                │  │                │  │               │  │                  │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ Seeder  (boot) │  │ Monitor        │  │ Synchronizer  │  │ Refresher (views)│&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ Finder  (disc) │  │  7 health      │  │  cursor-based │  │ Api       (http) │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ Validator (ws) │  │  checks/relay  │  │  pagination   │  │ Dvm      (nostr) │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;└────────────────┘  └────────────────┘  └───────────────┘  └──────────────────┘&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Five pillars, eight services.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-data-pipeline&quot;&gt;The Data Pipeline&lt;/h3&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Seeder&lt;/strong&gt; (one-shot) — Bootstrap: loads relay URLs from a seed file as “candidates.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finder&lt;/strong&gt; (recurring) — Discovers new relays from external APIs + mines URLs from tags in already-archived events. Inserts candidates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Validator&lt;/strong&gt; (recurring) — Picks up candidates, tests them with a WebSocket handshake. If a relay speaks proper Nostr protocol, it gets promoted to the &lt;code dir=&quot;auto&quot;&gt;relay&lt;/code&gt; table. If it fails, the error counter increments. After N consecutive failures, the candidate gets dropped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor&lt;/strong&gt; (recurring) — For every validated relay, runs 7 health checks in parallel (NIP-11 info + 6 NIP-66 tests). Produces content-addressed metadata with SHA-256. Publishes relay discovery events (Kind 30166) back onto the Nostr network itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Synchronizer&lt;/strong&gt; (recurring) — Connects to relays via WebSocket and archives events with cursor-based pagination. Tracks progress per relay.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refresher&lt;/strong&gt; (recurring) — Refreshes 11 materialized views in dependency order. Pre-computes aggregate statistics that power the API responses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Api&lt;/strong&gt; (continuous) — REST API with automatic schema discovery. Exposes all tables and materialized views with filtering, sorting, pagination. A parameterized query builder that prevents SQL injection by construction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dvm&lt;/strong&gt; (continuous) — NIP-90 Data Vending Machine: responds to Nostr requests over WebSocket, exposing the same data as the API on the native Nostr protocol. Any Nostr client can query BigBrotr without HTTP.&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;&lt;h3 id=&quot;why-the-database-as-communication-channel&quot;&gt;Why the Database as Communication Channel&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The good stuff:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Total decoupling: each service scales independently&lt;/li&gt;
&lt;li&gt;Fault isolation: if Monitor goes down, Finder/Validator/Synchronizer keep running&lt;/li&gt;
&lt;li&gt;Observability: all interactions are visible in the DB&lt;/li&gt;
&lt;li&gt;Idempotency: upsert, insert-or-skip, atomic cascades&lt;/li&gt;
&lt;li&gt;No additional infrastructure to maintain (no Redis, no RabbitMQ, no Kafka)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The conscious trade-off:&lt;/strong&gt; higher inter-service latency compared to direct RPC. But that’s fine — this isn’t a real-time system. Services run on 1-5 minute cycles. The simplicity is worth it.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;package-architecture--the-diamond-dag&quot;&gt;Package Architecture — The Diamond DAG&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Imports flow strictly downward, enforced by ruff’s linter rules:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;              &lt;/span&gt;&lt;/span&gt;&lt;span&gt;services         ← Business logic (8 services)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;             &lt;/span&gt;&lt;/span&gt;&lt;span&gt;/   |   \&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;core  nips  utils    ← Infrastructure, protocol, network primitives&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;             &lt;/span&gt;&lt;/span&gt;&lt;span&gt;\   |   /&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;              &lt;/span&gt;&lt;/span&gt;&lt;span&gt;models           ← Pure domain (zero I/O, frozen dataclasses)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This is a diamond DAG — &lt;code dir=&quot;auto&quot;&gt;services&lt;/code&gt; at the top, &lt;code dir=&quot;auto&quot;&gt;models&lt;/code&gt; at the bottom, three middle layers that can import from &lt;code dir=&quot;auto&quot;&gt;models&lt;/code&gt; and be imported by &lt;code dir=&quot;auto&quot;&gt;services&lt;/code&gt;, but never from each other. The DAG guarantees no circular imports and lets you use any subset of the package without pulling in unnecessary dependencies.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;models--pure-domain-immutable-fail-fast&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;models/&lt;/code&gt; — Pure Domain, Immutable, Fail-Fast&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The core principle here: &lt;strong&gt;no invalid object can exist&lt;/strong&gt;. All validation happens in the constructor (&lt;code dir=&quot;auto&quot;&gt;__post_init__&lt;/code&gt;). If data is invalid, the constructor raises.&lt;/p&gt;
&lt;p&gt;Every model follows the same pattern:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@dataclass&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;frozen&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;slots&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Metadata&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;: MetadataType&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;data: Mapping[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;, Any]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# Computed fields, excluded from repr/compare/hash&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;_canonical_json: &lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;field&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;init&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;repr&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compare&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;_content_hash: &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;field&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;init&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;repr&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compare&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;_db_params: MetadataDbParams &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;field&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;init&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;repr&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compare&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; __post_init__&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; -&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;# 1. Validate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;validate_instance&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.type&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; MetadataType&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;validate_mapping&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.data&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;# 2. Sanitize (remove None, empty containers, null bytes)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sanitized &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sanitize_data&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.data&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;# 3. Canonical JSON (sort_keys, compact separators, UTF-8)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;canonical &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; json.&lt;/span&gt;&lt;span&gt;dumps&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sanitized&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sort_keys&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;separators&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;# 4. SHA-256 hash of canonical JSON&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;content_hash &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; hashlib.&lt;/span&gt;&lt;span&gt;sha256&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;canonical.&lt;/span&gt;&lt;span&gt;encode&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;utf-8&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)).&lt;/span&gt;&lt;span&gt;digest&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;# 5. Deep freeze (recursive MappingProxyType) for total immutability&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;__setattr__&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;deep_freeze&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;sanitized&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;__setattr__&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;_canonical_json&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; canonical&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;__setattr__&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;_content_hash&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; content_hash&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;__setattr__&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;_db_params&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;_compute_db_params&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A few things worth noting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;frozen=True&lt;/code&gt;&lt;/strong&gt; means the hash never changes after construction. You can safely use these as dict keys or set members.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;object.__setattr__&lt;/code&gt;&lt;/strong&gt; is a documented workaround for setting computed fields in frozen dataclasses. It looks odd, but it’s the right way to do it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;deep_freeze()&lt;/code&gt;&lt;/strong&gt; wraps dicts with &lt;code dir=&quot;auto&quot;&gt;MappingProxyType&lt;/code&gt; and lists with &lt;code dir=&quot;auto&quot;&gt;tuple&lt;/code&gt;. Trying &lt;code dir=&quot;auto&quot;&gt;metadata.data[&quot;key&quot;] = &quot;value&quot;&lt;/code&gt; raises &lt;code dir=&quot;auto&quot;&gt;TypeError&lt;/code&gt;. The data is genuinely immutable, not just “please don’t mutate this.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;_db_params&lt;/code&gt; is cached&lt;/strong&gt;: conversion to a NamedTuple (what asyncpg wants) happens once at construction time, not on every DB call.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Content-addressed deduplication&lt;/strong&gt; is the key insight. Two relays reporting the same NIP-11 data produce the same SHA-256 hash → one row in the &lt;code dir=&quot;auto&quot;&gt;metadata&lt;/code&gt; table. The composite PK &lt;code dir=&quot;auto&quot;&gt;(id, type)&lt;/code&gt; allows the same hash for different types (which is valid — different data structures can hash to the same value, and we need to distinguish them).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Relay URL validation&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;relay.py&lt;/code&gt;) does full RFC 3986 parsing with the &lt;code dir=&quot;auto&quot;&gt;rfc3986&lt;/code&gt; library, auto-detects network type (clearnet/tor/i2p/loki) from TLD and IP, rejects local addresses (26 IANA IPv4/IPv6 ranges), normalizes the scheme (&lt;code dir=&quot;auto&quot;&gt;wss://&lt;/code&gt; for clearnet, &lt;code dir=&quot;auto&quot;&gt;ws://&lt;/code&gt; for overlay networks that handle encryption themselves), and strips default ports.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;“Never trust stored data”&lt;/strong&gt; is a design invariant. Every constructor re-validates everything, even data that “should” already be validated. A &lt;code dir=&quot;auto&quot;&gt;Relay&lt;/code&gt; read from the DB gets re-parsed completely. A &lt;code dir=&quot;auto&quot;&gt;Metadata&lt;/code&gt; read from the DB gets its hash recomputed. If the data is corrupt, the constructor fails immediately. This catches a class of bugs that “trust the DB” approaches miss.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;core--infrastructure-and-lifecycle&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;core/&lt;/code&gt; — Infrastructure and Lifecycle&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;h4 id=&quot;connection-pool-poolpy-738-lines&quot;&gt;Connection Pool (&lt;code dir=&quot;auto&quot;&gt;pool.py&lt;/code&gt;, 738 lines)&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;The asyncpg pool with configurable retry/backoff is one of the more carefully designed pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;5 nested Pydantic models&lt;/strong&gt; for configuration: DatabaseConfig, LimitsConfig, TimeoutsConfig, RetryConfig, ServerSettingsConfig → aggregated in PoolConfig.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Password from environment variable&lt;/strong&gt; (never in YAML): the &lt;code dir=&quot;auto&quot;&gt;password_env&lt;/code&gt; pattern with a &lt;code dir=&quot;auto&quot;&gt;@model_validator(mode=&quot;before&quot;)&lt;/code&gt; that resolves from &lt;code dir=&quot;auto&quot;&gt;os.getenv()&lt;/code&gt;. Pydantic’s &lt;code dir=&quot;auto&quot;&gt;SecretStr&lt;/code&gt; never appears in logs or repr.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exponential vs linear backoff&lt;/strong&gt;: configurable via boolean. &lt;code dir=&quot;auto&quot;&gt;2^attempt&lt;/code&gt; vs &lt;code dir=&quot;auto&quot;&gt;(attempt + 1)&lt;/code&gt;, both bounded by &lt;code dir=&quot;auto&quot;&gt;max_delay&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Async lock for thread-safe connection&lt;/strong&gt;: prevents race conditions if multiple coroutines call &lt;code dir=&quot;auto&quot;&gt;connect()&lt;/code&gt; simultaneously. Idempotent — the second call returns immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error discrimination in retry&lt;/strong&gt;: retry ONLY on transient errors (&lt;code dir=&quot;auto&quot;&gt;InterfaceError&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ConnectionDoesNotExistError&lt;/code&gt;). Query errors (syntax, constraint violations) propagate immediately — this prevents infinite loops on bad SQL, which is a surprisingly common mistake in retry logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fresh connection per retry attempt&lt;/strong&gt;: each attempt acquires a new connection from the pool. If the previous one was broken mid-query, the new socket works.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dual JSON codec&lt;/strong&gt;: handles both pre-serialized JSON strings (from &lt;code dir=&quot;auto&quot;&gt;Metadata.canonical_json&lt;/code&gt;) and raw Python dicts. Without this, double serialization would corrupt the data silently.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;db-facade-brotrpy-964-lines&quot;&gt;DB Facade (&lt;code dir=&quot;auto&quot;&gt;brotr.py&lt;/code&gt;, 964 lines)&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;High-level facade wrapping all PostgreSQL stored procedures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;3-level cascading timeouts&lt;/strong&gt;: asyncpg client-side (configurable per type: query=60s, batch=120s, cleanup=90s, refresh=unlimited) → PgBouncer query_timeout (300s safety net) → PostgreSQL statement_timeout. The tightest level wins. A runaway query doesn’t block other connections.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;_call_procedure()&lt;/code&gt;&lt;/strong&gt;: central private method that executes all stored procedures with the appropriate timeout, automatic retry, and parameter conversion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generic methods&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;fetch()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;fetchrow()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;fetchval()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;execute()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;transaction()&lt;/code&gt; — services use ONLY these, never the pool directly.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;service-lifecycle-base_servicepy-418-lines&quot;&gt;Service Lifecycle (&lt;code dir=&quot;auto&quot;&gt;base_service.py&lt;/code&gt;, 418 lines)&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;This combines Template Method + State Machine + Async Context Manager:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bounded generics&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;BaseService[ConfigT]&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;ConfigT = TypeVar(&quot;ConfigT&quot;, bound=BaseServiceConfig)&lt;/code&gt; → mypy does type-narrowing of the config in derived services. Your service config is correctly typed everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;run_forever()&lt;/code&gt; loop&lt;/strong&gt;: the ONLY broad &lt;code dir=&quot;auto&quot;&gt;except Exception&lt;/code&gt; in the entire codebase. Documented and justified as the event loop boundary. &lt;code dir=&quot;auto&quot;&gt;CancelledError&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;KeyboardInterrupt&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SystemExit&lt;/code&gt; always propagate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interruptible wait&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;wait(timeout)&lt;/code&gt; wraps &lt;code dir=&quot;auto&quot;&gt;asyncio.wait_for(event.wait())&lt;/code&gt;. The service wakes up immediately on shutdown signal, even with a 5-minute interval between cycles. Never &lt;code dir=&quot;auto&quot;&gt;asyncio.sleep()&lt;/code&gt; — it’s not interruptible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consecutive failure tracking&lt;/strong&gt;: error counter that resets to 0 on success. If it exceeds &lt;code dir=&quot;auto&quot;&gt;max_consecutive_failures&lt;/code&gt;, the service exits. If set to 0, no limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic Prometheus metrics&lt;/strong&gt;: cycle duration, timestamp, successes, errors, error type — all wired in &lt;code dir=&quot;auto&quot;&gt;run_forever()&lt;/code&gt;, not in derived services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Factory methods&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;from_yaml()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;from_dict()&lt;/code&gt; use &lt;code dir=&quot;auto&quot;&gt;cls.CONFIG_CLASS&lt;/code&gt; for dynamic config parsing without boilerplate in services.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;structured-logging-loggerpy-249-lines&quot;&gt;Structured Logging (&lt;code dir=&quot;auto&quot;&gt;logger.py&lt;/code&gt;, 249 lines)&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Key=value logging with optional JSON output. &lt;code dir=&quot;auto&quot;&gt;format_kv_pairs()&lt;/code&gt; for consistent formatting. Integrated with Prometheus: every cycle log includes metrics.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;prometheus-metrics-metricspy-209-lines&quot;&gt;Prometheus Metrics (&lt;code dir=&quot;auto&quot;&gt;metrics.py&lt;/code&gt;, 209 lines)&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Four metric types: &lt;code dir=&quot;auto&quot;&gt;SERVICE_INFO&lt;/code&gt; (identity), &lt;code dir=&quot;auto&quot;&gt;SERVICE_GAUGE&lt;/code&gt; (per-cycle snapshot), &lt;code dir=&quot;auto&quot;&gt;SERVICE_COUNTER&lt;/code&gt; (cumulative), &lt;code dir=&quot;auto&quot;&gt;CYCLE_DURATION_SECONDS&lt;/code&gt; (histogram). HTTP server on port 8000 for scraping.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;nips--protocol-layer-with-graceful-degradation&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;nips/&lt;/code&gt; — Protocol Layer with Graceful Degradation&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The golden rule:&lt;/strong&gt; NIP fetch methods never raise exceptions. Errors are captured in &lt;code dir=&quot;auto&quot;&gt;logs.success&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;logs.reason&lt;/code&gt;, allowing batch processing of hundreds of relays without individual error handling.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;declarative-parsing-fieldspec&quot;&gt;Declarative Parsing (&lt;code dir=&quot;auto&quot;&gt;FieldSpec&lt;/code&gt;)&lt;/h4&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@dataclass&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;frozen&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;slots&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FieldSpec&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;int_fields: frozenset[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;]       &lt;/span&gt;&lt;span&gt;# Excludes bool (Python: bool is a subclass of int!)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;bool_fields: frozenset[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;str_fields: frozenset[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;str_list_fields: frozenset[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;]  &lt;/span&gt;&lt;span&gt;# list[str], invalid elements filtered out&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;float_fields: frozenset[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;]     &lt;/span&gt;&lt;span&gt;# int or float → float&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;int_list_fields: frozenset[&lt;/span&gt;&lt;span&gt;str&lt;/span&gt;&lt;span&gt;]  &lt;/span&gt;&lt;span&gt;# list[int], bool excluded&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Each data model declares a &lt;code dir=&quot;auto&quot;&gt;_FIELD_SPEC&lt;/code&gt; and the &lt;code dir=&quot;auto&quot;&gt;parse_fields()&lt;/code&gt; function applies type coercion with silent failure. Invalid or missing fields get skipped — because relay JSON is “garbage-in.” NIP implementations must never crash on corrupt data. The schema can be declared once and the parser handles the messy reality of the open internet.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;nip-11-relay-info-fetch&quot;&gt;NIP-11: Relay Info Fetch&lt;/h4&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;HTTP(S) fetch with aiohttp, SOCKS5 proxy support for overlay networks&lt;/li&gt;
&lt;li&gt;SSL strategy: verify first → fallback to &lt;code dir=&quot;auto&quot;&gt;CERT_NONE&lt;/code&gt; if &lt;code dir=&quot;auto&quot;&gt;allow_insecure&lt;/code&gt; is configured → error otherwise&lt;/li&gt;
&lt;li&gt;Response limited to 64 KB, Content-Type validated (&lt;code dir=&quot;auto&quot;&gt;application/nostr+json&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;application/json&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Data parsed into &lt;code dir=&quot;auto&quot;&gt;Nip11InfoData&lt;/code&gt; (30+ fields: name, description, pubkey, supported NIPs, limitations, fees, retention policies)&lt;/li&gt;
&lt;li&gt;Python reserved keyword handling: NIP-11 field &lt;code dir=&quot;auto&quot;&gt;&quot;self&quot;&lt;/code&gt; aliased to &lt;code dir=&quot;auto&quot;&gt;self_pubkey&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;populate_by_name=True&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;nip-66-6-concurrent-health-tests&quot;&gt;NIP-66: 6 Concurrent Health Tests&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Six independent tests per relay, each producing a &lt;code dir=&quot;auto&quot;&gt;MetadataType&lt;/code&gt;:&lt;/p&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Test&lt;/th&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;I/O&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;RTT&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;NIP66_RTT&lt;/code&gt;&lt;/td&gt;&lt;td&gt;WebSocket open/read/write&lt;/td&gt;&lt;td&gt;3 sequential phases with cascading failure&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;SSL&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;NIP66_SSL&lt;/code&gt;&lt;/td&gt;&lt;td&gt;TCP socket (2 connections)&lt;/td&gt;&lt;td&gt;Clearnet only. Extracts with CERT_NONE, validates separately&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;DNS&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;NIP66_DNS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;dnspython (thread pool)&lt;/td&gt;&lt;td&gt;A, AAAA, CNAME, NS, PTR records&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Geo&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;NIP66_GEO&lt;/code&gt;&lt;/td&gt;&lt;td&gt;GeoIP2 City (thread pool)&lt;/td&gt;&lt;td&gt;Latitude, longitude, geohash (precision 9)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Net&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;NIP66_NET&lt;/code&gt;&lt;/td&gt;&lt;td&gt;GeoIP2 ASN&lt;/td&gt;&lt;td&gt;ASN, organization, CIDR. IPv4 takes priority over IPv6&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;NIP66_HTTP&lt;/code&gt;&lt;/td&gt;&lt;td&gt;aiohttp TraceConfig&lt;/td&gt;&lt;td&gt;Captures headers from WebSocket upgrade handshake&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The execution uses &lt;code dir=&quot;auto&quot;&gt;asyncio.gather(*tasks, return_exceptions=True)&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;results &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; asyncio.&lt;/span&gt;&lt;span&gt;gather&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;tasks&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;return_exceptions&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; name, result &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;zip&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;task_names&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; results&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;strict&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;isinstance&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&lt;span&gt; (asyncio.CancelledError, &lt;/span&gt;&lt;span&gt;KeyboardInterrupt&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SystemExit&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;raise&lt;/span&gt;&lt;span&gt; result  &lt;/span&gt;&lt;span&gt;# Shutdown signals ALWAYS propagate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;isinstance&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BaseException&lt;/span&gt;&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;logger.&lt;/span&gt;&lt;span&gt;error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;test=&lt;/span&gt;&lt;span&gt;%s&lt;/span&gt;&lt;span&gt; error=&lt;/span&gt;&lt;span&gt;%r&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; name&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; result&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;metadata_map[name] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;# Failed test → None&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;metadata_map[name] &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; result&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One crashing test doesn’t kill the others. The operator sees in the log which test failed. The field is &lt;code dir=&quot;auto&quot;&gt;None&lt;/code&gt; in the result.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RTT — Cascading Failure Pattern&lt;/strong&gt;: if the WebSocket open fails, read and write are automatically marked as failed with the same reason. No point testing read/write if the connection didn’t open.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RTT — Coroutine Factory for Retry&lt;/strong&gt;: the retry pattern uses a &lt;code dir=&quot;auto&quot;&gt;coro_factory: Callable[[], Coroutine]&lt;/code&gt; because Python coroutines are single-use. Once awaited, they can’t be re-awaited. The retry creates a fresh coroutine on each attempt:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;_with_retry&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;coro_factory&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;retry_config&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; attempt &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;range&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;retry_config.max_attempts&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;result &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;coro_factory&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; result &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;not&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; result&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;delay &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;min&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;retry_config.initial_delay &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;**&lt;/span&gt;&lt;span&gt; attempt)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; retry_config.max_delay&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;jitter &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; random.&lt;/span&gt;&lt;span&gt;uniform&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; retry_config.jitter&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; asyncio.&lt;/span&gt;&lt;span&gt;sleep&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;delay &lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt; jitter&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Exponential backoff + jitter decorrelates concurrent retries, preventing thundering herd when multiple relays fail simultaneously.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SSL — Two-Connection Methodology&lt;/strong&gt;: Connection 1 with &lt;code dir=&quot;auto&quot;&gt;CERT_NONE&lt;/code&gt; to extract the certificate even from invalid/self-signed chains. Connection 2 with default context to validate the chain. This lets us monitor relays with expired certificates — monitoring should see the state, not just valid certs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependency Injection for Optional Features&lt;/strong&gt;: if GeoIP databases aren’t available, Geo/Net tests are silently skipped. If signing keys aren’t configured, the RTT test is skipped. Same code handles deployments with and without optional features.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;event-builders-ground-truth--self-report&quot;&gt;Event Builders: Ground Truth &gt; Self-Report&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;When building discovery event tags (Kind 30166), NIP-66 probe results (ground truth) override NIP-11 self-reports:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; write_success &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;True&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# Probe wrote successfully without auth/payment → relay is open&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;auth &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;payment &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;elif&lt;/span&gt;&lt;span&gt; write_success &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;and&lt;/span&gt;&lt;span&gt; write_reason:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# Probe failed → trust the reason, supplement with NIP-11&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;auth &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;auth&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; write_reason &lt;/span&gt;&lt;span&gt;or&lt;/span&gt;&lt;span&gt; nip11_auth&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;payment &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;pay&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; write_reason &lt;/span&gt;&lt;span&gt;or&lt;/span&gt;&lt;span&gt; nip11_payment&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# No probe result → NIP-11 is all we have&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;auth &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;nip11_auth&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;payment &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;nip11_payment&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Relays can lie about auth/payment requirements. The NIP-66 probe result is authoritative.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;utils--network-primitives&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;utils/&lt;/code&gt; — Network Primitives&lt;/h3&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DNS&lt;/strong&gt;: A/AAAA resolution with dnspython, executed in thread pool via &lt;code dir=&quot;auto&quot;&gt;asyncio.to_thread()&lt;/code&gt; to avoid blocking the event loop&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transport&lt;/strong&gt;: WebSocket/HTTP with SSL fallback, &lt;code dir=&quot;auto&quot;&gt;InsecureWebSocketTransport&lt;/code&gt; for overlay networks, configurable &lt;code dir=&quot;auto&quot;&gt;DEFAULT_TIMEOUT&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keys&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;load_keys_from_env()&lt;/code&gt; loads Nostr keys from environment variables, &lt;code dir=&quot;auto&quot;&gt;KeysConfig&lt;/code&gt; Pydantic model with validation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Protocol&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;create_client()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;connect_relay()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;broadcast_events()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;is_nostr_relay()&lt;/code&gt; — all Nostr operations wrapped with error handling&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h3 id=&quot;services--business-logic&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;services/&lt;/code&gt; — Business Logic&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Let me go through each service briefly, highlighting what makes each one interesting.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;seeder-111-lines--bootstrap&quot;&gt;Seeder (111 lines) — Bootstrap&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;One-shot parsing of a seed file (one URL per line). Validates each URL → &lt;code dir=&quot;auto&quot;&gt;Relay&lt;/code&gt; object. Inserts as candidates or directly as relays. No retry, no cycle. &lt;code dir=&quot;auto&quot;&gt;restart: no&lt;/code&gt; in Docker Compose. It runs once and exits.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;finder-398-lines--multi-source-discovery&quot;&gt;Finder (398 lines) — Multi-Source Discovery&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Two discovery sources:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Event Mining&lt;/strong&gt;: for each relay in the DB, scans events archived by Synchronizer looking for relay URLs in tags. Uses composite cursors &lt;code dir=&quot;auto&quot;&gt;(seen_at, event_id)&lt;/code&gt; for deterministic pagination. Cursors are persisted in the DB — if Finder restarts, it resumes where it left off.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;External APIs&lt;/strong&gt;: HTTP GET on configurable endpoints (e.g., Nostr aggregator APIs). Parsing with JMESPath to extract URLs from arbitrary JSON structures.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Concurrency via &lt;code dir=&quot;auto&quot;&gt;asyncio.TaskGroup&lt;/code&gt; + semaphore to limit parallel event scans per relay.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;validator-237-lines--websocket-handshake-test&quot;&gt;Validator (237 lines) — WebSocket Handshake Test&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Cycle: cleanup stale → cleanup exhausted → validate.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Priority queue&lt;/strong&gt;: candidates ordered by &lt;code dir=&quot;auto&quot;&gt;(failures ASC, updated_at ASC)&lt;/code&gt; — those with fewer failures get priority&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-network semaphores&lt;/strong&gt;: Tor gets fewer simultaneous connections than clearnet&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Atomic promotion&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;promote_candidates()&lt;/code&gt; = &lt;code dir=&quot;auto&quot;&gt;insert_relay()&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;DELETE service_state CANDIDATE&lt;/code&gt; in a single operation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-cycle budget&lt;/strong&gt;: configurable &lt;code dir=&quot;auto&quot;&gt;max_candidates&lt;/code&gt;; the validator processes at most N candidates per cycle&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;allow_insecure&lt;/code&gt;&lt;/strong&gt;: configurable SSL fallback for relays with invalid certificates&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;monitor-669-lines--the-most-complex-service&quot;&gt;Monitor (669 lines) — The Most Complex Service&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Quadruple mixin composition:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Monitor&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;ConcurrentStreamMixin&lt;/span&gt;&lt;span&gt;,    &lt;/span&gt;&lt;span&gt;# TaskGroup + Queue streaming&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NetworkSemaphoresMixin&lt;/span&gt;&lt;span&gt;,   &lt;/span&gt;&lt;span&gt;# Per-network semaphores&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;GeoReaderMixin&lt;/span&gt;&lt;span&gt;,           &lt;/span&gt;&lt;span&gt;# GeoIP database lifecycle&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;ClientsMixin&lt;/span&gt;&lt;span&gt;,             &lt;/span&gt;&lt;span&gt;# Managed Nostr client pool&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;BaseService[MonitorConfig],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Cooperative initialization via MRO: &lt;code dir=&quot;auto&quot;&gt;super().__init__(**kwargs)&lt;/code&gt; in every mixin. The method resolution order handles the initialization chain automatically.&lt;/p&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;run()&lt;/code&gt; cycle:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Update GeoIP databases (download if missing/expired)&lt;/li&gt;
&lt;li&gt;Open GeoIP readers (in thread pool — blocking I/O)&lt;/li&gt;
&lt;li&gt;Publish profile (Kind 0) if due (configurable interval)&lt;/li&gt;
&lt;li&gt;Publish monitor announcement (Kind 10166) if due&lt;/li&gt;
&lt;li&gt;For each relay (ordered by “least recently checked”):
&lt;ul&gt;
&lt;li&gt;Acquire per-network semaphore&lt;/li&gt;
&lt;li&gt;NIP-11 fetch with retry&lt;/li&gt;
&lt;li&gt;NIP-66 RTT with retry (can apply PoW if relay requires &lt;code dir=&quot;auto&quot;&gt;min_pow_difficulty&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;5 NIP-66 tests in parallel with retry&lt;/li&gt;
&lt;li&gt;Return &lt;code dir=&quot;auto&quot;&gt;CheckResult&lt;/code&gt; (NamedTuple with 7 nullable fields)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Publish discovery events (Kind 30166) for relays with data&lt;/li&gt;
&lt;li&gt;Persist metadata (atomic cascade) + monitoring state&lt;/li&gt;
&lt;li&gt;Close GeoIP readers&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Publication interval state management&lt;/strong&gt;: the monitor uses the &lt;code dir=&quot;auto&quot;&gt;service_state&lt;/code&gt; table with type &lt;code dir=&quot;auto&quot;&gt;PUBLICATION&lt;/code&gt; to track when it last published each event type. If the interval hasn’t elapsed, it skips publishing. This prevents publishing the same event thousands of times per hour.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;synchronizer-449-lines--event-archiving-with-binary-split-windowing&quot;&gt;Synchronizer (449 lines) — Event Archiving with Binary-Split Windowing&lt;/h4&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Relay shuffle&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;random.shuffle()&lt;/code&gt; prevents thundering herd when multiple instances hit the same relays in the same order&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cursor-based resumption&lt;/strong&gt;: per-relay cursor &lt;code dir=&quot;auto&quot;&gt;{last_synced_at: timestamp}&lt;/code&gt; in the DB. On restart, resumes where it left off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Binary-split windowing&lt;/strong&gt;: data-driven time windows with completeness verification. If a relay’s response appears incomplete, the window is split in half and retried — ensuring no events are missed even from high-volume relays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-relay overrides&lt;/strong&gt;: custom timeouts for high-traffic relays&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event validation&lt;/strong&gt;: signature verification (&lt;code dir=&quot;auto&quot;&gt;evt.verify()&lt;/code&gt;), temporal bounds, null bytes. Invalid events counted but not inserted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;allow_insecure&lt;/code&gt;&lt;/strong&gt;: configurable SSL fallback for relays with invalid certificates, same as Validator and Dvm.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;refresher-92-lines--the-simplest&quot;&gt;Refresher (92 lines) — The Simplest&lt;/h4&gt;&lt;/div&gt;
&lt;p&gt;Sequential, no parallelism. For each view in the configured list: &lt;code dir=&quot;auto&quot;&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY view_name&lt;/code&gt;. One view failing doesn’t block the others.&lt;/p&gt;
&lt;div&gt;&lt;h4 id=&quot;api-389-lines--dynamic-rest-api&quot;&gt;Api (389 lines) — Dynamic REST API&lt;/h4&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automatic schema discovery&lt;/strong&gt;: on startup, queries &lt;code dir=&quot;auto&quot;&gt;information_schema&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;pg_catalog&lt;/code&gt; to discover tables, columns, PKs, unique indexes. Generates endpoints automatically for each enabled table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parameterized query builder&lt;/strong&gt;: filters (&lt;code dir=&quot;auto&quot;&gt;col=value&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;col=&gt;=:100&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;col=ILIKE:%pattern%&lt;/code&gt;), sorting, pagination. Operator whitelist. Type transforms for bytea (hex), timestamp (text), numeric (float).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CatalogError&lt;/strong&gt;: client-safe exception that never exposes raw DB errors. Controlled messages or validated identifiers only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offset limit&lt;/strong&gt;: 100,000 max to prevent deep pagination abuse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Middleware&lt;/strong&gt;: request/response logging, timing, status code tracking, configurable CORS.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h4 id=&quot;dvm-396-lines--nostr-data-vending-machine-nip-90&quot;&gt;Dvm (396 lines) — Nostr Data Vending Machine (NIP-90)&lt;/h4&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;NIP-90 protocol&lt;/strong&gt;: listens for Kind 5050 requests via WebSocket, responds with results (Kind 6050) or errors (Kind 7000) on the Nostr network&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Same Catalog as API&lt;/strong&gt;: uses the same query builder for consistency&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request deduplication&lt;/strong&gt;: in-memory set (flushed at 10K) + temporal &lt;code dir=&quot;auto&quot;&gt;since&lt;/code&gt; window to prevent duplicate processing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-table pricing&lt;/strong&gt;: each table can have a price in millisatoshi. If the bid is insufficient, publishes &lt;code dir=&quot;auto&quot;&gt;payment_required&lt;/code&gt; (Kind 7000)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NIP-89 announcement&lt;/strong&gt;: on startup publishes Kind 31990 with available tables&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h3 id=&quot;servicescommon--shared-code&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;services/common/&lt;/code&gt; — Shared Code&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Query Functions&lt;/strong&gt; (1,012 lines total across 6 modules): domain SQL queries are distributed across service-specific &lt;code dir=&quot;auto&quot;&gt;queries.py&lt;/code&gt; modules (monitor 248, finder 260, validator 241, synchronizer 98, seeder 29) plus a shared &lt;code dir=&quot;auto&quot;&gt;common/queries.py&lt;/code&gt; (136 lines). Parameterized &lt;code dir=&quot;auto&quot;&gt;$1&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;$2&lt;/code&gt; placeholders (never f-strings for values). Timeouts from config. Batching via &lt;code dir=&quot;auto&quot;&gt;_batched_insert()&lt;/code&gt; that splits records into chunks of &lt;code dir=&quot;auto&quot;&gt;max_size&lt;/code&gt;. Composite cursors for deterministic pagination.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mixins&lt;/strong&gt; (423 lines, 5 cooperative mixins):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ConcurrentStreamMixin&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;_iter_concurrent()&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;asyncio.TaskGroup&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;asyncio.Queue&lt;/code&gt; streaming — results are yielded as workers complete, not buffered until all finish.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NetworkSemaphoresMixin&lt;/strong&gt;: maps &lt;code dir=&quot;auto&quot;&gt;NetworkType → asyncio.Semaphore&lt;/code&gt;. Different networks get different concurrency. &lt;code dir=&quot;auto&quot;&gt;LOCAL&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;UNKNOWN&lt;/code&gt; return &lt;code dir=&quot;auto&quot;&gt;None&lt;/code&gt; (no concurrency limiting).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GeoReaderMixin&lt;/strong&gt;: GeoIP2 database lifecycle (opening in thread pool via &lt;code dir=&quot;auto&quot;&gt;asyncio.to_thread()&lt;/code&gt;, synchronous close). Idempotent — opening or closing twice is safe.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ClientsMixin&lt;/strong&gt;: managed Nostr client pool with per-relay proxy URL resolution, auto-configured from &lt;code dir=&quot;auto&quot;&gt;MonitorConfig&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CatalogAccessMixin&lt;/strong&gt;: schema discovery + table access policy, used by Api and Dvm for safe parameterized queries.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Catalog&lt;/strong&gt; (532 lines): the safe query builder shared between Api and Dvm. Schema discovery via &lt;code dir=&quot;auto&quot;&gt;information_schema&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;pg_catalog&lt;/code&gt;. Whitelist-by-construction: only tables/columns discovered from the DB can be used. Type transforms. Operator validation. &lt;code dir=&quot;auto&quot;&gt;CatalogError&lt;/code&gt; for client-safe errors.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;extensibility-and-reusability--the-hardest-challenge&quot;&gt;Extensibility and Reusability — The Hardest Challenge&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The most complex part of the project wasn’t implementing individual services. It was &lt;strong&gt;designing a system that works both as a deployable application and as a reusable Python library&lt;/strong&gt;, and that can be extended without modifying existing code.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;bigbrotr-as-a-python-library&quot;&gt;BigBrotr as a Python Library&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The package is designed for independent use at any level of the DAG:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Just relay URL validation (models, zero I/O)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.models &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Relay, NetworkType&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;relay &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Relay&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;wss://relay.damus.io&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)  &lt;/span&gt;&lt;span&gt;# Validates, parses, detects network&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Just DB infrastructure (core)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.core &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Brotr, Pool&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;brotr &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; Brotr.&lt;/span&gt;&lt;span&gt;from_dict&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;pool&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;database&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: {...}}}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Just health checking a relay (nips, I/O)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.nips &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Nip11, Nip66&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;nip11 &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; Nip11.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;nip66 &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; Nip66.&lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;selection&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;Nip66Selection&lt;/span&gt;&lt;span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;dns&lt;/span&gt;&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;False&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Full service with lifecycle, metrics, logging&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Monitor&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The package’s &lt;code dir=&quot;auto&quot;&gt;__init__.py&lt;/code&gt; exposes &lt;strong&gt;32 symbols&lt;/strong&gt; with lazy loading: imports happen only on first access, reducing startup time for consumers who use only a subset. The diamond DAG guarantees you can import any subset without unnecessary dependencies and without circular imports.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;adding-a-new-service--minimal-boilerplate&quot;&gt;Adding a New Service — Minimal Boilerplate&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A new service requires only &lt;strong&gt;~50 lines of code&lt;/strong&gt; and 4-5 files. You write just the business logic; all infrastructure is “free”:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; bigbrotr.core.base_service &lt;/span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; BaseService, BaseServiceConfig&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MyConfig&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;BaseServiceConfig&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;batch_size: &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MyService&lt;/span&gt;&lt;span&gt;(BaseService[MyConfig]):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;SERVICE_NAME&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ServiceName.&lt;/span&gt;&lt;span&gt;MYSERVICE&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;CONFIG_CLASS&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; MyConfig&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; -&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;relays &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;fetch_all_relays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;._brotr&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;# ... business logic ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Registration: one line in the &lt;code dir=&quot;auto&quot;&gt;SERVICE_REGISTRY&lt;/code&gt; in &lt;code dir=&quot;auto&quot;&gt;__main__.py&lt;/code&gt; + a YAML config file. Then &lt;code dir=&quot;auto&quot;&gt;python -m bigbrotr myservice --config config/services/myservice.yaml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What you get for free:&lt;/p&gt;


















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Feature&lt;/th&gt;&lt;th&gt;Provided by&lt;/th&gt;&lt;th&gt;Notes&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;YAML config parsing&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;BaseService.from_yaml()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Standard Pydantic fields&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Structured logging&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;self._logger&lt;/code&gt;&lt;/td&gt;&lt;td&gt;key=value with JSON output&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Prometheus metrics&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;SERVICE_COUNTER&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;GAUGE&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;/metrics&lt;/code&gt; endpoint on port 8000&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Graceful shutdown&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;run_forever()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;SIGINT/SIGTERM → clean shutdown&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Failure tracking&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;run_forever()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Consecutive error counter&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DB access&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;self._brotr&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Pool, queries, transactions, stored procedures&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Connection pooling&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Pool&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Retry/backoff, health-checked acquisition&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Run/wait cycle&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;run_forever()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Configurable interval, interruptible wait&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;You don’t have to manage connections, logging, metrics, shutdown, or retry — the framework handles all of it. Your service just implements &lt;code dir=&quot;auto&quot;&gt;async def run()&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;adding-new-nips-and-metadatatypes--the-hardest-part&quot;&gt;Adding New NIPs and MetadataTypes — The Hardest Part&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This was the most complex architectural challenge: modeling the &lt;code dir=&quot;auto&quot;&gt;nips/&lt;/code&gt; layer with &lt;strong&gt;robust validation that doesn’t block future extensions&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The problem: today BigBrotr supports 7 metadata types (NIP-11 info + 6 NIP-66 tests). Tomorrow someone might want to add NIP-66 DNSSEC, or an entirely new NIP (e.g., NIP-XX for content analysis). The model must accommodate new types &lt;strong&gt;without modifying existing code and without database migrations&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The solution is a &lt;strong&gt;3-level forward-compatible schema&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Level 1 — Database&lt;/strong&gt;: the &lt;code dir=&quot;auto&quot;&gt;metadata&lt;/code&gt; table accepts any string as &lt;code dir=&quot;auto&quot;&gt;type&lt;/code&gt;. Adding &lt;code dir=&quot;auto&quot;&gt;NIP66_DNSSEC&lt;/code&gt; requires no ALTER TABLE. Content is free-form JSONB. The SHA-256 hash is computed in Python, not in the DB. Existing code ignores unknown types.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Level 2 — Python Models&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;MetadataType&lt;/code&gt; is a &lt;code dir=&quot;auto&quot;&gt;StrEnum&lt;/code&gt;. Adding a member is one line:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MetadataType&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;StrEnum&lt;/span&gt;&lt;span&gt;):&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NIP11_INFO&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;nip11_info&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NIP66_RTT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;nip66_rtt&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NIP66_DNSSEC&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;nip66_dnssec&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;# NEW: one line, zero breaking change&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;Metadata&lt;/code&gt; model is &lt;strong&gt;agnostic&lt;/strong&gt; about the internal data structure. It accepts any &lt;code dir=&quot;auto&quot;&gt;Mapping[str, Any]&lt;/code&gt;, sanitizes it, serializes to canonical JSON, and computes the hash. The same model works for NIP-11, NIP-66, and any future NIP without modifications.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Level 3 — NIP Implementation&lt;/strong&gt;: every NIP follows the &lt;code dir=&quot;auto&quot;&gt;BaseNip&lt;/code&gt; contract:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BaseNip&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;@classmethod&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;cls&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;**kwargs&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; -&gt; Self:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;span&gt;Async factory. NEVER raises exceptions.&lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;def&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;to_relay_metadata_tuple&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;self&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; -&gt; tuple[RelayMetadata &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;None&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;]:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;span&gt;Converts results to DB-ready records.&lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Adding a new NIP-66 test requires:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;code dir=&quot;auto&quot;&gt;StrEnum&lt;/code&gt; member in &lt;code dir=&quot;auto&quot;&gt;MetadataType&lt;/code&gt; (1 line)&lt;/li&gt;
&lt;li&gt;A frozen data model with &lt;code dir=&quot;auto&quot;&gt;_FIELD_SPEC&lt;/code&gt; for declarative parsing (~30 lines)&lt;/li&gt;
&lt;li&gt;An &lt;code dir=&quot;auto&quot;&gt;execute()&lt;/code&gt; method with the I/O logic (~50 lines)&lt;/li&gt;
&lt;li&gt;A field in &lt;code dir=&quot;auto&quot;&gt;Nip66Selection&lt;/code&gt; to enable/disable (1 line)&lt;/li&gt;
&lt;li&gt;A field in the &lt;code dir=&quot;auto&quot;&gt;Nip66&lt;/code&gt; orchestrator (1 line)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;The Monitor doesn’t change.&lt;/strong&gt; It calls &lt;code dir=&quot;auto&quot;&gt;Nip66.create()&lt;/code&gt; and iterates over results. New tests appear automatically because the Monitor has no switch/case on types — it uses &lt;code dir=&quot;auto&quot;&gt;to_relay_metadata_tuple()&lt;/code&gt; which returns all results (those that are &lt;code dir=&quot;auto&quot;&gt;None&lt;/code&gt; get skipped).&lt;/p&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;Selection&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Options&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Dependencies&lt;/code&gt; pattern lets you add parameters without changing signatures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Selection&lt;/strong&gt;: which tests to run (boolean flag per type)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Options&lt;/strong&gt;: how to run them (e.g., &lt;code dir=&quot;auto&quot;&gt;allow_insecure&lt;/code&gt; for SSL)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependencies&lt;/strong&gt;: what’s available (Nostr keys, GeoIP databases)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If a dependency is &lt;code dir=&quot;auto&quot;&gt;None&lt;/code&gt;, the test is silently skipped — same code handles deployments with and without optional features.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;configurable-deployments--zero-code&quot;&gt;Configurable Deployments — Zero Code&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Everything is configurable via YAML without touching a single line of code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pool&lt;/strong&gt;: size, timeouts, retry, backoff strategy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Services&lt;/strong&gt;: enabled networks (clearnet/tor/i2p/loki), per-network concurrency, proxy URLs, batch size, intervals, limits, cleanup thresholds&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor&lt;/strong&gt;: enabled checks, retry config per test, publication intervals, GeoIP database paths&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Synchronizer&lt;/strong&gt;: per-relay timeout overrides, temporal ranges, event filters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Api/Dvm&lt;/strong&gt;: exposed tables, pagination limits, CORS, per-table pricing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Creating a new deployment&lt;/strong&gt; requires zero code:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Copy &lt;code dir=&quot;auto&quot;&gt;deployments/bigbrotr/&lt;/code&gt; to &lt;code dir=&quot;auto&quot;&gt;deployments/mydeployment/&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Edit the YAML config files&lt;/li&gt;
&lt;li&gt;Optionally customize the &lt;code dir=&quot;auto&quot;&gt;event&lt;/code&gt; table schema (the only table with a variable schema — from a minimal &lt;code dir=&quot;auto&quot;&gt;id&lt;/code&gt;-only version to the full version with all fields)&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;docker compose up&lt;/code&gt; — the parametric Dockerfile (&lt;code dir=&quot;auto&quot;&gt;ARG DEPLOYMENT&lt;/code&gt;) handles everything&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The result: you can have N different deployments of the same codebase, each with a different schema, different ports, different configurations — and zero code duplication.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;backward-compatibility-by-design&quot;&gt;Backward Compatibility by Design&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The fundamental principle: &lt;strong&gt;extend, never modify&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New &lt;code dir=&quot;auto&quot;&gt;MetadataType&lt;/code&gt; → the DB accepts it without migration, existing code ignores it&lt;/li&gt;
&lt;li&gt;New services → hook into the existing framework, use the same queries, same pool, same lifecycle&lt;/li&gt;
&lt;li&gt;New NIPs → follow the &lt;code dir=&quot;auto&quot;&gt;BaseNip&lt;/code&gt; contract, the Monitor orchestrates them transparently&lt;/li&gt;
&lt;li&gt;New deployments → same Dockerfile, same CI, just different config&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This means you can build heavy, complex services on top of the BigBrotr infrastructure without having to modify it. You get a managed pool, DB facade, logging, metrics, lifecycle, and a validated and immutable domain model — all ready to go.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;database-schema&quot;&gt;Database Schema&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;core-tables-6&quot;&gt;Core Tables (6)&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Relay: identity + network&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;relay&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;network &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;discovered_at &lt;/span&gt;&lt;span&gt;BIGINT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Event: complete Nostr event&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id &lt;/span&gt;&lt;span&gt;BYTEA&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;pubkey &lt;/span&gt;&lt;span&gt;BYTEA&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;created_at &lt;/span&gt;&lt;span&gt;BIGINT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;kind &lt;/span&gt;&lt;span&gt;INTEGER&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tags JSONB &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tagvalues &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;[] &lt;/span&gt;&lt;span&gt;GENERATED&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ALWAYS&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AS&lt;/span&gt;&lt;span&gt; (tags_to_tagvalues(tags)) STORED,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;content &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;sig &lt;/span&gt;&lt;span&gt;BYTEA&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Junction: which event was seen on which relay&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;event_relay&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;event_id &lt;/span&gt;&lt;span&gt;BYTEA&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;REFERENCES&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;event&lt;/span&gt;&lt;span&gt;(id),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;relay_url &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;REFERENCES&lt;/span&gt;&lt;span&gt; relay(&lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;seen_at &lt;/span&gt;&lt;span&gt;BIGINT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt; (event_id, relay_url)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Metadata: content-addressed (SHA-256 hash)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;metadata&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id &lt;/span&gt;&lt;span&gt;BYTEA&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt; JSONB &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt; (id, &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Time-series junction: health check history per relay&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;relay_metadata&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;relay_url &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;REFERENCES&lt;/span&gt;&lt;span&gt; relay(&lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;metadata_id &lt;/span&gt;&lt;span&gt;BYTEA&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;metadata_type &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;generated_at &lt;/span&gt;&lt;span&gt;BIGINT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt; (relay_url, generated_at, metadata_type),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;FOREIGN KEY&lt;/span&gt;&lt;span&gt; (metadata_id, metadata_type) &lt;/span&gt;&lt;span&gt;REFERENCES&lt;/span&gt;&lt;span&gt; metadata(id, &lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Generic key-value store for service state&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;service_state&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;service_name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;state_type &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;state_key &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;state_value JSONB &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DEFAULT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;{}&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;updated_at &lt;/span&gt;&lt;span&gt;BIGINT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;service_name&lt;/span&gt;&lt;span&gt;, state_type, state_key)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;schema-design-decisions&quot;&gt;Schema Design Decisions&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;tagvalues&lt;/code&gt; as GENERATED ALWAYS STORED&lt;/strong&gt;: computed column, calculated once on INSERT. Feeds a GIN index for containment queries (&lt;code dir=&quot;auto&quot;&gt;WHERE tagvalues @&gt; ARRAY[&apos;&amp;#x3C;id&gt;&apos;]&lt;/code&gt;). Avoids recalculation on every query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Composite FK in &lt;code dir=&quot;auto&quot;&gt;relay_metadata&lt;/code&gt;&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;(metadata_id, metadata_type)&lt;/code&gt; references &lt;code dir=&quot;auto&quot;&gt;metadata(id, type)&lt;/code&gt;. Maintains referential integrity on the pair, not just the hash.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Composite PK in &lt;code dir=&quot;auto&quot;&gt;relay_metadata&lt;/code&gt;&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;(relay_url, generated_at, metadata_type)&lt;/code&gt;. Each relay can have many instances of each metadata type over time — it’s a time-series of health checks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;service_state&lt;/code&gt; as generic store&lt;/strong&gt;: 3-field PK &lt;code dir=&quot;auto&quot;&gt;(service_name, state_type, state_key)&lt;/code&gt;. Each service uses it differently: Finder for cursors, Validator for candidates, Monitor for last-check timestamps, Synchronizer for sync cursors. Zero schema migration when adding a new state type.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;stored-procedures-25&quot;&gt;Stored Procedures (25)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Level 1 — Single operations&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;relay_insert()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;event_insert()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;metadata_insert()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;service_state_upsert()&lt;/code&gt;. All accept bulk arrays (&lt;code dir=&quot;auto&quot;&gt;UNNEST($1::text[], $2::text[], ...)&lt;/code&gt;) for batch insertion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Level 2 — Atomic cascades&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;event_relay_insert_cascade()&lt;/code&gt;: in a single SQL call, inserts relay (if not exists) + event (if not exists) + junction. &lt;code dir=&quot;auto&quot;&gt;DISTINCT ON (event_id, relay_url)&lt;/code&gt; for intra-batch deduplication. Atomic.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;relay_metadata_insert_cascade()&lt;/code&gt;: relay + metadata + junction in one call.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Why two levels:&lt;/strong&gt; Synchronizer can use &lt;code dir=&quot;auto&quot;&gt;event_relay_insert()&lt;/code&gt; when events already exist, or the cascade when discovering new relays with new events.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Batched cleanup&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;orphan_metadata_delete()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;orphan_event_delete()&lt;/code&gt; remove orphaned records in batches of 10,000 to limit lock duration.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;materialized-views-11&quot;&gt;Materialized Views (11)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Pre-compute aggregate statistics so the API doesn’t need expensive JOINs at runtime:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;relay_metadata_latest&lt;/code&gt; — Latest snapshot per relay/type (DISTINCT ON pattern)&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;event_stats&lt;/code&gt; — Global event counts (singleton with &lt;code dir=&quot;auto&quot;&gt;singleton_key&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;relay_stats&lt;/code&gt; — Per-relay stats with LATERAL join for average RTT from last 10 measurements&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;kind_counts&lt;/code&gt; — Distribution by event kind&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;kind_counts_by_relay&lt;/code&gt; — Distribution by relay&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;pubkey_counts&lt;/code&gt; — Global author activity&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;pubkey_counts_by_relay&lt;/code&gt; — Per-relay author activity (HAVING COUNT &gt;= 2)&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;network_stats&lt;/code&gt; — Aggregate by network type&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;relay_software_counts&lt;/code&gt; — Relay software distribution (from NIP-11)&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;supported_nip_counts&lt;/code&gt; — NIP adoption (CROSS JOIN LATERAL jsonb_array_elements)&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;event_daily_counts&lt;/code&gt; — Daily time-series (UTC dates)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Refresh strategy&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY&lt;/code&gt; requires a unique index on each view. The Refresher updates them in dependency order (3 levels).&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;indexes-31&quot;&gt;Indexes (31)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Strategically placed to support the query patterns of each service:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;7 on &lt;code dir=&quot;auto&quot;&gt;event&lt;/code&gt;&lt;/strong&gt;: timeline (DESC), by kind, by author, compound (author + kind + timeline), GIN on tagvalues, cursor-based pagination (ASC)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 on &lt;code dir=&quot;auto&quot;&gt;event_relay&lt;/code&gt;&lt;/strong&gt;: by relay, by timestamp, compound for index-only scan&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 on &lt;code dir=&quot;auto&quot;&gt;relay_metadata&lt;/code&gt;&lt;/strong&gt;: by timestamp, compound FK, latest-per-type&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 on &lt;code dir=&quot;auto&quot;&gt;service_state&lt;/code&gt;&lt;/strong&gt;: by service, by type, expression index on JSON for Validator&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;15 on materialized views&lt;/strong&gt;: unique indexes required for CONCURRENTLY&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;deployment-infrastructure&quot;&gt;Deployment Infrastructure&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;docker-13-containers-on-2-networks&quot;&gt;Docker: 13 Containers on 2 Networks&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# Network isolation&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;networks&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;bigbrotr-data-network&lt;/span&gt;&lt;span&gt;:        &lt;/span&gt;&lt;span&gt;# PostgreSQL + services&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;bigbrotr-monitoring-network&lt;/span&gt;&lt;span&gt;:  &lt;/span&gt;&lt;span&gt;# Prometheus + Grafana + AlertManager&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;





































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Container&lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;postgres&lt;/td&gt;&lt;td&gt;Primary database (PostgreSQL 16)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;pgbouncer&lt;/td&gt;&lt;td&gt;Connection pooling (transaction mode)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;tor&lt;/td&gt;&lt;td&gt;SOCKS5 proxy for .onion relays&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;seeder&lt;/td&gt;&lt;td&gt;Bootstrap (one-shot, &lt;code dir=&quot;auto&quot;&gt;restart: no&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;finder&lt;/td&gt;&lt;td&gt;Relay discovery&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;validator&lt;/td&gt;&lt;td&gt;WebSocket validation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;monitor&lt;/td&gt;&lt;td&gt;NIP-11 + NIP-66 health checks&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;synchronizer&lt;/td&gt;&lt;td&gt;Event archiving&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;refresher&lt;/td&gt;&lt;td&gt;Materialized view refresh&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;api&lt;/td&gt;&lt;td&gt;REST API (FastAPI)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;dvm&lt;/td&gt;&lt;td&gt;Nostr DVM (NIP-90)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;postgres-exporter&lt;/td&gt;&lt;td&gt;PostgreSQL metrics for Prometheus&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;prometheus&lt;/td&gt;&lt;td&gt;Metrics collection (30 day retention)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;alertmanager&lt;/td&gt;&lt;td&gt;Alert routing&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;grafana&lt;/td&gt;&lt;td&gt;Visualization dashboards&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Plus I2P and Lokinet as optional containers (commented out but documented for enablement).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependency graph&lt;/strong&gt;: PgBouncer depends on PostgreSQL (&lt;code dir=&quot;auto&quot;&gt;service_healthy&lt;/code&gt;). All services depend on PgBouncer + Tor (&lt;code dir=&quot;auto&quot;&gt;service_healthy&lt;/code&gt;). Grafana depends on Prometheus.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Health checks&lt;/strong&gt;: PostgreSQL uses &lt;code dir=&quot;auto&quot;&gt;pg_isready&lt;/code&gt;. BigBrotr services use &lt;code dir=&quot;auto&quot;&gt;/metrics&lt;/code&gt; (port 8000). Api uses &lt;code dir=&quot;auto&quot;&gt;/health&lt;/code&gt; (port 8080). Prometheus and Grafana use their native endpoints.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;parametric-multi-stage-dockerfile&quot;&gt;Parametric Multi-Stage Dockerfile&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A single Dockerfile serves 2 deployment variants (&lt;code dir=&quot;auto&quot;&gt;bigbrotr&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;lilbrotr&lt;/code&gt;):&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 1 (Builder)&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;python:3.11.14-slim&lt;/code&gt; base, &lt;code dir=&quot;auto&quot;&gt;uv&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;ghcr.io/astral-sh/uv:0.10.2&lt;/code&gt; (fast, deterministic build tool), layer caching with dependencies separated from source code, BuildKit cache mounts to avoid re-downloading.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Stage 2 (Production)&lt;/strong&gt;: non-root user (UID 1000, name = deployment), runtime-only libraries (libpq5, libsecp256k1-dev), pip/setuptools removed from base image (reduced attack surface), &lt;code dir=&quot;auto&quot;&gt;tini&lt;/code&gt; as PID 1 (proper signal handling, no zombie processes), explicit &lt;code dir=&quot;auto&quot;&gt;STOPSIGNAL SIGTERM&lt;/code&gt;, config copied from specific deployment.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;pgbouncer&quot;&gt;PgBouncer&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Connection pooler in front of PostgreSQL. Connection multiplexing to reduce DB load. Password templating via custom entrypoint. 300s safety net timeout.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;monitoring-stack&quot;&gt;Monitoring Stack&lt;/h3&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Prometheus&lt;/strong&gt; (v2.51.0): scraping every 30s from all services + postgres-exporter. 30-day TSDB retention. 7 alert rules covering service health, failure rates, cycle performance, database connections, cache hit ratios, and view refresh failures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AlertManager&lt;/strong&gt; (v0.27.0): grouping by &lt;code dir=&quot;auto&quot;&gt;alertname&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;service&lt;/code&gt;. Critical alerts repeated every 1h, normal every 4h.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grafana&lt;/strong&gt; (10.4.1): automatic datasource + dashboard provisioning. Zero manual setup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;postgres-exporter&lt;/strong&gt; (v0.16.0): PostgreSQL metrics (connections, cache hit ratio, table stats).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;cicd-pipeline&quot;&gt;CI/CD Pipeline&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;github-actions-306-lines&quot;&gt;GitHub Actions (306 lines)&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Trigger: push/PR on main/develop (excludes docs, README)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;┌─────────────────────────────────────────────┐&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ pre-commit (10 min)                         │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;│ ruff, mypy, pre-commit hooks                │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;└──────────────┬──────────────────────────────┘&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;┌───────┼────────────────────────────────┐&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│       │                                │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;▼       ▼                                ▼&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;unit-test   integration-test               docs&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(matrix     (testcontainers PG)            (mkdocs&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;3.11-3.14)  (10 min)                      --strict)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(15 min)                                   (5 min)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(codecov)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│       │                                │&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;└───────┼────────────────────────────────┘&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;▼&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span&gt;build (main/develop only)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(matrix: bigbrotr/lilbrotr)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(Trivy security scan)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(SARIF → GitHub Security)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(20 min)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;│&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;               &lt;/span&gt;&lt;/span&gt;&lt;span&gt;▼&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ci-success (gate for branch protection)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div aria-live=&quot;polite&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Key details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unit test matrix&lt;/strong&gt;: Python 3.11, 3.12, 3.13, 3.14 (pre-release, allowed to fail)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQL drift detection&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;tools/generate_sql.py --check&lt;/code&gt; verifies SQL templates match generated files&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency audit&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;uv-secure uv.lock&lt;/code&gt; for known vulnerabilities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trivy scan&lt;/strong&gt;: gates on CRITICAL/HIGH, reports MEDIUM. SARIF uploaded to GitHub Security tab.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency&lt;/strong&gt;: one run per branch; new pushes cancel previous runs.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h3 id=&quot;pre-commit-hooks-23-hooks&quot;&gt;Pre-commit Hooks (23 hooks)&lt;/h3&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;File hygiene&lt;/strong&gt;: trailing-whitespace, end-of-file-fixer, check-yaml/json/toml, check-large-files (max 1MB), detect-private-key, mixed-line-ending (LF)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python&lt;/strong&gt;: ruff (lint + format), mypy (strict on src/)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQL&lt;/strong&gt;: sqlfluff (PostgreSQL dialect)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt;: detect-secrets with baseline&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependency&lt;/strong&gt;: uv-lock (verifies pyproject.toml ↔ uv.lock sync)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker&lt;/strong&gt;: hadolint&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Markdown&lt;/strong&gt;: markdownlint-cli&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spell check&lt;/strong&gt;: codespell&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;recurring-architectural-patterns&quot;&gt;Recurring Architectural Patterns&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A few patterns show up everywhere in the codebase. Recognizing them makes it easier to understand any part of the system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content-Addressed Deduplication&lt;/strong&gt;: identical data → same SHA-256 hash → one row in the DB. Trades CPU (Python hashing) for reduced storage and disk I/O. The bottleneck is network, not CPU, so this is a good trade.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cascade Atomicity&lt;/strong&gt;: stored procedures that insert across multiple tables in a single SQL call. Referential integrity guaranteed even on connection failure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Never-Raising Factories&lt;/strong&gt;: NIP fetch methods always return a result (with &lt;code dir=&quot;auto&quot;&gt;logs.success&lt;/code&gt; indicating success/failure). Enables batch processing without individual try/catch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Interruptible Wait&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;asyncio.wait_for(event.wait(), timeout)&lt;/code&gt; for cycles with immediate shutdown. Never bare &lt;code dir=&quot;auto&quot;&gt;asyncio.sleep()&lt;/code&gt; (not interruptible).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-Network Semaphores&lt;/strong&gt;: different concurrency for clearnet, Tor, I2P, Lokinet. Configurable, zero magic numbers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cooperative Multiple Inheritance&lt;/strong&gt;: mixins with &lt;code dir=&quot;auto&quot;&gt;super().__init__(**kwargs)&lt;/code&gt; via MRO. No manual &lt;code dir=&quot;auto&quot;&gt;_init_*()&lt;/code&gt; calls.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layered Timeouts&lt;/strong&gt;: asyncpg → PgBouncer → PostgreSQL. Three independent levels, the tightest wins.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Coroutine Factory for Retry&lt;/strong&gt;: Python coroutines are single-use. Retry creates a fresh coroutine on each attempt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Declarative Parsing&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;FieldSpec&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;parse_fields()&lt;/code&gt; for type-safe parsing of untrusted JSON. Silent failure for invalid fields.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ground Truth &gt; Self-Report&lt;/strong&gt;: NIP-66 probe results are authoritative over NIP-11 relay self-reports.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;conscious-trade-offs&quot;&gt;Conscious Trade-offs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every architectural decision is a trade-off. Here are the ones that mattered most and why we landed where we did:&lt;/p&gt;





















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Decision&lt;/th&gt;&lt;th&gt;Upside&lt;/th&gt;&lt;th&gt;Downside&lt;/th&gt;&lt;th&gt;Reasoning&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;DB as inter-service channel&lt;/td&gt;&lt;td&gt;Decoupling, fault isolation, observability&lt;/td&gt;&lt;td&gt;Higher latency than RPC&lt;/td&gt;&lt;td&gt;Not real-time; 1-5 min cycles are fine&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Content-addressed dedup&lt;/td&gt;&lt;td&gt;Less storage, less disk I/O&lt;/td&gt;&lt;td&gt;CPU for hashing in Python&lt;/td&gt;&lt;td&gt;Network is the bottleneck, not CPU&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Materialized views&lt;/td&gt;&lt;td&gt;Fast API queries, zero JOINs at runtime&lt;/td&gt;&lt;td&gt;Slightly stale data (periodic refresh)&lt;/td&gt;&lt;td&gt;Analytics don’t need real-time data&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Frozen dataclasses&lt;/td&gt;&lt;td&gt;Correctness, thread-safety, stable hash&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;object.__setattr__&lt;/code&gt; verbosity&lt;/td&gt;&lt;td&gt;Correctness &gt; ergonomics&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Stored procedures for mutations&lt;/td&gt;&lt;td&gt;Atomicity, fewer roundtrips, logic in DB&lt;/td&gt;&lt;td&gt;More complex SQL, two languages&lt;/td&gt;&lt;td&gt;Acceptable trade for data integrity&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ws://&lt;/code&gt; for overlay, &lt;code dir=&quot;auto&quot;&gt;wss://&lt;/code&gt; for clearnet&lt;/td&gt;&lt;td&gt;Overlay handles encryption; no TLS overhead&lt;/td&gt;&lt;td&gt;Non-uniform scheme&lt;/td&gt;&lt;td&gt;Semantically correct: TLS is redundant on Tor&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;No message queue&lt;/td&gt;&lt;td&gt;Less infrastructure to maintain&lt;/td&gt;&lt;td&gt;No native backpressure&lt;/td&gt;&lt;td&gt;DB + semaphores are sufficient for this workload&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;by-the-numbers&quot;&gt;By the Numbers&lt;/h2&gt;&lt;/div&gt;

























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Dimension&lt;/th&gt;&lt;th&gt;Value&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Source code&lt;/td&gt;&lt;td&gt;~18,000 lines Python, 88 modules&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tests&lt;/td&gt;&lt;td&gt;~2,739 unit + ~216 integration (~2,955 total)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SQL&lt;/td&gt;&lt;td&gt;1,759 lines, 10 init files&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Services&lt;/td&gt;&lt;td&gt;8 independent&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Stored procedures&lt;/td&gt;&lt;td&gt;25&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Materialized views&lt;/td&gt;&lt;td&gt;11&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Indexes&lt;/td&gt;&lt;td&gt;31 (16 table + 15 matview)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Docker containers&lt;/td&gt;&lt;td&gt;13 (+ 2 optional)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Core dependencies&lt;/td&gt;&lt;td&gt;18&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Dev dependencies&lt;/td&gt;&lt;td&gt;18&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Pre-commit hooks&lt;/td&gt;&lt;td&gt;23&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Coverage&lt;/td&gt;&lt;td&gt;80% branch coverage (enforced)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;closing-thoughts&quot;&gt;Closing Thoughts&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr started as “let’s index some Nostr relays” and turned into a distributed system with 8 services, 25 stored procedures, 11 materialized views, and a monitoring stack that could probably run a small startup.&lt;/p&gt;
&lt;p&gt;The most important lesson was resisting the urge to solve future problems today. The forward-compatible schema wasn’t designed on day one — it evolved from real pain points when adding the second and third metadata type. The mixin system emerged when Monitor’s &lt;code dir=&quot;auto&quot;&gt;__init__&lt;/code&gt; hit 40 lines. The configurable deployments came from actually needing two different setups.&lt;/p&gt;
&lt;p&gt;Every pattern in this codebase exists because of a real problem, not because it looked good on a whiteboard. That’s the difference between architecture and architecture astronautics.&lt;/p&gt;
&lt;p&gt;If you want to explore the code, it’s all open source. Questions, feedback, and contributions are welcome.&lt;/p&gt;</content:encoded><category>architecture</category><category>deep-dive</category><category>nostr</category></item><item><title>Welcome to BigBrotr</title><link>https://bigbrotr.com/blog/welcome/</link><guid isPermaLink="true">https://bigbrotr.com/blog/welcome/</guid><description>Introducing BigBrotr — a distributed relay observatory for the Nostr network built with Python and PostgreSQL.

</description><pubDate>Thu, 15 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;BigBrotr is a relay observatory for the Nostr network. It discovers relays across four networks, monitors their health with NIP-11 and NIP-66 checks, archives events, pre-computes analytics through materialized views, and exposes everything through a REST API and a native Nostr Data Vending Machine.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-we-built&quot;&gt;What We Built&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;BigBrotr tackles the Nostr network through five pillars:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discovery&lt;/strong&gt; — find relays across clearnet, Tor, I2P, and Lokinet using seed files, external APIs, and event tag mining&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring&lt;/strong&gt; — 7 health checks per relay: NIP-11 info fetch, RTT latency (WebSocket open/read/write), SSL certificate inspection, DNS records, IP geolocation, network/ASN info, and HTTP headers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Archiving&lt;/strong&gt; — cursor-based event synchronization with binary-split windowing for completeness verification&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Analytics&lt;/strong&gt; — 11 materialized views pre-computing aggregate statistics refreshed concurrently by a dedicated service&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data Access&lt;/strong&gt; — REST API (FastAPI) for HTTP clients and a NIP-90 Data Vending Machine for native Nostr clients over WebSocket&lt;/li&gt;
&lt;/ol&gt;
&lt;div&gt;&lt;h2 id=&quot;architecture&quot;&gt;Architecture&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The system follows a diamond DAG dependency structure — imports flow strictly downward, enforced by linter rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;models&lt;/strong&gt; — pure frozen dataclasses with fail-fast validation, content-addressed deduplication (SHA-256), zero I/O&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;core&lt;/strong&gt; — asyncpg connection pool with retry/backoff, database facade (Brotr) wrapping 25 stored procedures, base service with lifecycle management&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;nips&lt;/strong&gt; — NIP-11 relay information fetch with graceful degradation, NIP-66 health monitoring (6 concurrent tests per relay)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;utils&lt;/strong&gt; — DNS resolution, HTTP/WebSocket transport with SSL fallback, Nostr key management, relay protocol helpers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;services&lt;/strong&gt; — eight independent services communicating exclusively through PostgreSQL&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;eight-services&quot;&gt;Eight Services&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Each service runs independently — no direct service-to-service dependencies, all communication via the database:&lt;/p&gt;


















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Service&lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;th&gt;Description&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Seeder&lt;/td&gt;&lt;td&gt;Bootstrap&lt;/td&gt;&lt;td&gt;One-shot: loads relay URLs from seed files as candidates&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Finder&lt;/td&gt;&lt;td&gt;Discovery&lt;/td&gt;&lt;td&gt;Discovers relays from external APIs and mines URLs from archived event tags&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Validator&lt;/td&gt;&lt;td&gt;Validation&lt;/td&gt;&lt;td&gt;Tests WebSocket connectivity, promotes candidates to validated relays&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Monitor&lt;/td&gt;&lt;td&gt;Health&lt;/td&gt;&lt;td&gt;NIP-11 info + 6 NIP-66 tests per relay, publishes results to the Nostr network&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Synchronizer&lt;/td&gt;&lt;td&gt;Archiving&lt;/td&gt;&lt;td&gt;Cursor-based event collection with binary-split windowing and per-relay progress&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Refresher&lt;/td&gt;&lt;td&gt;Analytics&lt;/td&gt;&lt;td&gt;Refreshes 11 materialized views concurrently in dependency order&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Api&lt;/td&gt;&lt;td&gt;HTTP access&lt;/td&gt;&lt;td&gt;FastAPI REST API with automatic schema discovery, filtering, sorting, pagination&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Dvm&lt;/td&gt;&lt;td&gt;Nostr access&lt;/td&gt;&lt;td&gt;NIP-90 Data Vending Machine: responds to Nostr job requests over WebSocket&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get Started&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://bigbrotr.com/docs/getting-started/quick-start/&quot;&gt;Quick Start guide&lt;/a&gt; to run BigBrotr with Docker Compose, or read the &lt;a href=&quot;https://bigbrotr.com/blog/inside-bigbrotr/&quot;&gt;full technical deep dive&lt;/a&gt; for a comprehensive look at the architecture and design decisions.&lt;/p&gt;
&lt;p&gt;The full source code is available on &lt;a href=&quot;https://github.com/BigBrotr/bigbrotr&quot;&gt;GitHub&lt;/a&gt; under the MIT license.&lt;/p&gt;</content:encoded><category>announcement</category></item></channel></rss>