pijn — pijn is just nostr.
A self-hostable decentralized web where identities own signed content, users choose how much of the network they support, and a "website" is just one way of rendering that content. Built on Nostr, not beside it.
Pijn is plumbing for Nostr content, not a new network.
Status: v0.4.0 — P4 (transport) is in, the cautious way. Outbound connections (replication, discovery, announce) can route over Tor (SOCKS), per node or per mirrored site, and reach
.onionpeers; local targets always stay direct. Inbound is opt-in and read-only first: a node can publish only its gateway as a hidden service (inbound_onion: gateway), with the writable relay/blob ports exposed solely under an explicitall. This builds on v0.3.4's hardening — audited cryptography (libsecp256k1 viacoincurve; at-rest nsec via pyca/cryptography), enforced moderation, and the Markdown-XSS / replication-SSRF fixes. No on-disk changes — existing identities and stores carry over. SeeCHANGELOG.mdfor the full list,SPEC.mdfor the frozen architecture, androadmap.mdfor the plan.
Most decentralized-web projects die of the cold-start problem: they seed an empty network and wait for users who never come (ZeroNet, OpenBazaar, …). pijn refuses to start cold. Every layer rides a real, published Nostr spec, so existing relays, Blossom servers, clients, and gateways understand pijn content the day it ships. We join a populated network instead of building a lonely one.
The model is closer to BitTorrent than to "the cloud." Content survives because someone chooses to seed it — not because a publisher decreed it permanent. Each participant decides, in one YAML file, how much storage and bandwidth to give, which sites to mirror, and whether to route over Tor.
A pubkey owns events (signed JSON, mutable, on relays). Events point — by
sha256 — at blobs (immutable bytes, on Blossom servers). A "site" is just a
manifest event mapping paths to blob hashes; updating it means re-signing a new
manifest. Mutability lives entirely in events; permanence lives in blobs. That
single split is the whole data model (SPEC.md §3).
Some sites aren't blob-manifests at all. A blog is a manifest tagged
app=blog with no paths: the gateway projects the owner's long-form events
(kind 30023) into pages on the fly. The website is one rendering of the content;
the same events render in any NIP-23 client too.
| Layer | Rides on |
|---|---|
| Identity, signing, names | Nostr keys (NIP-01), NIP-05 |
| Events & discovery | Nostr relays (NIP-01), NIP-50 search, NIP-65 relay lists |
| Immutable bytes | Blossom (sha256 blobs, kind 24242 auth) |
| The "site" | nsite / NIP-5A (kinds 15128 root, 35128 named) |
| Apps | forum 34550 / NIP-7D · blog 30023 · wiki 30818 · shop 30017/30018 |
| Payments | NIP-57 zaps (Lightning) · Cashu NIP-87/60/61 |
| Rendering | local resolver → localhost; clearweb via njump |
Where pijn fits — honestly, in both directions. No single component here is
unprecedented, and pijn says so: deploying a static site to Blossom + relays is
what nsyte does (down to the same
<npub>.localhost resolver scheme), and "one box that is a relay and a
Blossom server" is what HAVEN, HORNET Storage, and Route96 already are. The
publishing tools and templates are convenience, not a moat — and pretending
otherwise would be dishonest.
But the combination is what's missing from the ecosystem. Nobody else puts all of this behind one identity and one policy file: publish a site/blog, run your own relay + Blossom, and choose — per site, in one YAML — to mirror other people's whole sites and keep them reachable when the author is offline, with storage caps, eviction, bandwidth budgets, knowing moderation, and (P7) Cashu paid pinning, all reachable over Tor. Blossom's own mirroring is publisher-push (you replicate your own blobs); pijn is seeder-pull-by-policy (I choose to keep your site alive). That user-controlled, third-party seeding layer is the part no one has really built.
The closest ancestors are the older "permanent web" systems, and the contrast is the point. ZeroNet is essentially abandoned. Freenet/Hyphanet and GNUnet are alive but notoriously hard to actually find working content or peers on, and — crucially — they decide for you what your node stores (content is scattered/encrypted across the swarm by the protocol). pijn inverts that last property: you decide exactly what you carry, by npub and by item, and you can always answer "I won't host that." It also doesn't bootstrap a lonely new network — it rides the populated Nostr/Blossom one, which is what sank the cold-start projects above. So: modest about the pieces, but as a whole, pijn is trying to occupy a space nothing else currently does.
One daemon, four independent processes — run together (default) or each alone:
- event-store — a minimal Nostr relay (WebSocket + filter queries over SQLite)
- blob-store — a minimal Blossom server (PUT/GET/HEAD/list by sha256)
- gateway / resolver — turns a pubkey + identifier into a rendered site; owns no state, it's a pure projection
- transport — Direct or Tor (SOCKS out, optional
.onionin)
A replication controller (P3) reads your policy and drives the two stores — mirroring chosen sites, enforcing storage caps with file-level partial seeding, and honoring bandwidth budgets.
- Interoperate with mainline Nostr; never fork the protocol. Sanity-check every feature against: does vanilla Nostr software still understand this?
- Assemble, don't invent. The application layer already exists as NIPs.
- Replication is user-controlled, not publisher-controlled. You decide storage, bandwidth, which sites to seed, and privacy.
- Honest persistence. Content lives iff someone seeds it. Paid pinning (Cashu) is the escape valve for content lacking organic seeders.
- Knowing, accountable operators. No blind/encrypted-unknowable storage in v1. Moderation is layered, always knowing, and enforced (as of v0.3.4) on what this node ingests, accepts as an upload, and replicates: an opt-in/opt-out base, npub allow/block lists, and per-item overrides — carry one piece you like even if its author isn't whitelisted, or is blacklisted (the latter with a warning). "I won't carry that," at any granularity.
- Audited crypto, no build step. Signatures use libsecp256k1 (
coincurve); the at-rest key uses pyca/cryptography. Both are wheels — no compiler — with a pure-Python fallback only for platforms lacking one. - House stack. FastAPI + SQLite + vanilla JS. No npm, no build step, local-first. Templates are static + vanilla JS.
Configuration is a single YAML policy file (schema in
SPEC.md §4); copy the example to start, then start the daemon:
pip install -r requirements.txt
cp policy.example.yaml policy.yaml
python -m pijn keygen # create your identity (encrypts the nsec; asks a passphrase)
python -m pijn run # start relay :4848, blob-store :4849, gateway :4850All data lives under ~/.pijn/<npub>/ (relay db, blobs, state), so reinstalling
the code never touches it; set PIJN_HOME to relocate it. Your nsec is stored
encrypted (~/.pijn/<npub>/nsec.enc) and unlocked with your passphrase when a
signing command needs it — or set PIJN_PASSPHRASE for automation, or bypass with
a plaintext PIJN_NSEC. The npub is plaintext (public), so pubkey needs no
passphrase, and the daemon runs unattended — only publish/post/blog/
announce ask for the passphrase. pijn identities lists your keys (--use switches).
keygen is interactive: if an identity already exists it offers to keep it
(unlocking and verifying the key works) or overwrite it, and when making a new one
it asks whether to import an existing nsec (checked before it's saved) or generate
a fresh key. Use keygen --force to non-interactively overwrite with a new key.
Each identity (npub) can own one root site plus any number of named
sites, told apart by an identifier (a slug like blog or notes). Every site
is served from its own origin, so its links — relative and root-absolute like
/style.css — resolve to that site and nothing else:
| canonical origin | also reachable at | |
|---|---|---|
| root site | http://<npub>.localhost:4850/ |
/n/<npub>/ (redirects here) |
| named site | http://<id>.<npub>.localhost:4850/ |
http://localhost:4850/s/<npub>/<id>/ |
For local browsing, prefer the <npub>.localhost form for the root site and
the /s/<npub>/<id>/ path form for named sites — the path form always works,
whereas the nested <id>.<npub>.localhost subdomain depends on your browser
resolving multi-level .localhost names (Chrome does; some setups don't). The
nested subdomain is the canonical address (it's what a clearweb gateway uses in
P5), but the path form is the dependable local route. Use localhost, not the
bare 127.0.0.1.
One root manifest per identity. A root site and a root blog occupy the same slot (both are kind-15128, keyed only by your pubkey), so publishing one replaces the other. If you want both a site and a blog under one key, make at least one of them a named site with
--identifier(see below).
In another shell:
python -m pijn init mysite --title "My Site" # scaffold a starter (relative links)
# edit mysite/*.html, then publish as your ROOT site:
python -m pijn publish mysite --title "My Site"
# -> browse: http://<npub>.localhost:4850/
# or publish the bundled demo (it uses root-absolute links, so view it via the subdomain):
python -m pijn publish examplesite --title "demo"Re-running publish is the update path: a fresh manifest is signed and the old
one superseded — no blob is ever mutated. Use --server <url> to upload blobs to
an external Blossom host instead of this node.
Give it an --identifier. It does not touch your root site:
python -m pijn init notes --title "Notes"
python -m pijn publish notes --identifier notes --title "Notes"
# -> browse locally: http://localhost:4850/s/<npub>/notes/
# canonical: http://notes.<npub>.localhost:4850/A blog is long-form posts (NIP-23, kind 30023) projected into a site — the
gateway renders your posts on the fly and stores no HTML, and the same posts are
readable by any NIP-23 client (Habla, Yakihonne, …). Two steps: write posts with
post, and mark an origin as a blog once with blog.
Because of the one-root rule above, the common case is a named blog (so it coexists with your root site):
python -m pijn init myblog --template blog --title "Field Notes"
python -m pijn post myblog/first-post.md --summary "Hello" # signs a kind-30023 event
python -m pijn blog --title "Field Notes" --identifier blog # mark a NAMED origin as a blog
# -> read it locally: http://localhost:4850/s/<npub>/blog/
# each post at: http://localhost:4850/s/<npub>/blog/<slug>If instead you want the blog to be your main site, omit --identifier to make
it your root blog (this replaces any root static site):
python -m pijn blog --title "Field Notes" # ROOT blog at http://<npub>.localhost:4850/Editing a post and re-running post (same slug) supersedes the old version.
python -m pijn pubkey # print this node's npub
python -m pijn whois alice@example.com # resolve & verify a NIP-05; or pass an npub for its digestA claimed name is only ever shown beside a short, unfakeable npub digest, and a NIP-05 is marked verified only after its domain actually vouches (SPEC §7).
This node can mirror other people's sites and keep them reachable when their
author is offline. List them under sites: in your policy (and point
relays.read at a relay that carries the author's events):
relays:
read: ["wss://relay.damus.io", "ws://127.0.0.1:4848"]
sites:
- name: a-friends-blog
pubkey: npub1... # the author (npub or hex)
identifier: blog # "" = their root site; else a named site
pin: true # never evicted, ignore caps
- name: someones-wiki
pubkey: npub1...
identifier: notes
pin: false
storage_cap: 500MB # keep a subset up to this; evict the restpython -m pijn sync # mirror them now (prints a per-site report)
python -m pijn run # also keeps them refreshed in the backgroundAfter a sync, this node serves each mirrored site from its own stores — so it
stays up even if the author's node goes down. Everything pulled is verified
before it's stored (signatures on events, sha256 on blobs), so the relays and
blob servers you pull from don't have to be trusted. pin: true keeps a full
copy; otherwise storage_cap keeps a subset of files (file-level partial
seeding) and limits.storage_total caps the whole node.
Discovery. You don't have to know the author's relay: the controller reads
their NIP-65 relay list (kind 10002) and pulls from the relays it names, so one
shared/indexer relay in relays.read is enough to find them. It also looks for
other seeders of the site (kind-30888 announcements) and will pull the
manifest and blobs from them too — so a site stays reachable even if the author
is offline, as long as someone seeds it. To make yourself discoverable as an
author and to advertise the sites you seed:
python -m pijn announce # publishes your relay list + a seed announcement per seeded siteBudgets. limits.storage_total caps total disk; limits.bandwidth_day and
bandwidth_month cap how much replication downloads (metered across restarts).
When the node hits storage_total, eviction.policy decides what gives — default
manual (nothing auto-deleted; you prune), or lru to drop least-recently-stored
blobs; protect_pinned: true never evicts a pinned site's blobs.
Each service can run alone (they bind separate ports):
python -m pijn run --only event_store # just the relay
python -m pijn run --only blob_store # just the Blossom server
python -m pijn run --only gateway # just the resolver/rendererYour nsec never enters the policy file or any server — it lives encrypted in
~/.pijn/<npub>/nsec.enc, is read only to sign, and is decrypted into memory only
when you run a signing command.
Tor is wired in two independent halves; you can use either, both, or neither.
Both assume a Tor daemon you run yourself (default SOCKS 127.0.0.1:9050).
Outbound — hide your IP, reach .onion peers. Set transport.default: tor
to route this node's outbound relay/blob connections (replication, discovery,
announce) through Tor; override per mirrored site with sites[].transport: direct|tor. Local 127.0.0.1 targets are never tunnelled. Hostnames (including
.onion) resolve inside Tor (socks5h), so your resolver never sees them.
transport:
default: tor
tor: { socks: "127.0.0.1:9050" }
sites:
- { name: a-blog, pubkey: npub1..., identifier: blog, transport: tor }Inbound — be reachable as a hidden service, cautiously. This is opt-in and
read-only first. inbound_onion: gateway publishes only your gateway as an
.onion (people can browse the sites you serve; nobody can write to you). Once
you're satisfied it works, inbound_onion: all additionally exposes the writable
relay and blob ports. Inbound needs Tor's control port open (set a
HashedControlPassword in your torrc and put the password in
tor.control_password); if the control port is unreachable, pijn logs a warning
and keeps serving without an onion.
transport:
tor:
control: "127.0.0.1:9051"
control_password: "…" # matches torrc HashedControlPassword
inbound_onion: gateway # read-only; use `all` only when you trust the setupOn startup the daemon prints the .onion address and the port mapping. The
service is ephemeral — it exists only while the node runs and disappears on
exit, leaving nothing behind.
P0 spec → P1 node skeleton (relay + Blossom + resolver) → P2 publishing
tools + static/blog templates → P3 replication & seeding → P4 transport
(Tor) — done → P5 clearweb bridge → P6 forum → wiki → shop (+ Cashu,
NIP-57 zaps) → P7 tipping authors/hosts, paid pinning, escrow, chunk-level
swarm. Full detail in roadmap.md.
pijn/
├── roadmap.md # the plan
├── SPEC.md # architecture spec (what's frozen)
├── README.md # you are here
├── CHANGELOG.md # what landed in each phase
├── requirements.txt # wheel-only deps (incl. coincurve, cryptography); no build step
├── policy.example.yaml # policy template (SPEC §4)
├── examplesite/ # a tiny static site for the publish demo
└── pijn/ # the daemon package
├── __main__.py # CLI: run/keygen/pubkey/identities/whois/init/publish/blog/post/sync/announce
├── app.py # service composition + daemon runner
├── policy.py # YAML policy loader (data paths under ~/.pijn/<npub>/)
├── keystore.py # identity/data home; encrypted-key load/save
├── publish.py # static-site publisher (content-typed blobs + manifest)
├── post.py # blog authoring: kind-30023 posts + app=blog manifest
├── templates.py # `init` scaffolds (static site, blog starter)
├── replication.py # P3: mirror configured sites into the local stores
├── discovery.py # P3: NIP-65 relay discovery + seeder announce/discovery
├── eviction.py # P3: storage-cap eviction strategies (manual/lru/popularity)
├── bandwidth.py # P3: persisted daily/monthly download budget
├── moderation.py # SPEC §4 accept/refuse, enforced on ingest + replication
├── netguard.py # SSRF guard for network-discovered blob servers/relays
├── nostr/ # Nostr core: schnorr (libsecp256k1 via coincurve, with
│ # a pure-Python fallback), bech32, keys, events,
│ # filters, nsite manifests, nip05, display, cipher
│ # (pyca/cryptography ChaCha20-Poly1305 for the nsec)
├── event_store/ # SQLite persistence + NIP-01 WebSocket relay
├── blob_store/ # content-addressed storage + Blossom HTTP + 24242 auth
├── client/ # outbound relay (WS) + Blossom (HTTP) clients (SOCKS-aware)
├── transport/ # P4: outbound Tor (SOCKS) + inbound hidden service
│ # (config.py selection; tor.py control-port ADD_ONION)
└── gateway/ # resolver + per-site-origin renderer; blog projection
# (blog.py) and a safe Markdown subset (markdown.py)
Active eviction, bandwidth budgets, and seeder discovery finished P3 in 0.3.x; audited crypto + enforced moderation + the XSS/SSRF fixes landed in 0.3.4; transport (outbound Tor + cautious inbound onion) landed in 0.4.0.