Skip to content

n0rthranger/nostrlab

Repository files navigation

NostrLab

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.

What's in the box

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

Custom Nostr kinds

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.

Quick start

# 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:3000

You'll need a NIP-07 signer browser extension or a NIP-46 remote signer to sign in:

Environment variables

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

See docs/production-release.md for the full public-release runbook, health checks, live Lightning test, backup/restore drill, and monitoring gates.

Architecture

              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 pubkey is 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.

Folder map

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

API

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

Payment reconciler

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

If the worker runs as the Next.js worker role, set ENABLE_PAYMENT_RECONCILER=true there and keep it false on web nodes.

Notification delivery and event alerts

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 start

nostr_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 start

Keep both worker flags off on public web nodes.

User flows

Free event

  1. Sign in with Nostr →
  2. Open event → click Going
  3. Browser signs kind:31925, NostrLab indexes + re-broadcasts.

Paid event

  1. Open event → Buy ticket
  2. NostrLab requests an invoice from the organizer's Lightning Address; modal shows QR + bolt11 →
  3. Wallet pays; NostrLab polls; on first paid:true we transactionally mark Payment.PAID and issue a Ticket with a fresh random secret →
  4. User is redirected to /tickets/[id]#secret=... with the ticket.

Organizer check-in

  1. Organizer opens /dashboard/events/[id]/check-in
  2. Scans the QR with the browser camera or pastes the payload →
  3. Browser signs an auth envelope (action=checkin); server verifies the pubkey matches organizer or co-host, and checks the ticket secret →
  4. Ticket is marked checked in; duplicates are rejected.

Community ownership

  1. Community owner opens /communities/[slug]/settings
  2. Updates approved hosts, website verification link, tags, image, or transfer target →
  3. Browser signs community.update; the server requires the current owner key and rewrites host access atomically.

Security model

  • No private keys, ever. We do not implement nsec import in the MVP.
  • All write endpoints verify a signed Nostr event whose pubkey is 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.rawEvent for re-broadcast.
  • Write endpoints are rate-limited per pubkey/IP. Set REDIS_URL for multi-node production.
  • /api/ready fails release readiness when required production env, Postgres, or Redis is missing.

Contributing

See CONTRIBUTING.md. Security reports should follow SECURITY.md.

License

MIT.

About

Open-source event platform for Nostr communities

Topics

Resources

License

Contributing

Security policy

Stars

5 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages