Open-source events for Nostr communities, built with Bitcoin and Lightning payments.
NostrLab is an open-source event platform for communities that want portable identity, public discovery, and censorship-resistant distribution. Identity is your Nostr key. Events, RSVPs, communities, and ticket proofs are signed Nostr events. Postgres is an index/cache for fast product flows, not the source of identity or event ownership. Paid tickets use Lightning; NostrLab does not custody sats.
| Layer | Tech |
|---|---|
| App | Next.js 15 (App Router), React 19, TypeScript, Tailwind |
| Identity | NIP-07 browser signers and NIP-46 Nostr Connect / bunker signers — no nsec import |
| Storage | PostgreSQL via Prisma — used as a search index, not an identity store |
| Nostr | nostr-tools (server + client SimplePool) |
| Lightning | LNURL-pay through organizer Lightning Addresses; mock and none modes for local/dev |
| Tickets | Random-secret ticket payloads; check-in is organizer-signed |
| Kind | Purpose |
|---|---|
31923 |
NIP-52 time-based event listing (parameterized replaceable; d tag = stable slug) |
31925 |
RSVP (status tag: accepted / tentative / declined / waitlist) |
31926 |
Ticket proof with hashed ticket secret and paid-ticket settlement evidence |
34550 |
NIP-72 community definition used for interoperable community calendars and host access |
We deliberately do not publish invoices, ticket secrets, Lightning preimages, or buyer PII to relays.
# 1. Install
pnpm install # or npm install / yarn
# 2. Configure
cp .env.example .env
# fill DATABASE_URL, NOSTRLAB_SESSION_SECRET, and NOSTR_RELAYS
# 3. Database
pnpm db:migrate
pnpm db:seed # optional local seed data with signed Nostr events
# 4. Run
pnpm dev
# http://localhost:3000You'll need a NIP-07 signer browser extension or a NIP-46 remote signer to sign in:
DATABASE_URL=postgresql://...
NEXT_PUBLIC_APP_URL=http://localhost:3000
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
NEXT_PUBLIC_NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
NOSTRLAB_SESSION_SECRET=
NOSTRLAB_ADMIN_PUBKEY=
NOSTRLAB_METRICS_TOKEN=
NOSTRLAB_RUNTIME_ROLE=web
NOSTRLAB_RELEASE=
NOSTRLAB_ERROR_WEBHOOK_URL=
NOSTRLAB_ERROR_LOG_PATH=
NOSTRLAB_APP_NSEC= # generated by pnpm dev/build when absent
NEXT_PUBLIC_NOSTRLAB_APP_PUBKEY=
REDIS_URL=
ENABLE_RELAY_LISTENER=false
ENABLE_PAYMENT_RECONCILER=false
PAYMENT_RECONCILE_INTERVAL_MS=30000
PAYMENT_RECONCILE_BATCH_SIZE=100
ENABLE_NOTIFICATION_DELIVERY=false
NOSTRLAB_NOTIFICATION_CHANNELS=nostr_dm # nostr_dm, webhook
NOSTRLAB_NOTIFICATION_WEBHOOK_URL=
NOTIFICATION_DELIVERY_INTERVAL_MS=30000
NOTIFICATION_DELIVERY_BATCH_SIZE=50
ENABLE_EVENT_ALERTS=false
EVENT_ALERT_INTERVAL_MS=300000
EVENT_ALERT_BATCH_SIZE=100
UPLOAD_BACKEND=blossom # or "local" for dev, "s3" for S3-compatible storage
BLOSSOM_SERVER_URL=https://blossom.nostr.build
BLOSSOM_SIGNING_NSEC= # optional; falls back to NOSTRLAB_APP_NSEC
OBJECT_STORAGE_BUCKET= # required only when UPLOAD_BACKEND=s3
OBJECT_STORAGE_ENDPOINT=https://fsn1.your-objectstorage.com
OBJECT_STORAGE_REGION=fsn1
OBJECT_STORAGE_ACCESS_KEY_ID=
OBJECT_STORAGE_SECRET_ACCESS_KEY=
OBJECT_STORAGE_PUBLIC_BASE_URL=
LIGHTNING_MODE=lnurl # or "mock" or "none"
INVOICE_EXPIRY_SECONDS=900
LIGHTNING_MODE=none disables paid flows; free events still work.
Production deploys must use checked-in migrations:
pnpm verify
pnpm release:check
pnpm db:deploySee docs/production-release.md for the full public-release runbook, health checks, live Lightning test, backup/restore drill, and monitoring gates.
browser (Nostr signer)
│
│ sign event
▼
┌─────────────────────────┐
│ NostrLab Next.js app │
│ ─ verifies signatures │
│ ─ indexes to Postgres │ ──── publishes to ──▶ Nostr relays
│ ─ requests LN invoices │
└────────────┬────────────┘
│ checkInvoice / payment hash
▼
Organizer Lightning Address
- Source of truth for identity & event content is Nostr. Postgres is an index/cache.
- Every privileged write requires a freshly-signed Nostr event whose
pubkeyis the caller. We verify on the server before mutating. - The relay listener indexes relevant external NIP-52 kind:31923 events from configured relays. External events are filtered for public meetup/event signals before they enter discovery.
- Paid ticket proofs (
kind:31926) include a hashed ticket secret; the QR-code secret and Lightning preimage stay private. - The relay-pool is best-effort; we don't fail an API call when a relay is slow.
src/
app/ Next.js routes (UI + API)
page.tsx home
events/ list, create, detail
communities/ list, detail
dashboard/ organizer dashboard, manage, check-in
tickets/[id]/ ticket page; secret arrives via URL fragment
api/ server routes
components/ UI primitives + feature components
hooks/useNostr.tsx Nostr signer React context
lib/
nostr/ kinds, verify, parse, build, pools
lightning/ LNURL, mock, and disabled payment modes
prisma.ts auth.ts validation.ts dto.ts utils.ts
prisma/
schema.prisma Postgres index schema
seed.ts optional local seed data
| Method + path | Notes |
|---|---|
GET /api/events |
List + filter; ?q=&city=&tag=&mode=&paid= |
POST /api/events |
Body: { signedEvent } (kind 31923); verifies sig, indexes if event-like, re-broadcasts NostrLab-authored events |
GET /api/events/[id] |
Detail + organizer stats |
POST /api/events/[id]/rsvp |
Body: { signedEvent } (kind 31925); replaceable per (event, pubkey) |
GET /api/events/[id]/me?pubkey= |
Caller's RSVP + ticket existence |
GET /api/events/[id]/ics |
Single-event calendar file |
GET /api/calendar/events.ics |
Subscribable public event feed; accepts the event filters as query params |
GET /api/users/[npub-or-hex] |
Cached profile metadata |
POST /api/users/[pubkey]/refresh |
Force kind:0 re-fetch (rate-limited) |
POST /api/invoices |
Body: { eventId, buyerPubkey } → bolt11 |
GET /api/invoices/[id] |
Polled by buyer session; transactionally issues a ticket on first PAID read |
GET /api/tickets/[id] |
Stripped ticket detail |
POST /api/tickets/[id]/reveal |
Reveals full ticket data only to the original buyer with the ticket secret |
POST /api/tickets/[id]/check-in |
Body: { ticketSecret, signedAuthEvent }; organizer-only |
GET /api/communities |
List communities |
POST /api/communities |
Create community (organizer-signed auth event) |
PATCH /api/communities/[id] |
Owner-only community settings, approved hosts, verification website, and ownership transfer |
GET /api/communities/[slug]/ics |
Subscribable community calendar feed |
GET /api/dashboard/[npub] |
Organizer + attendee dashboard |
GET /api/event-alerts |
List saved event discovery alerts for the signed-in user |
POST /api/event-alerts |
Save current discovery filters as an alert |
DELETE /api/event-alerts/[id] |
Delete a saved discovery alert |
GET /api/health |
Liveness probe |
GET /api/ready |
Readiness probe for DB, Redis, and production env |
GET /api/ops/metrics |
Bearer-protected operational counters |
Buyer polling still confirms tickets immediately in the browser, but production should also run a worker so paid tickets settle if the buyer closes the tab:
ENABLE_PAYMENT_RECONCILER=true pnpm payments:reconcile:loopIf the worker runs as the Next.js worker role, set ENABLE_PAYMENT_RECONCILER=true there and keep it false on web nodes.
In-app notifications are written to Postgres for announcements, cancellations, ticket events, waitlist movement, and saved discovery alerts. A worker can also fan those notifications out through delivery channels:
ENABLE_NOTIFICATION_DELIVERY=true NOSTRLAB_NOTIFICATION_CHANNELS=nostr_dm pnpm startnostr_dm sends encrypted NIP-04 DMs from NOSTRLAB_APP_NSEC to the attendee pubkey. webhook posts the notification payload to NOSTRLAB_NOTIFICATION_WEBHOOK_URL and retries failures through the delivery queue.
Saved event alerts are scanned by the worker:
ENABLE_EVENT_ALERTS=true pnpm startKeep both worker flags off on public web nodes.
Free event
- Sign in with Nostr →
- Open event → click Going →
- Browser signs kind:31925, NostrLab indexes + re-broadcasts.
Paid event
- Open event → Buy ticket →
- NostrLab requests an invoice from the organizer's Lightning Address; modal shows QR + bolt11 →
- Wallet pays; NostrLab polls; on first
paid:truewe transactionally markPayment.PAIDand issue aTicketwith a fresh random secret → - User is redirected to
/tickets/[id]#secret=...with the ticket.
Organizer check-in
- Organizer opens
/dashboard/events/[id]/check-in→ - Scans the QR with the browser camera or pastes the payload →
- Browser signs an auth envelope (
action=checkin); server verifies the pubkey matches organizer or co-host, and checks the ticket secret → - Ticket is marked checked in; duplicates are rejected.
Community ownership
- Community owner opens
/communities/[slug]/settings→ - Updates approved hosts, website verification link, tags, image, or transfer target →
- Browser signs
community.update; the server requires the current owner key and rewrites host access atomically.
- No private keys, ever. We do not implement nsec import in the MVP.
- All write endpoints verify a signed Nostr event whose
pubkeyis the caller's identity. - Private RSVPs stay off public relays. When the attendee's signer supports NIP-04, the private RSVP payload is encrypted to the event organizer key and can be decrypted from the organizer dashboard.
- Ticket secrets are random UUIDs, only revealed to the original purchaser via the URL fragment they receive on payment confirmation. They are never published to Nostr.
- Bolt11s, providerRefs, and Lightning preimages are server-only.
- Description fields are sanitized for stripped HTML before being rendered. The original signed event JSON is preserved verbatim in
Event.rawEventfor re-broadcast. - Write endpoints are rate-limited per pubkey/IP. Set
REDIS_URLfor multi-node production. /api/readyfails release readiness when required production env, Postgres, or Redis is missing.
See CONTRIBUTING.md. Security reports should follow SECURITY.md.
MIT.