A place to write, publish and discover technical specifications, built on Nostr.
Any protocol, format or convention can be published here. Nostr is the identity and storage layer, not the subject matter: documents are ordinary Nostr events, signed by their author and stored on relays. No account is issued by this project and no content is owned by it.
Relays are the source of truth. The backend is an accelerator, never a dependency.
The server renders pages, indexes content and serves search, but every document lives on relays as a signed event. If this project's servers disappear, the client keeps working against relays directly, and a fully static build of the same codebase can be hosted by anyone.
Three rules follow from this, and they are load bearing:
- No server writes. Every mutation is a Nostr event signed in the browser and
published straight to relays. The backend only observes. There is no
actionexport anywhere in the app. - No server-only state. Anything that matters (approvals, forks, status, revisions) must exist as a Nostr event. The database is a recomputable projection, never an origin.
- The backend is swappable. Its URL is a user setting, and an empty value is valid and means relays only.
The main gap Open Specs closes is discoverability. Pages are server rendered so that crawlers and link unfurlers see real HTML, with per-document metadata, dynamic OpenGraph images, JSON-LD, a sitemap and feeds.
Canonical URLs are built on npub rather than NIP-05, because an npub is the identity itself: it cannot expire and cannot be reassigned by a domain owner.
/spec/npub1.../custom-xyz-events canonical
/spec/alice@example.com/custom-xyz-events 301 to the canonical form
/naddr1... 301 to the canonical form
An author is one of those URLs too, at the root of the site rather than under a heading of this project's choosing, because an npub is the identity itself and not a category a site files someone under. Nothing else can sit there: every path this app serves is a word, and no npub is one.
/npub1... the author, and everything they signed
/alice@example.com 301 to the canonical form
/nprofile1... 301 to the canonical form
A specification is an addressable event of kind 30817, holding Markdown in content,
identified by its d tag. Discussion, curation and annotation reuse standard NIPs rather
than inventing anything: NIP-22 comments, NIP-32 labels, NIP-84 highlights and NIP-37
encrypted drafts.
Nothing in the schema is Nostr specific. A spec about Nostr declares the event kinds it
covers with k tags; a spec about anything else simply omits them. Both are ordinary
events, and any client can read either.
This schema is read as it already exists rather than redefined here. Anything the interface needs that the schema does not carry is derived from the document itself instead of being mandated as a new tag.
Two other sites render these documents, nostrhub.io and better-nips, and both carry the conversation about them. A comment written here appears there, and theirs appears here, because the events are the same events: NIP-22 comments, NIP-25 reactions and NIP-57 zaps, in the exact shape those two already publish.
The reader signs in a browser extension, a remote signer or with a key kept on this device, and every event goes from their browser straight to relays. This project holds no account and signs nothing.
React Router (framework mode) for SSR, TypeScript, Tailwind. Routes read specifications from relays directly, with a short lived cache. Postgres, Meilisearch and a relay indexer arrive later, once there is something to gain from them.
pnpm install
pnpm dev| Command | Description |
|---|---|
pnpm dev |
Dev server with HMR |
pnpm build |
Production build |
pnpm start |
Serve the production build |
pnpm typecheck |
Type check every workspace package |
pnpm test |
Run tests |
pnpm lint |
Lint and format check |
pnpm format |
Apply lint and format fixes |
Every push to main publishes a multi-architecture image to
ghcr.io/nogringo/openspecs/web, but only after CI has started that image and read its
response, so a container that does not boot or does not server render never ships.
The compose file is a single service listening on loopback, with no proxy of its own, so it drops into whatever stack you already run:
cd infra
cp .env.example .env
docker compose up -dPoint your existing reverse proxy at 127.0.0.1:3000, or set OPENSPECS_BIND to publish
the port elsewhere. If the host has no proxy, an overlay adds Caddy with automatic TLS:
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -dTo build from this checkout instead of pulling the published image, overlay the build definition the same way:
docker compose -f docker-compose.yml -f docker-compose.build.yml up --buildA document lives exactly as long as some relay keeps it, and no relay owes anyone
that. The crawler in apps/crawler reads every document from the relays this app
queries and copies what is missing to others, over NIP-77 so only what a relay
lacks travels. Copying needs no key and no account: a document carries its
author's signature, and every relay checks it.
Running one has nothing to do with hosting the site, and needs no port open:
cd infra
docker compose -f docker-compose.crawler.yml up -dThe useful part is naming your own relay, in OPENSPECS_CRAWLER_MIRRORS. A
corpus copied by many operators is one none of them can decide to drop.
Copying keeps documents alive but not their history: a document is an addressable
event, relays keep one revision of one, and an edit erases what it said before.
A crawler given a key in OPENSPECS_CRAWLER_ARCHIVIST_KEY also archives, in the
sense of kind 1349: each revision it reads is wrapped in a regular
event, which relays keep. The key signs the wrapper and proves only who archived
a version, since the document inside keeps its author's own signature.
apps/web React Router app, SSR and static builds
apps/crawler Dart service copying documents to mirror relays
packages/ shared contracts and implementations (from lot 1 on)
infra/ Docker Compose, reverse proxy
See ROADMAP.md for what is built and in which order.