A modern, secure NIP-46 Nsec Bunker management tool built with TypeScript.
| Login | Dashboard |
|---|---|
![]() |
![]() |
| Connections | Keys |
|---|---|
![]() |
![]() |
Screenshots are captured with Playwright (WebP). The connections screenshot uses mocked API data. To regenerate: run pnpm dev in one terminal, then pnpm run e2e:screenshots.
| Layer | Technology |
|---|---|
| Frontend | Vue 3, Vite 8, Tailwind CSS v4, shadcn-vue, nanostores, localforage |
| Backend | NestJS 11, Fastify, Prisma ORM 7 (driver adapter), nostr-tools |
| Database | PostgreSQL 17 |
| Auth | JWT + Argon2, TOTP (otplib), WebAuthn/Passkeys (@simplewebauthn) |
| Testing | Vitest, Playwright, eslint-plugin-security |
| Infra | Docker, Docker Compose, Node 24, pnpm 10, Redis (optional, for live updates) |
bunker46/
├── apps/
│ ├── server/ # NestJS + Fastify backend
│ │ ├── src/
│ │ │ ├── auth/ # JWT, TOTP, WebAuthn, session management
│ │ │ ├── bunker/ # NIP-46 RPC handler, relay pool, URI parsing
│ │ │ ├── connections/ # CRUD for bunker connections & permissions
│ │ │ ├── events/ # Redis pub/sub, SSE stream (live dashboard/connections)
│ │ │ ├── logging/ # Signing logs & dashboard stats
│ │ │ ├── users/ # User management
│ │ │ ├── prisma/ # Database service
│ │ │ └── common/ # Encryption, guards, interceptors
│ │ └── prisma/ # Schema & migrations
│ └── web/ # Vue 3 SPA
│ ├── src/
│ │ ├── components/ # UI components (shadcn-style)
│ │ ├── views/ # Route views
│ │ ├── stores/ # nanostores + localforage
│ │ ├── router/ # Vue Router
│ │ └── lib/ # API client, utilities
│ └── e2e/ # Playwright E2E tests
├── packages/
│ ├── shared-types/ # Zod schemas & TypeScript types
│ ├── config/ # Environment config & constants
│ ├── tsconfig/ # Shared TypeScript configs
│ └── eslint-config/ # Shared ESLint configs
├── docker-compose.yml # Production Docker Compose
└── docker-compose.dev.yml # Dev services (DB, Redis)
- Node.js >= 24
- pnpm >= 10
- Docker & Docker Compose (for database)
# Install dependencies
pnpm install
# Start database services
docker compose -f docker-compose.dev.yml up -d
# Copy environment config
cp .env.example .env
# Optional: set REDIS_URL=redis://localhost:6379 in .env for live dashboard/connections updates
# Optional: set ALLOW_REGISTRATION=false to disable new user sign-ups (backend returns 403; frontend hides register link)
# Optional: set TRUST_PROXY=true when the API is behind a reverse proxy (e.g. Caddy) so session IPs and rate limiting use the real client IP
# Generate Prisma client (required; runs automatically on pnpm install) & run migrations
pnpm db:generate
pnpm db:migrate
# Optional: seed the database (Prisma 7 does not auto-seed after migrate)
# pnpm --filter @bunker46/server run db:seed
# Start dev servers (backend + frontend)
pnpm devThe frontend will be available at http://localhost:5173 and the API at http://localhost:3000. With Redis running and REDIS_URL set, the dashboard and connections list refresh automatically when activity occurs.
If ports 3000 or 5173 are already in use (e.g. a previous dev run didn’t exit cleanly), free them and restart:
pnpm kill-port
pnpm dev# Run all unit/integration tests
pnpm test
# Run with coverage
pnpm test:coverage
# Run E2E tests (Playwright; starts full stack from repo root so API works)
pnpm e2e
# If you already have `pnpm dev` running, reuse it to avoid port conflicts:
# CI= pnpm e2e
# Run E2E with UI
pnpm e2e:ui
# When the server is started by Playwright (pnpm dev), rate limiting is skipped for localhost in non-production so E2E does not hit limits. For E2E_USE_PREVIEW, start the server yourself; localhost requests are also skipped when NODE_ENV is not production.
# Lint (includes security rules)
pnpm lint
# Security-specific lint
pnpm lint:securitydocker-compose.yml is the production example. It is secure by default: it has no fallback
secrets and does not publish the database/Redis ports to the host, so it will refuse to start until
you provide strong secrets.
# 1. Generate strong secrets and write them to a .env next to docker-compose.yml
cat > .env <<EOF
JWT_SECRET=$(openssl rand -base64 48)
JWT_REFRESH_SECRET=$(openssl rand -base64 48)
ENCRYPTION_KEY=$(openssl rand -base64 48)
# Set these to your real public origin:
CORS_ORIGINS=https://bunker.example.com
WEBAUTHN_RP_ID=bunker.example.com
WEBAUTHN_ORIGIN=https://bunker.example.com
EOF
# 2. Build and run all services
docker compose up --build
# 3. Access the application (front it with TLS via your reverse proxy)
open http://localhost:8080
⚠️ ENCRYPTION_KEYprotects the stored Nostr private keys. If you lose or change it, existing encrypted keys can no longer be decrypted. Back it up and rotate deliberately.
Production deployment checklist:
- Strong, unique
JWT_SECRET,JWT_REFRESH_SECRET,ENCRYPTION_KEY(not the dev placeholders — the server refuses to start with them whenNODE_ENV=production) - TLS terminated by a reverse proxy (Caddy/Nginx); only the web/proxy entrypoint is exposed publicly
-
TRUST_PROXY=trueset only when the app is reachable solely through that trusted proxy -
CORS_ORIGINS,WEBAUTHN_RP_ID,WEBAUTHN_ORIGINset to your real public origin -
ALLOW_REGISTRATIONleft disabled (default) unless you intend open sign-ups; the first account can still be created on first run - Postgres/Redis not published to the host (default) — reachable only over the internal Docker network
- Database backups and a tested restore procedure in place
When running behind a reverse proxy (e.g. Caddy, Nginx), set TRUST_PROXY=true (or 1 / yes) in the server environment so that session IPs and throttling use the client IP from X-Forwarded-For / X-Real-IP. Only enable this when the app is only reachable through a trusted proxy.
This tool implements the full NIP-46 Nostr Remote Signing specification:
- URI Support: Parse and generate both
bunker://andnostrconnect://URIs - All RPC Methods:
connect,sign_event,ping,get_public_key,nip04_encrypt/decrypt,nip44_encrypt/decrypt,switch_relays - Fine-grained Permissions: Per-connection method and event-kind restrictions, enforced default-deny (a connection with no permissions can sign/decrypt nothing). New connections get a conservative default set (common signing kinds, no decryption). Following the NIP-46 model for a non-interactive signer, a connecting client may declare the scope it needs in its
connectrequest and that becomes the connection's permission set, so only connect clients you trust with the bound key; operators can tighten any connection's permissions in the dashboard at any time. - NIP-44 Encryption: All communication encrypted using NIP-44
- Auth Challenges: Support for out-of-band authentication
- Relay Management: Configurable relays per connection with automatic reconnection
- Private keys (nsecs) are encrypted at rest using AES-256-GCM
- Passwords hashed with Argon2
- 2FA via TOTP and WebAuthn/Passkeys
- JWT with short-lived access tokens and refresh token rotation
- Session management: list active sessions (with IP and user-agent), revoke a session, or log out all other sessions
- HTTP-only secure cookies option
- Security ESLint rules enforced
- Non-root Docker containers
CORS / CSRF: Keep CORS_ORIGINS strict: use explicit origins (e.g. http://localhost:5173 or your frontend URL), never * when credentials are used. Avoid overly broad origins to reduce cross-site request risk; consider SameSite cookie attributes and CSRF tokens for critical state-changing actions.
To check for outdated packages across the monorepo:
pnpm outdated -rThe monorepo tracks current major versions (TypeScript 6, Vite 8, Prisma 7, Vitest 4, Vue Router 5, Zod 4). Run pnpm outdated -r periodically and upgrade deliberately, running the full test suite after each bump.
MIT




