Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pijn

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 .onion peers; 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 explicit all. This builds on v0.3.4's hardening — audited cryptography (libsecp256k1 via coincurve; 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. See CHANGELOG.md for the full list, SPEC.md for the frozen architecture, and roadmap.md for the plan.


The idea

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.

How it fits together

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.

Node architecture

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 .onion in)

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.

Principles

  • 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.

Running it

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 :4850

All 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.

Addressing — root sites and named sites

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).

Publish a static site

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.

Publish a named site (a second site under the same key)

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/

Publish a blog

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.

Names

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 digest

A 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).

Seed someone else's site

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 rest
python -m pijn sync          # mirror them now (prints a per-site report)
python -m pijn run           # also keeps them refreshed in the background

After 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 site

Budgets. 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.

Running services separately

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/renderer

Your 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.

Privacy & Tor (P4)

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 setup

On 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) — doneP5 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.

Repository layout

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.

About

pijn (pijn is just nostr) builds a distributed web on the nostr protocol

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages