Open-source, encryption-first content platform powered by SatsRail Bitcoin Lightning payments. Sell any type of media — video, audio, articles, photos, podcasts — with instant, non-custodial payments. No payment processor accounts, no chargebacks, no middlemen.
The buyer-facing copy of every piece of content is encrypted at rest, and decryption happens entirely in the buyer's browser after payment — the server never decrypts content for a buyer. SatsRail manages encryption keys and payment verification but never sees your content. PrivaPaid never touches customer funds.
A second copy of each item — the source URL for video/audio/podcast, the body text for articles, the wrapped DEK for photos — is persisted on the Media document so admins can re-encrypt after a key rotation without depending on SatsRail returning the old key. That plaintext copy is admin-only by route convention; no public endpoint returns it. See docs/ENCRYPTION.md § "Threat model" for the trade-off and mitigations.
Fork it, deploy it, sell whatever you want through it.
You need Docker installed. That's it.
git clone https://github.com/SatsRail/privapaid.git
cd privapaid
cp .env.docker.example .env
docker compose up -dOpen http://localhost:3000. A setup wizard walks you through everything:
- Name your instance and pick a theme color
- Paste your SatsRail API key (from your SatsRail merchant dashboard)
Done. Log in with your SatsRail merchant credentials and start creating channels.
Encryption keys and auth secrets are generated automatically on first run.
One click spins up the app + Postgres from the template. Railway prompts for the required environment variables during deploy.
If you'd rather deploy your own fork manually:
- Create the project: in Railway, click New Project → Deploy from GitHub repo → pick your fork of
privapaid. Railway will detect theDockerfileand start a build. - Add Postgres: on the project canvas, click + Create → Database → Add PostgreSQL.
- Generate secrets locally:
echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)" echo "SK_ENCRYPTION_KEY=$(openssl rand -hex 32)"
- Set variables on the
privapaidservice → Variables tab:DATABASE_URL=${{Postgres.DATABASE_URL}} NEXTAUTH_SECRET=<paste from step 3> SK_ENCRYPTION_KEY=<paste from step 3> SATSRAIL_API_URL=https://satsrail.com${{Postgres.DATABASE_URL}}is Railway's reference syntax — it auto-resolves to the connection string of the Postgres service. The Docker entrypoint runsprisma migrate deploybefore starting the server. - Deploy: Railway redeploys automatically when variables change. Once the healthcheck on
/api/healthpasses, open the public URL and complete the setup wizard.
Important: always set
NEXTAUTH_SECRETandSK_ENCRYPTION_KEYexplicitly. Railway containers have ephemeral filesystems, so the entrypoint's auto-generated secrets would rotate on every restart — invalidating sessions and breaking decryption of existing content.
See DEPLOYMENT.md for EC2, Docker, and other deployment options.
- Channels — each creator gets their own page with a dedicated SatsRail product type for revenue grouping
- Five media types — see Media Types below
- Lightning payments — customers pay with Bitcoin, funds go directly to your wallet
- Encryption at rest — all content encrypted with AES-256-GCM before it touches the database
- Payment-gated access — three-state gating (unavailable → locked → unlocked) with no unencrypted fallback
- Macaroon-based persistent access — signed tokens allow return visits without re-payment
- Key rotation — admin-controlled per-product key rotation with streaming re-encryption
- White-label — your name, your colors, your domain
- RSS feeds — per-channel
/c/{slug}/feed.xmlso viewers can follow new content in any RSS reader (auto-discovered via<link rel="alternate">) - Admin dashboard — manage channels, media, and categories
- NSFW toggle — enable or disable adult content categories per instance
Every media item has a media_type that controls how content is stored, encrypted, and rendered. All five types share the same payment flow and per-product encryption — only the storage shape differs.
| Type | What source_url holds |
Viewer renders | Notes |
|---|---|---|---|
video |
Direct file URL (.mp4/.webm/HLS) or embed URL (YouTube, Vimeo, Twitch, Bunny Stream, Cloudflare Stream, Mux, Dailymotion) |
<video> for direct files; <iframe> for known hosts |
URL itself is encrypted; the host stores the bytes. |
audio |
Direct audio file URL (.mp3/.wav/.flac/.aac) |
<audio> player with optional artwork from the thumbnail |
URL itself is encrypted. |
article |
Markdown text or a URL | Markdown rendered inline (GFM, sanitized via DOMPurify in a closed shadow root); URLs render as an "Open article" external link card | Auto-detects URL vs markdown. Max 500KB. Links open in a new tab with rel="noopener noreferrer". |
photo |
EncryptedPhotoBlob.id pointer to the encrypted bytes |
<img> after client-side decryption |
Encrypted at rest in the EncryptedPhotoBlob Postgres table (bytea column). Envelope encryption: a random per-photo DEK encrypts the bytes once; the DEK is encrypted under each product key. Upload through /api/admin/photos (multipart, 5MB cap, JPEG/PNG/WebP/GIF, EXIF stripped). Photos cannot be added via JSON import — bytes must be uploaded to encrypt. |
podcast |
Audio URL | Same as audio plus podcast-style metadata in JSON-LD |
Treated like audio at render time. |
- For
video,audio,article,podcastthe URL or text is encrypted intoMediaProduct.encrypted_source_url(orChannelProduct.encrypted_media[].encrypted_source_url) under the SatsRail product key. The viewer decrypts client-side after payment. - For
photo, the bytes themselves live encrypted in theEncryptedPhotoBlobtable (Postgresbytea), andMediaProduct.encryptedSourceUrlholds the encrypted DEK (envelope). The viewer unwraps the DEK with the product key, fetches the ciphertext from/api/photos/[id], and decrypts in the browser.
In every case the SatsRail Portal holds the encryption keys and never sees content; the Stream app holds the content (both an encrypted, buyer-facing copy and an admin-only plaintext copy for re-encryption — see Architecture below) and never persists product keys at rest.
Populate your instance from a JSON file — either the entire site (categories, channels, and media) or media for a single channel.
Upload a JSON file at Admin > Import / Export to create categories, channels, and media in one pass.
{
"version": "1.0",
"categories": [
{ "slug": "bitcoin-education", "name": "Bitcoin Education", "position": 1 }
],
"channels": [
{
"slug": "beginner",
"name": "Level 1 — Beginner",
"bio": "Start here.",
"category_slug": "bitcoin-education",
"nsfw": false,
"product": {
"name": "Full Channel Access",
"price_cents": 500,
"currency": "USD",
"access_duration_seconds": 2592000
},
"media": [
{
"ref": 1,
"name": "What is Bitcoin?",
"source_url": "https://www.youtube.com/watch?v=example",
"media_type": "video",
"position": 1,
"product": {
"name": "What is Bitcoin? — Individual",
"price_cents": 100,
"currency": "USD",
"access_duration_seconds": 604800
}
}
]
}
]
}Add media to an existing channel at Admin > Channels > [channel] > Import. The file contains only a media array.
{
"version": "1.0",
"media": [
{
"ref": 1,
"name": "Episode Title",
"source_url": "https://www.youtube.com/watch?v=example",
"media_type": "video",
"position": 1,
"product": {
"name": "Episode Title",
"price_cents": 100,
"currency": "USD",
"access_duration_seconds": 604800
}
}
]
}Importable media_type values: video, audio, article, podcast. The photo type is not importable via JSON — photo bytes must be uploaded through the admin UI (/api/admin/photos) so the EncryptedPhotoBlob row and DEK envelope can be created. See Media Types for full details on each type.
Each media item can include a product with pricing — the import automatically creates the corresponding SatsRail product and encrypts the source URL. Imports are idempotent: re-importing with the same slugs or refs updates existing records.
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript (strict mode) |
| Database | PostgreSQL + Prisma |
| Auth | NextAuth.js v5 (credentials) |
| Encryption | AES-256-GCM via Web Crypto API (browser) and Node.js crypto (server) |
| Payments | SatsRail (Bitcoin Lightning) |
| Styling | Tailwind CSS |
| Deployment | Docker |
Every piece of content has an encrypted buyer-facing copy in Postgres:
MediaProduct.encryptedSourceUrl (and ChannelProductMedia.encryptedSourceUrl)
hold the AES-256-GCM ciphertext under the SatsRail product key, with the
product's UUID bound as AAD so a blob encrypted for product A is mathematically
useless in the context of product B. Photo bytes live encrypted in the
EncryptedPhotoBlob table (Postgres bytea) under a random per-photo DEK;
the DEK itself is what the product key wraps. A single media item can be sold
through multiple products (individually, as part of a bundle, etc.); each
product-media combination produces a separately encrypted blob locked with
that product's key.
The blob format is Base64(IV[12] + ciphertext + auth_tag[16]). The browser
splits the IV from the ciphertext+tag and decrypts using the Web Crypto API
after payment. The server plays no role in buyer-side decryption.
PrivaPaid also persists a plaintext recovery copy of each item on the
Media row — Media.sourceUrl for video/audio/article/podcast, and
Media.encryptedDek (the per-photo DEK wrapped under the operator's
PHOTO_KEK, decryptable server-side without SatsRail) for photos. This copy
exists so admin-triggered key rotation can re-encrypt every product blob in a
single in-DB operation, without depending on SatsRail still returning the old
product key — that pipeline has failed in practice and used to brick rotations
mid-way through.
The cost of that choice is that a full Postgres dump now exposes the plaintext
source for non-photo media (and the wrapped DEK for photos, which stays opaque
unless PHOTO_KEK is also leaked). Mitigations:
- No public route returns these fields. The viewer page deliberately
forwards
sourceUrlonly whenmediaType === 'photo', and only as anEncryptedPhotoBlob.idpointer to encrypted bytes. Regression test:tests/integration/pages/viewer-photo-page.test.ts. - Admin-only API surface. Every endpoint that reads the plaintext fields
lives under
/api/admin/*and is gated by middleware +requireOwnerApi()defense-in-depth. - Sentry scrubber redacts
source_url,encrypted_dek,password,satsrail_api_key,macaroon,authorization,cookie, andsk_*/pk_*keys from every captured event so a mid-rotation error doesn't ship plaintext to the error tracker. Seesrc/lib/sentry-scrub.ts. PHOTO_KEKlives in env, not the DB. Photo DEKs stay opaque under a Postgres-only breach.
Full discussion lives in docs/ENCRYPTION.md § "Threat model".
Content on a media page exists in one of three states:
| State | Condition | Behavior |
|---|---|---|
| Unavailable | No MediaProduct records exist (no encrypted blobs) |
"Unavailable" overlay shown. Payment buttons disabled. No invoice is created. |
| Locked | Encrypted blobs exist but no valid access token | "Pay to Watch" overlay with pricing tiers. Lightning payment flow available. |
| Unlocked | Valid access token + successful decryption | Content plays. Blur removed. Source URL loaded from decrypted blob. |
The unavailable state prevents charging for content that can't be delivered — if there's no encrypted blob, there's no key to sell and no URL to decrypt. This short-circuits before any payment UI appears.
- Buyer selects a product and pays a Lightning invoice
- SatsRail confirms payment and issues a macaroon (signed access token) plus the product's decryption key
- Key is delivered to the browser via HTTP polling through the stream app's own API routes
- Browser decrypts the encrypted blob using Web Crypto API and loads the content
- Macaroon is stored in an httpOnly cookie for return visits — it encodes
product_idand an expiry, not the key itself
On return visits, the browser presents its macaroon to the stream app's /api/macaroons route, which proxies the verification request to SatsRail server-side. If the signature is valid and the token hasn't expired, the current product key is returned. No re-payment required. Macaroons survive key rotation because they reference product_id, not the key.
Products support admin-controlled key rotation with a two-key window:
- Admin triggers rotation — current key moves to
old_key, new key is generated - Product enters "rotation pending" state — media uploads are blocked, admin sees a badge
- Admin triggers re-encryption — PrivaPaid decrypts each blob with
old_keyand re-encrypts with the new key, streaming progress - On success,
old_keyis cleared via the SatsRail API — rotation is complete
During the rotation window, existing buyers' macaroons remain valid but decryption will fail until re-encryption completes. This is pull-based by design — no webhooks are relied upon because they may not reach the destination.
PrivaPaid generates opaque external_ref values that SatsRail stores but never interprets. These refs encode scope and attribution using prefixes: ch_ for channels, md_ for individual media items. SatsRail uses them for revenue grouping and product lookup, but has zero knowledge of what they point to.
When a channel is created, PrivaPaid assigns an auto-incrementing numeric ref and creates a corresponding ProductType on SatsRail (external_ref: ch_7). When media is associated with a product, the product gets external_ref: md_12. This decouples payment identity from display identity — slugs are user-facing and editable, refs are stable and opaque.
Revenue attribution flows through these refs: channel earnings are grouped by ProductType, media earnings by external_ref within the type. No channel ID, media ID, or content type flag is needed on the order model.
npm install
cp .env.local.example .env.local # Fill in your values
npm run dev # http://localhost:3001Requires Node.js and PostgreSQL 16+ (docker compose up -d postgres or any managed Postgres).
npm run dev # Dev server with hot reload
npm run build # Production build
npm run start # Start production server
npm run lint # ESLint
npx tsc --noEmit # Type-check
npm test # Full test suite
npm run test:decryption # Decryption end-to-end specs only (fast)
npm run verify:sentry-dsn # Confirm production build inlines NEXT_PUBLIC_SENTRY_DSN
npm run cleanup:orphan-photos # Delete unreferenced encrypted-photo blobsThe decryption test suite is documented in tests/integration/decryption-e2e/README.md — read it before changing anything in src/lib/content-encryption.ts, src/lib/client-crypto.ts, or src/components/PaymentWall.tsx.
The photo upload flow writes an encrypted blob to the EncryptedPhotoBlob table before the admin commits to creating the Media row + first product. If the admin abandons the flow, the bytes sit forever — unrecoverable (DEK is gone) but consuming storage. Run the cleanup on a schedule to reclaim them.
# Defaults: 1-hour grace period, real deletes
npm run cleanup:orphan-photos
# Report only — no deletes
npm run cleanup:orphan-photos -- --dry-run
# Delete every unreferenced blob regardless of age (use with care)
npm run cleanup:orphan-photos -- --grace 0Outputs a single JSON object summarising the run. Exits 0 on success, 2 if any individual delete failed.
Scheduling:
- Railway / Render: add a cron service (e.g. daily at 03:00 UTC) running
npm run cleanup:orphan-photos. - systemd: a simple
OnCalendar=dailytimer. - Kubernetes:
CronJobpointing at the same container image. - On-demand:
POST /api/admin/photos/cleanup(owner-only) accepts{ graceSeconds?, dryRun? }and returns the same stats — useful for ad-hoc cleanups from the admin dashboard.