docs(examples): operator cookbook — six deployment recipes + premium-relay spec

Add ready-to-run .env + README recipes (personal, public-free, friends,
paid-access, paid-relay, community) and the premium-relay design overview,
with audit fixes to the config template and spec:

- Drop the inert INSTANCE_ID knob (config never read it) and document the real
  per-node id, which is derived from ADMIN_PUBLIC_URL.
- Warn that a placeholder PUBLIC_API_URL is rejected at boot in production
  (Nostr identity is on by default); soften the recipe "boots today" status.
- Remove dead template vars: BTC/LTC Core-RPC settings and the non-existent
  PUBLIC_EXPOSE_FEATURES (replaced with the real per-field landing switches).
- Mark ALTCHA_HMAC_KEY conditional in PoW-off recipes; blank placeholder
  payment creds so a recipe doesn't ship a key that fails validation.
- Fix premium-relay spec drift: the migration creates no premium_until index,
  disabled endpoints return 400/200 (not 404), and neutralize operator voice.
This commit is contained in:
jwinterm
2026-07-02 19:47:46 -04:00
parent dec9994e06
commit d96f457b69
15 changed files with 370 additions and 93 deletions
+31 -17
View File
@@ -6,14 +6,13 @@
# ── Server ──────────────────────────────────────────────────────────────── # ── Server ────────────────────────────────────────────────────────────────
SERVER_HOST=0.0.0.0 SERVER_HOST=0.0.0.0
SERVER_PORT=8080 SERVER_PORT=8080
# Public absolute API base URL. REQUIRED when admin/erasure features are enabled: # Public absolute API base URL — set this to YOUR real domain. It is the canonical
# it is the canonical value the NIP-98 `u` tag is verified against (never the # value the NIP-98 `u` tag is verified against (never the request Host header). In
# request Host header). Boot fails if unset while those features are on. # production, boot fails closed if this is left as a placeholder (contains `example`
# or `your-`) while Nostr identity (on by default), admin, or erasure is enabled —
# so every real deployment must replace it.
PUBLIC_API_URL=https://backend.example.org/api/v1 PUBLIC_API_URL=https://backend.example.org/api/v1
ENVIRONMENT=development ENVIRONMENT=development
# Stable per-node identifier. Bound into signed admin/erasure actions so a proof
# captured against one node cannot be replayed across a load-balanced fleet.
INSTANCE_ID=
# ── Database ────────────────────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────────────────────
DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk DATABASE_URL=postgres://user:password@127.0.0.1:5432/smirk
@@ -31,6 +30,9 @@ REFRESH_TOKEN_PEPPER=
IP_SALT= IP_SALT=
# ── Proof-of-work signup gate (ALTCHA v2) ───────────────────────────────── # ── Proof-of-work signup gate (ALTCHA v2) ─────────────────────────────────
# FEATURE_POW is the master switch; POW_REQUIRED only applies when it is on. Boot
# fails closed if POW_REQUIRED=true while FEATURE_POW=false (a silently-open gate).
FEATURE_POW=false
ALTCHA_HMAC_KEY= ALTCHA_HMAC_KEY=
POW_REQUIRED=false POW_REQUIRED=false
ALTCHA_COST=100000 ALTCHA_COST=100000
@@ -64,21 +66,13 @@ PAYMENT_CURRENCY=XMR
PAYMENT_CONFIRMATIONS=1 PAYMENT_CONFIRMATIONS=1
PAYMENT_EXPIRES_MINUTES=60 PAYMENT_EXPIRES_MINUTES=60
# ── Bitcoin ─────────────────────────────────────────────────────────────── # ── Bitcoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ─────
BTC_NETWORK=mainnet BTC_NETWORK=mainnet
BTC_USE_LOCAL_NODE=false
BTC_RPC_URL=http://127.0.0.1:8332
BTC_RPC_USER=bitcoinrpc
BTC_RPC_PASS=
BTC_ELECTRUM_URL= BTC_ELECTRUM_URL=
BTC_ELECTRUM_FALLBACKS= BTC_ELECTRUM_FALLBACKS=
# ── Litecoin ────────────────────────────────────────────────────────────── # ── Litecoin (Electrum/Fulcrum only; the backend runs no Core RPC node) ────
LTC_NETWORK=mainnet LTC_NETWORK=mainnet
LTC_USE_LOCAL_NODE=false
LTC_RPC_URL=http://127.0.0.1:9332
LTC_RPC_USER=litecoinrpc
LTC_RPC_PASS=
LTC_ELECTRUM_URL= LTC_ELECTRUM_URL=
LTC_ELECTRUM_FALLBACKS= LTC_ELECTRUM_FALLBACKS=
@@ -163,11 +157,27 @@ RELAY_ADMISSION_ALLOW_PUBLIC=false
RELAY_MAX_EVENT_BYTES=65536 RELAY_MAX_EVENT_BYTES=65536
RELAY_RETENTION_DAYS=30 RELAY_RETENTION_DAYS=30
# ── Premium relay tier (optional; recurring, tiered relay-posting access) ──
# Wallet use stays free; premium unlocks GENERAL Nostr posting to the relay
# (wallet-functional events — DMs, tips, swap coordination — stay free). Requires
# RELAY_ENABLED=true + RELAY_WRITE_POLICY=premium-post + the PAYMENT_* processor
# block above. Off by default. See docs/design/premium-relay.md.
PREMIUM_ENABLED=false
# Price currency: a fiat code the processor converts, or a specific coin.
PREMIUM_CURRENCY=USD
# Comma-separated plans, each `id:days:amount` (a built-in tiered discount).
PREMIUM_PLANS=quarter:90:5,halfyear:182:9,year:365:15
# ── Admin surface (see docs; default posture is loopback/Tor) ────────────── # ── Admin surface (see docs; default posture is loopback/Tor) ──────────────
# Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here, # Admin auth is "Sign in with Smirk": only admin PUBLIC keys are stored here,
# never a seed or private key. The allowlist is mutated CLI/loopback-only in v1. # never a seed or private key. The allowlist is mutated CLI/loopback-only in v1.
ADMIN_ENABLED=false ADMIN_ENABLED=false
ADMIN_BIND=127.0.0.1:8081 ADMIN_BIND=127.0.0.1:8081
# Public absolute URL of THIS node's admin surface. The per-node id that binds
# signed admin actions (so a challenge signed for one node can't be replayed to
# another across a fleet) is DERIVED from this value — give each node in a fleet a
# distinct one. Must be an absolute URL when ADMIN_ENABLED=true.
ADMIN_PUBLIC_URL=https://admin.example.org
ADMIN_JWT_SECRET= ADMIN_JWT_SECRET=
# Integrity MAC secret protecting admin/setup trust anchors against DB tampering. # Integrity MAC secret protecting admin/setup trust anchors against DB tampering.
# Losing this locks out admin — back it up (see docs/operations). # Losing this locks out admin — back it up (see docs/operations).
@@ -177,7 +187,11 @@ ADMIN_PENDING_KEY_TTL_DAYS=7
# ── Public landing page (OFF by default; full + per-field tunable when on) ── # ── Public landing page (OFF by default; full + per-field tunable when on) ──
PUBLIC_LANDING_ENABLED=false PUBLIC_LANDING_ENABLED=false
PUBLIC_EXPOSE_FEATURES=false # Optional custom landing title (leave unset for the default).
#PUBLIC_LANDING_TITLE=
PUBLIC_EXPOSE_VERSION=false
PUBLIC_EXPOSE_CHAINS=false
PUBLIC_EXPOSE_PRICE_FEED=false
PUBLIC_EXPOSE_UPTIME=false PUBLIC_EXPOSE_UPTIME=false
PUBLIC_STATS_ENABLED=false PUBLIC_STATS_ENABLED=false
PUBLIC_STATS_CACHE_HOURS=24 PUBLIC_STATS_CACHE_HOURS=24
+54 -46
View File
@@ -1,18 +1,18 @@
# Spec — Premium relay access (recurring) # Spec — Premium relay access (recurring)
Status: **DRAFT / spec-first** · Owner: Such Software · Target: v0.4 Status: **implemented** — a design overview of the as-built feature (shipped v0.3.0).
## 1. Summary ## 1. Summary
A **premium tier**: registration and wallet use stay **free** (PoW-gated); an A **premium tier**: registration and wallet use stay **free** (PoW-gated); an
optional **recurring fee** (e.g. $5 / quarter) grants **posting access to the optional **recurring fee** (e.g. $5 / quarter) grants **posting access to the
Smirk-operated Nostr relay**. This reframes payment from *pay-to-register* (a operator's Nostr relay**. This reframes payment from *pay-to-register* (a
one-time gate on wallet creation) to a *subscription* (a recurring gate on relay one-time gate on wallet creation) to a *subscription* (a recurring gate on relay
writes). Everything else — register, unlock, balances, tip stats, receiving DMs, writes). Everything else — register, unlock, balances, tip stats, receiving DMs,
posting to public relays — remains free. posting to public relays — remains free.
This is the flagship **cookbook recipe** (`examples/paid-relay/`): the live, This is the flagship **cookbook recipe** (`examples/paid-relay/`): a complete,
dogfooded demonstration of operator monetization, built entirely on seams that working demonstration of operator monetization, built entirely on seams that
already exist (PaymentProvider + the relay admission engine). already exist (PaymentProvider + the relay admission engine).
## 2. Goals / non-goals ## 2. Goals / non-goals
@@ -34,18 +34,18 @@ already exist (PaymentProvider + the relay admission engine).
## 3. Model ## 3. Model
The free tier includes everything that makes the **wallet** work — including The free tier includes everything that makes the **wallet** work — including
publishing **wallet-functional events** to our relay (encrypted DMs that ride with publishing **wallet-functional events** to the operator's relay (encrypted DMs that
tips, and future P2P atomic-swap coordination). Premium adds using our relay as a ride with tips, and future P2P atomic-swap coordination). Premium adds using that
**general-purpose Nostr relay** (notes, reactions, anything). relay as a **general-purpose Nostr relay** (notes, reactions, anything).
| Capability | Free | Premium | | Capability | Free | Premium |
|---|---|---| |---|---|---|
| Register / unlock / use wallet (all chains) | ✅ | ✅ | | Register / unlock / use wallet (all chains) | ✅ | ✅ |
| Login, view tip/balance stats | ✅ | ✅ | | Login, view tip/balance stats | ✅ | ✅ |
| Receive DMs (gift-wrap delivery to you) | ✅ | ✅ | | Receive DMs (gift-wrap delivery to you) | ✅ | ✅ |
| Publish **wallet events** to the Smirk relay (encrypted DMs, tips, swap coordination) | ✅ | ✅ | | Publish **wallet events** to the operator's relay (encrypted DMs, tips, swap coordination) | ✅ | ✅ |
| Publish to **public** relays (damus, nos.lol…) | ✅ | ✅ | | Publish to **public** relays (damus, nos.lol…) | ✅ | ✅ |
| Publish **general Nostr** to the Smirk relay (kind-1 notes, reactions, …) | ❌ | ✅ while `premium_until > now` | | Publish **general Nostr** to the operator's relay (kind-1 notes, reactions, …) | ❌ | ✅ while `premium_until > now` |
The value line: **the relay is free for what makes your wallet work; you pay to use The value line: **the relay is free for what makes your wallet work; you pay to use
it as your everyday Nostr relay.** Free users are never cut off from wallet features. it as your everyday Nostr relay.** Free users are never cut off from wallet features.
@@ -55,7 +55,9 @@ it as your everyday Nostr relay.** Free users are never cut off from wallet feat
Migration `..._premium.sql`: Migration `..._premium.sql`:
```sql ```sql
ALTER TABLE users ADD COLUMN premium_until TIMESTAMPTZ; -- NULL = never premium ALTER TABLE users ADD COLUMN premium_until TIMESTAMPTZ; -- NULL = never premium
CREATE INDEX users_premium_until_idx ON users (premium_until); -- No standalone index on premium_until: is_premium_npub keys on the existing UNIQUE
-- index on users.nostr_pubkey and `premium_until > NOW()` is a cheap per-row filter,
-- so an index here would only add write cost to every users UPDATE.
``` ```
A **dedicated `premium_invoices` table** (not a `purpose` column on `payment_invoices`), A **dedicated `premium_invoices` table** (not a `purpose` column on `payment_invoices`),
because premium binds on the authenticated `user_id` (a logged-in purchase) while because premium binds on the authenticated `user_id` (a logged-in purchase) while
@@ -101,14 +103,16 @@ loopback-admission requirement (non-open policies need the admission service).
## 6. Payment flow (recurring + tiered, reuses the PaymentProvider seam) ## 6. Payment flow (recurring + tiered, reuses the PaymentProvider seam)
- `POST /premium/invoice` (JWT auth), body `{ plan }` → mint an invoice for the - `POST /premium/invoice` (JWT auth), body `{ plan }` → mint an invoice for the
selected plan's amount via the PaymentProvider, atomically bound to the caller with selected plan's amount via the PaymentProvider, recorded in `premium_invoices`
`purpose='premium'` and the plan's `days`. Returns the invoice (pay URL / address). bound to the caller's `user_id` with the plan's `days`. Returns the invoice (pay
- Settle (pull model, same poller as pay-to-register): on a `premium` invoice URL / address).
settling, call `extend_premium(user_id, plan_days)` **once** (idempotent on invoice - `POST /premium/activate` (JWT auth), body `{ invoice_id }` → the client-driven
id). Never holds funds — pull-only. settle path (pull model, no background poller): verify the invoice is `Settled` at
the processor, then `activate_premium` (single-use consume + `premium_until` extend,
in one transaction). Never holds funds — status reads only.
- `GET /premium/status` (JWT auth) → `{ active, premium_until }`. - `GET /premium/status` (JWT auth) → `{ active, premium_until }`.
**Tiered plans (a discount feature we build + exercise).** `PREMIUM_PLANS` is a list **Tiered plans (a built-in discount feature).** `PREMIUM_PLANS` is a list
of `{ id, days, amount }`: of `{ id, days, amount }`:
| id | days | price | vs. quarterly | | id | days | price | vs. quarterly |
@@ -127,12 +131,11 @@ Config: `PREMIUM_ENABLED` (default false), `PREMIUM_CURRENCY`, `PREMIUM_PLANS`
`PREMIUM_ENABLED` ⇒ provider configured **and** `RELAY_ENABLED` **and** `PREMIUM_ENABLED` ⇒ provider configured **and** `RELAY_ENABLED` **and**
`RELAY_WRITE_POLICY=premium-post` **and** ≥1 valid plan (fail closed otherwise). `RELAY_WRITE_POLICY=premium-post` **and** ≥1 valid plan (fail closed otherwise).
## 6.5 Resource governance — the box is shared ## 6.5 Resource governance — a shared host
The reference instance runs the relay **on the same box as the wallet backend and the When the relay runs **on the same host as the wallet backend and the LWS scanners**,
LWS scanners**, so the relay must stay within a budget and never starve them. A paid it must stay within a budget and never starve them. A paid base is naturally bounded
base is naturally bounded (only paying users post general content), but we set hard (only paying users post general content); set hard limits regardless:
limits regardless:
- **Size + retention** — `RELAY_MAX_EVENT_BYTES` (64 KB) and `RELAY_RETENTION_DAYS` - **Size + retention** — `RELAY_MAX_EVENT_BYTES` (64 KB) and `RELAY_RETENTION_DAYS`
(30) cap storage; general (non-DM) content can carry a shorter retention than (30) cap storage; general (non-DM) content can carry a shorter retention than
gift-wraps if needed. gift-wraps if needed.
@@ -141,9 +144,9 @@ limits regardless:
monopolize CPU / DB / bandwidth. monopolize CPU / DB / bandwidth.
- **DB isolation** — the relay uses its own tables + retention job; the wallet - **DB isolation** — the relay uses its own tables + retention job; the wallet
backend's hot auth / LWS paths are untouched. backend's hot auth / LWS paths are untouched.
- **Watch + escape hatch** — the `paid-relay` recipe ships a suggested cap set and - **Escape hatch** — the `paid-relay` recipe ships a suggested cap set: if relay load
says: if relay load approaches the wallet backend's headroom, move the relay to its approaches the wallet backend's headroom, move the relay to its own host — the seam
own box — the seam already supports `RELAY_MODE=external`. already supports `RELAY_MODE=external`.
## 7. Capabilities + OpenAPI ## 7. Capabilities + OpenAPI
@@ -152,14 +155,14 @@ limits regardless:
so the client renders the plan tiers with the discount visible. The client greys the so the client renders the plan tiers with the discount visible. The client greys the
**general-Nostr-posting** affordance unless `premium.active`; wallet events + DMs **general-Nostr-posting** affordance unless `premium.active`; wallet events + DMs
stay available free. stay available free.
- OpenAPI: document `POST /premium/invoice` + `GET /premium/status`; the existing - OpenAPI: document `POST /premium/invoice`, `POST /premium/activate`, and
**OpenAPI no-drift check** must pass. Capabilities **golden test** updated to `GET /premium/status`; the existing **OpenAPI no-drift check** must pass.
include `premium_relay` + `premium`. Capabilities **golden test** updated to include `premium_relay` + `premium`.
## 8. Free ↔ premium enforcement points (defense in depth) ## 8. Free ↔ premium enforcement points (defense in depth)
1. **Relay admission (authoritative):** the gRPC hook denies non-premium *general* 1. **Relay admission (authoritative):** the gRPC hook denies non-premium *general*
writes to our relay regardless of client behavior (wallet events stay free). This writes to the relay regardless of client behavior (wallet events stay free). This
is the real gate. is the real gate.
2. **Client UX:** reads `premium.active` from `/premium/status` and disables the 2. **Client UX:** reads `premium.active` from `/premium/status` and disables the
affordance — a courtesy, never the enforcement. affordance — a courtesy, never the enforcement.
@@ -169,31 +172,36 @@ The client is never trusted; a user who bypasses the UI still hits admission-den
## 9. Migration / compatibility ## 9. Migration / compatibility
- Additive column; existing users get `premium_until = NULL` (free). No break. - Additive column; existing users get `premium_until = NULL` (free). No break.
- v0.2 backend unaffected. `PREMIUM_ENABLED=false` (default) → zero behavior change; - v0.2 backend unaffected. `PREMIUM_ENABLED=false` (default) → zero behavior change:
`/premium/*` return 404/feature-off and `/capabilities` omits `premium`. `POST /premium/invoice` and `/premium/activate` return **400** (`VALIDATION_ERROR`,
"Premium is not available on this instance."), `GET /premium/status` returns **200**
with `{ active: false, premium_until: null }`, and `/capabilities` omits the
`premium` object (`features.premium_relay: false`).
- The relay stays **off** until an operator opts in (`RELAY_ENABLED=true` + - The relay stays **off** until an operator opts in (`RELAY_ENABLED=true` +
`premium-post`); `api.smirk.cash` enables it as the reference paid relay. `premium-post`) — see the `examples/paid-relay/` recipe.
## 10. Testing (Ps and Qs) ## 10. Testing (Ps and Qs)
- **Unit** — `policy.rs`: PremiumPost permit (premium author), deny (lapsed / - **Unit** — `policy.rs`: PremiumPost permits a premium author (any kind), permits a
non-premium), gift-wrap delivery to premium recipient, PoW on external inbound. registered author's wallet kinds, denies a registered author's general post, and
- **Unit** — `extend_premium` stacks from `max(now, current)`; `is_premium_npub` delivers an external gift-wrap to a registered recipient (else denies).
respects expiry (peppered lookup). - **Unit** — config fail-closed matrix (relay / policy / plans / creds / 0-conf /
- **L2** — admission service: premium author PERMIT, lapsed author DENY. days cap) and the `PREMIUM_PLANS` parser.
- **L2** — settle handler: a `premium` invoice extends `premium_until` once (idempotent). - **Integration** — `activate_premium` is single-use, stacks from `max(now, current)`,
and rejects a cross-user invoice; `is_premium_npub` respects expiry (npub is public,
not peppered); the premium routes are feature-gated off by default.
- **Golden** — capabilities includes `premium_relay` + `premium` when enabled, omits when off. - **Golden** — capabilities includes `premium_relay` + `premium` when enabled, omits when off.
- **No-drift** — OpenAPI covers the two new endpoints. - **No-drift** — OpenAPI covers the three premium endpoints.
- **E2E** — pay invoice → post to Smirk relay PERMITted → fast-forward past expiry
→ post DENIED → renew → PERMITted again.
## 11. Open questions ## 11. Design decisions & limitations
1. **Grace period** on expiry (e.g. 3-day soft window) vs hard cutoff? (Lean hard; 1. **Expiry is a hard cutoff** — no grace window; early-renewal stacking
`extend_premium` early-renewal stacking softens it.) (`activate_premium` extends from `max(now, current)`) softens it.
2. **Read-gating** (NIP-42) — defer to v2; note it in the cookbook recipe. 2. **Writes are gated, reads stay open** — read-gating would need NIP-42 AUTH plus a
3. **Default price/period** for the reference instance — product call. premium check; a possible future addition.
4. **Refunds** — none (prepaid access to a service); state in ToS/recipe README. 3. **Prepaid periods, not auto-charge** — crypto has no card-on-file, so a member
re-pays each term or posting lapses (honest + simple).
4. **No refunds** — prepaid access to a service; stated in the recipe README / ToS.
## 12. Cookbook tie-in ## 12. Cookbook tie-in
+20 -20
View File
@@ -10,16 +10,17 @@ Each recipe is a directory with:
called out; copy the full `../../.env.example` and apply the deltas). called out; copy the full `../../.env.example` and apply the deltas).
- **`README.md`** — what it's for, what it costs to run, the legal/operational notes. - **`README.md`** — what it's for, what it costs to run, the legal/operational notes.
> **Status.** `personal/`, `public-free/`, and `paid-relay/` (the flagship — it > Each recipe below has its own directory with a tuned `.env.example` and a short
> uses the shipped [premium-relay feature](../docs/design/premium-relay.md)) have > README. `paid-relay/` is the flagship — it uses the shipped
> full `.env.example`s. `friends/`, `paid-access/`, and `community/` are summarized > [premium-relay feature](../docs/design/premium-relay.md).
> below and get their own directories on the next pass.
## Every recipe, the same three steps ## Every recipe, the same three steps
1. `cp ../../.env.example .env`, then apply the recipe's deltas. 1. `cp ../../.env.example .env`, then apply the recipe's deltas.
2. Generate each secret: `openssl rand -hex 32` (`JWT_SECRET`, `SEED_FINGERPRINT_PEPPER`, 2. Set `PUBLIC_API_URL` to your real domain (a placeholder is rejected at boot in
`REFRESH_TOKEN_PEPPER`, `IP_SALT`, `ALTCHA_HMAC_KEY`, `ADMIN_*_SECRET`). Fill chain production). Generate each secret: `openssl rand -hex 32` (`JWT_SECRET`,
endpoints. `chmod 600 .env`. `SEED_FINGERPRINT_PEPPER`, `REFRESH_TOKEN_PEPPER`, `IP_SALT`, `ADMIN_*_SECRET`, plus
`ALTCHA_HMAC_KEY` only if the recipe sets `FEATURE_POW=true`). Fill chain endpoints.
`chmod 600 .env`.
3. Run the binary (migrations apply on boot). Reverse-proxy TLS; bind the API to 3. Run the binary (migrations apply on boot). Reverse-proxy TLS; bind the API to
`127.0.0.1`. `127.0.0.1`.
@@ -30,12 +31,12 @@ funds. **Self-hosting bypasses every gate.** Secrets are never committed.
| Recipe | For | Registration | Relay | Pitch | Status | | Recipe | For | Registration | Relay | Pitch | Status |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| [`personal/`](personal/) | Just you | open, no gate | off (or open, just you) | Your own sovereign backend | ✅ boots today | | [`personal/`](personal/) | Just you | open, no gate | off (or open, just you) | Your own sovereign backend | ✅ set domain + secrets |
| [`public-free/`](public-free/) | A free public instance | **open + PoW** | off (v1) | What `api.smirk.cash` runs | ✅ boots today | | [`public-free/`](public-free/) | A free public instance | **open + PoW** | off (v1) | The shape a free public instance runs | ✅ set domain + secrets |
| `friends/` | Your circle | **invite-only** | on, `inbox-outbox` | A backend for people you trust | 📝 2nd pass | | [`friends/`](friends/) | Your circle | **invite-only** | on, `inbox-outbox` | A backend for people you trust | ✅ |
| `paid-access/` | A paid wallet service | **pay-to-register** | optional | Charge once for a hosted wallet backend | 📝 2nd pass | | [`paid-access/`](paid-access/) | A paid wallet service | **pay-to-register** | optional | Charge once for a hosted wallet backend | ✅ |
| `paid-relay/` | Monetize a relay | **open + PoW** (free wallet) | on, **`premium-post`** | Free wallet, paid ($/quarter) Nostr posting | ✅ premium-relay shipped | | [`paid-relay/`](paid-relay/) | Monetize a relay | **open + PoW** (free wallet) | on, **`premium-post`** | Free wallet, paid ($/quarter) Nostr posting | ✅ premium-relay shipped |
| `community/` | A project/DAO space | invite | on, `author-allowlist` | A gated relay for a named group | 📝 2nd pass | | [`community/`](community/) | A project/community space | invite | on, `author-allowlist` | A gated relay for a named group | ✅ |
## Recipe summaries (config deltas) ## Recipe summaries (config deltas)
@@ -52,8 +53,8 @@ funds. **Self-hosting bypasses every gate.** Secrets are never committed.
processor paying *your* wallet). A one-time access fee gates wallet creation. Non-custodial; processor paying *your* wallet). A one-time access fee gates wallet creation. Non-custodial;
you never touch funds (pull-model invoice reads only). you never touch funds (pull-model invoice reads only).
- **`paid-relay`** — free wallet (open + PoW) **plus** `PREMIUM_ENABLED=true`, - **`paid-relay`** — free wallet (open + PoW) **plus** `PREMIUM_ENABLED=true`,
`RELAY_ENABLED=true`, `RELAY_WRITE_POLICY=premium-post`, `PREMIUM_AMOUNT` / `RELAY_ENABLED=true`, `RELAY_WRITE_POLICY=premium-post`, `PREMIUM_CURRENCY` +
`PREMIUM_CURRENCY` / `PREMIUM_PERIOD_DAYS`. Recurring revenue for relay posting; reads `PREMIUM_PLANS` (`id:days:amount`). Recurring revenue for relay posting; reads
open (v1). **Content posture:** running a relay carries user content — see the NCMEC/DMCA open (v1). **Content posture:** running a relay carries user content — see the NCMEC/DMCA
clauses in the published Terms (§6, §9.1). Spec: [premium-relay](../docs/design/premium-relay.md). clauses in the published Terms (§6, §9.1). Spec: [premium-relay](../docs/design/premium-relay.md).
- **`community`** — `REGISTRATION_REQUIRE_INVITE=true`, `RELAY_ENABLED=true` + - **`community`** — `REGISTRATION_REQUIRE_INVITE=true`, `RELAY_ENABLED=true` +
@@ -61,8 +62,7 @@ funds. **Self-hosting bypasses every gate.** Secrets are never committed.
## Choosing ## Choosing
- Want **freedom**? `personal` (or `friends` for a group). - Solo, or a trusted group → `personal` / `friends`.
- Want a **free public good**? `public-free`. - A free public instance → `public-free`.
- Want it to **pay for itself**? `paid-relay` (recurring, real utility) or `paid-access` - Cost recovery → `paid-relay` (recurring, tied to a real service — the flagship) or
(one-time). `paid-relay` is the flagship — recurring revenue tied to a service people `paid-access` (a one-time signup fee).
actually value, and the live demo runs at `api.smirk.cash`.
+54
View File
@@ -0,0 +1,54 @@
# ── Recipe: community ────────────────────────────────────────────────────────
# A gated space for a named project / community: invite-only registration plus a
# relay that ONLY your registered members may publish to (author-allowlist). A
# members-only Nostr space with its own DM inbox.
#
# ⚠️ Running a relay hosts user content — see ../LEGAL_AND_OPS.md.
#
# Start from ../../.env.example and apply the lines below. openssl rand -hex 32
# for each secret.
# ─────────────────────────────────────────────────────────────────────────────
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
# REQUIRED: replace with YOUR real https domain. The your-domain.example placeholder
# is rejected at boot in production (Nostr identity is on by default).
PUBLIC_API_URL=https://api.your-domain.example/api/v1
ENVIRONMENT=production
DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql
JWT_SECRET=
SEED_FINGERPRINT_PEPPER=
REFRESH_TOKEN_PEPPER=
IP_SALT=
# ALTCHA_HMAC_KEY — only needed if you set FEATURE_POW=true (this recipe leaves PoW off).
ALTCHA_HMAC_KEY=
ADMIN_KEY_INTEGRITY_SECRET=
# Invite-only membership (mint codes with `smirk-admin mint-invite`).
POW_REQUIRED=false
REGISTRATION_REQUIRE_INVITE=true
REGISTRATION_REQUIRE_PAYMENT=false
WALLET_RESTORE_POLICY=unlimited
FEATURE_PRICES=true
FEATURE_TIPS=false
# Relay on, members-only: only registered members may publish anything.
RELAY_ENABLED=true
RELAY_MODE=bundled
RELAY_URL=wss://relay.your-domain.example
RELAY_WRITE_POLICY=author-allowlist
RELAY_INBOUND_POW_BITS=0
RELAY_ADMISSION_BIND=127.0.0.1:8090
# Chain endpoints — see ../../.env.example.
XMR_LWS_URL=
XMR_LWS_ADMIN_URL=
XMR_LWS_ADMIN_KEY=
XMR_DAEMON_URL=
WOW_LWS_URL=
WOW_LWS_ADMIN_URL=
WOW_LWS_ADMIN_KEY=
WOW_DAEMON_URL=
+9
View File
@@ -0,0 +1,9 @@
# community — a members-only space for a project or group
Invite-only registration plus a relay that ONLY your registered members may publish
to (`author-allowlist`) — a members-only Nostr space with its own DM inbox.
- **Config:** [`.env.example`](.env.example) — `REGISTRATION_REQUIRE_INVITE=true`,
`RELAY_ENABLED=true`, `RELAY_WRITE_POLICY=author-allowlist`.
- **Membership:** mint codes with `smirk-admin mint-invite`.
- **Relay:** you host member content — see [`../LEGAL_AND_OPS.md`](../LEGAL_AND_OPS.md).
+56
View File
@@ -0,0 +1,56 @@
# ── Recipe: friends ──────────────────────────────────────────────────────────
# A backend for a known group. Registration is invite-only (mint a code for each
# person you trust); the relay is on with the locked-down inbox-outbox policy so
# the group has a private DM inbox. No payment, generous restore.
#
# Start from ../../.env.example and apply the lines below. openssl rand -hex 32
# for each secret.
# ─────────────────────────────────────────────────────────────────────────────
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
# REQUIRED: replace with YOUR real https domain. The your-domain.example placeholder
# is rejected at boot in production (Nostr identity is on by default).
PUBLIC_API_URL=https://api.your-domain.example/api/v1
ENVIRONMENT=production
DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql
JWT_SECRET=
SEED_FINGERPRINT_PEPPER=
REFRESH_TOKEN_PEPPER=
IP_SALT=
# ALTCHA_HMAC_KEY — only needed if you set FEATURE_POW=true (this recipe leaves PoW off).
ALTCHA_HMAC_KEY=
ADMIN_KEY_INTEGRITY_SECRET=
# Invite-only — hand out codes with `smirk-admin mint-invite --count <n>`.
POW_REQUIRED=false
REGISTRATION_REQUIRE_INVITE=true
REGISTRATION_REQUIRE_PAYMENT=false
# A known group can restore as far back as they like.
WALLET_RESTORE_POLICY=unlimited
FEATURE_PRICES=true
FEATURE_TIPS=false
# Relay on, locked down: registered members publish their own events; anyone may
# deliver a gift-wrapped DM to a member. (Running a relay hosts user content —
# see ../LEGAL_AND_OPS.md.)
RELAY_ENABLED=true
RELAY_MODE=bundled
RELAY_URL=wss://relay.your-domain.example
RELAY_WRITE_POLICY=inbox-outbox
RELAY_INBOUND_POW_BITS=0
RELAY_ADMISSION_BIND=127.0.0.1:8090
# Chain endpoints — point at your own nodes / LWS. See ../../.env.example.
XMR_LWS_URL=
XMR_LWS_ADMIN_URL=
XMR_LWS_ADMIN_KEY=
XMR_DAEMON_URL=
WOW_LWS_URL=
WOW_LWS_ADMIN_URL=
WOW_LWS_ADMIN_KEY=
WOW_DAEMON_URL=
+10
View File
@@ -0,0 +1,10 @@
# friends — a backend for a known group
Invite-only registration plus a locked-down relay (`inbox-outbox`) so your circle
has a private DM inbox. No payment; generous restore.
- **Config:** [`.env.example`](.env.example) — `REGISTRATION_REQUIRE_INVITE=true`,
`RELAY_ENABLED=true`, `RELAY_WRITE_POLICY=inbox-outbox`.
- **Invites:** mint codes with `smirk-admin mint-invite --count <n>` and hand them out.
- **Relay:** you host user messages — review the content notes in
[`../LEGAL_AND_OPS.md`](../LEGAL_AND_OPS.md).
+61
View File
@@ -0,0 +1,61 @@
# ── Recipe: paid-access ──────────────────────────────────────────────────────
# A paid hosted wallet backend: a one-time fee gates NEW wallet creation (existing
# wallets and self-hosters bypass it). Non-custodial and pull-model — the backend
# only READS invoice status; funds go straight to your own wallet.
#
# ⚠️ Charging a fee has operational + legal implications — see ../LEGAL_AND_OPS.md.
#
# Start from ../../.env.example and apply the lines below. openssl rand -hex 32
# for each secret.
# ─────────────────────────────────────────────────────────────────────────────
SERVER_HOST=127.0.0.1
SERVER_PORT=8080
# REQUIRED: replace with YOUR real https domain. The your-domain.example placeholder
# is rejected at boot in production (Nostr identity is on by default).
PUBLIC_API_URL=https://api.your-domain.example/api/v1
ENVIRONMENT=production
DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql
JWT_SECRET=
SEED_FINGERPRINT_PEPPER=
REFRESH_TOKEN_PEPPER=
IP_SALT=
ALTCHA_HMAC_KEY=
ADMIN_KEY_INTEGRITY_SECRET=
# Pay-to-register: a settled invoice is required to create a new wallet. Compose
# with PoW if you like (FEATURE_POW is the master switch; POW_REQUIRED alone is
# inert). Returning wallets never pay again.
FEATURE_POW=true
POW_REQUIRED=true
REGISTRATION_REQUIRE_INVITE=false
REGISTRATION_REQUIRE_PAYMENT=true
# Processor (BTCPay-compatible; also served by xmrcheckout / wowcheckout). Price
# is fiat-denominated here (payable in any coin the processor offers).
PAYMENT_PROVIDER=btcpay
PAYMENT_PROVIDER_URL=https://pay.your-domain.example
PAYMENT_STORE_ID=
PAYMENT_API_KEY=
PAYMENT_AMOUNT=5
PAYMENT_CURRENCY=USD
PAYMENT_CONFIRMATIONS=1
PAYMENT_EXPIRES_MINUTES=60
WALLET_RESTORE_POLICY=bounded
WALLET_MAX_RESTORE_DEPTH_DAYS=365
FEATURE_PRICES=true
FEATURE_TIPS=false
RELAY_ENABLED=false
# Chain endpoints — see ../../.env.example.
XMR_LWS_URL=
XMR_LWS_ADMIN_URL=
XMR_LWS_ADMIN_KEY=
XMR_DAEMON_URL=
WOW_LWS_URL=
WOW_LWS_ADMIN_URL=
WOW_LWS_ADMIN_KEY=
WOW_DAEMON_URL=
+13
View File
@@ -0,0 +1,13 @@
# paid-access — a paid hosted wallet backend
A one-time fee gates creating a NEW wallet (existing wallets and self-hosters bypass
it). Non-custodial and pull-model: the backend only reads invoice status, and funds
go straight to your own wallet.
- **Config:** [`.env.example`](.env.example) — `REGISTRATION_REQUIRE_PAYMENT=true` +
the `PAYMENT_*` processor block (BTCPay-compatible; also served by xmrcheckout /
wowcheckout). Price is fiat-denominated, payable in any coin the processor offers.
- **You are a merchant, not a money transmitter** — you sell access to your service
and never hold user funds. Have clear Terms (what the fee buys, refunds, fair use),
treat the fees as income, and see [`../LEGAL_AND_OPS.md`](../LEGAL_AND_OPS.md).
- `PAYMENT_CONFIRMATIONS >= 1` (0-conf lets a paid invoice be reversed after signup).
+7 -4
View File
@@ -13,11 +13,12 @@
SERVER_HOST=127.0.0.1 SERVER_HOST=127.0.0.1
SERVER_PORT=8081 SERVER_PORT=8081
# REQUIRED: replace with YOUR real https domain. The your-domain.example placeholder
# is rejected at boot in production (Nostr identity is on by default).
PUBLIC_API_URL=https://api.your-domain.example/api/v1 PUBLIC_API_URL=https://api.your-domain.example/api/v1
ENVIRONMENT=production ENVIRONMENT=production
INSTANCE_ID=paid-relay-1
DATABASE_URL=postgresql:///smirk_v3_db?host=/var/run/postgresql DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql
# Secrets (REQUIRED; openssl rand -hex 32 each). # Secrets (REQUIRED; openssl rand -hex 32 each).
JWT_SECRET= JWT_SECRET=
@@ -28,6 +29,8 @@ ALTCHA_HMAC_KEY=
ADMIN_KEY_INTEGRITY_SECRET= ADMIN_KEY_INTEGRITY_SECRET=
# ── Free registration, PoW-gated (wallet use is always free) ── # ── Free registration, PoW-gated (wallet use is always free) ──
# FEATURE_POW is the master switch; POW_REQUIRED alone is inert without it.
FEATURE_POW=true
POW_REQUIRED=true POW_REQUIRED=true
REGISTRATION_REQUIRE_INVITE=false REGISTRATION_REQUIRE_INVITE=false
REGISTRATION_REQUIRE_PAYMENT=false REGISTRATION_REQUIRE_PAYMENT=false
@@ -58,8 +61,8 @@ PREMIUM_PLANS=quarter:90:5,halfyear:182:9,year:365:15
# directly to YOUR wallet. # directly to YOUR wallet.
PAYMENT_PROVIDER=btcpay PAYMENT_PROVIDER=btcpay
PAYMENT_PROVIDER_URL=https://pay.your-domain.example PAYMENT_PROVIDER_URL=https://pay.your-domain.example
PAYMENT_STORE_ID=your-store-id PAYMENT_STORE_ID=
PAYMENT_API_KEY=your-processor-api-key PAYMENT_API_KEY=
PAYMENT_CONFIRMATIONS=1 PAYMENT_CONFIRMATIONS=1
PAYMENT_EXPIRES_MINUTES=60 PAYMENT_EXPIRES_MINUTES=60
+21
View File
@@ -0,0 +1,21 @@
# paid-relay — free wallet, paid Nostr posting (the flagship)
A free wallet (open + PoW) **plus** a paid premium tier for **general** Nostr
posting to your relay. Wallet-functional events — encrypted DMs, tips, swap
coordination — stay **free** for registered users; premium unlocks general posting.
Recurring revenue tied to a real service. Implements the
[premium-relay feature](../../docs/design/premium-relay.md).
- **Config:** [`.env.example`](.env.example) — `RELAY_WRITE_POLICY=premium-post`,
`PREMIUM_ENABLED=true`, tiered `PREMIUM_PLANS` (a built-in discount curve), and the
`PAYMENT_*` processor block (reused from pay-to-register; BTCPay-compatible).
- **Free vs paid:** the relay is free for what makes the wallet work; you pay to use
it as your everyday relay. Reads stay open (v1); premium gates *writes*.
- **Money:** you sell access to your relay (a fee), non-custodial + pull-model —
never hold funds. `PAYMENT_CONFIRMATIONS >= 1`; validation rejects the unsafe combos.
- **Content:** running a relay means hosting user content — a DMCA designated agent,
a CSAM-reporting process, and acceptable-use terms apply. See
[`../LEGAL_AND_OPS.md`](../LEGAL_AND_OPS.md).
- **Resources:** the relay runs beside the wallet backend + LWS, so keep the size /
retention / connection / rate caps (in `.env.example` and the nostr-rs-relay config
under `deploy/relay`); move to `RELAY_MODE=external` on its own host if load climbs.
+3 -1
View File
@@ -7,9 +7,10 @@
SERVER_HOST=127.0.0.1 SERVER_HOST=127.0.0.1
SERVER_PORT=8080 SERVER_PORT=8080
# REQUIRED: replace with YOUR real https domain. The your-domain.example placeholder
# is rejected at boot in production (Nostr identity is on by default).
PUBLIC_API_URL=https://your-domain.example/api/v1 PUBLIC_API_URL=https://your-domain.example/api/v1
ENVIRONMENT=production ENVIRONMENT=production
INSTANCE_ID=my-personal-node
# Unix socket + peer auth (run the service as the DB-owning OS user) — no password. # Unix socket + peer auth (run the service as the DB-owning OS user) — no password.
DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql
@@ -19,6 +20,7 @@ JWT_SECRET=
SEED_FINGERPRINT_PEPPER= SEED_FINGERPRINT_PEPPER=
REFRESH_TOKEN_PEPPER= REFRESH_TOKEN_PEPPER=
IP_SALT= IP_SALT=
# ALTCHA_HMAC_KEY — only needed if you set FEATURE_POW=true (this recipe leaves PoW off).
ALTCHA_HMAC_KEY= ALTCHA_HMAC_KEY=
# No gates — it's just you. # No gates — it's just you.
+11
View File
@@ -0,0 +1,11 @@
# personal — your own sovereign backend
Run the backend for yourself alone: no registration gates, unlimited restore, the
relay optional. The smallest footprint; you are the only user.
- **Config:** [`.env.example`](.env.example) — all gates off,
`WALLET_RESTORE_POLICY=unlimited`, relay off.
- **Boot:** copy `.env.example` → `.env`, generate the secrets (`openssl rand -hex 32`),
fill your chain endpoints, run the binary (migrations apply on boot). Bind the API
to `127.0.0.1` behind your own TLS.
- Non-custodial: the backend never sees your seed or spend keys — funds stay on-chain.
+8 -5
View File
@@ -1,7 +1,7 @@
# ── Recipe: public-free ────────────────────────────────────────────────────── # ── Recipe: public-free ──────────────────────────────────────────────────────
# A free, open public instance — anyone can register and use the wallet; spam is # A free, open public instance — anyone can register and use the wallet; spam is
# gated by proof-of-work, not payment. This is (minus secrets) what the reference # gated by proof-of-work, not payment. This is (minus secrets) the shape a free,
# instance api.smirk.cash runs. # open public instance runs.
# #
# Start from ../../.env.example and apply the lines below. openssl rand -hex 32 # Start from ../../.env.example and apply the lines below. openssl rand -hex 32
# for each secret. # for each secret.
@@ -9,11 +9,12 @@
SERVER_HOST=127.0.0.1 SERVER_HOST=127.0.0.1
SERVER_PORT=8081 SERVER_PORT=8081
# REQUIRED: replace with YOUR real https domain. The your-domain.example placeholder
# is rejected at boot in production (Nostr identity is on by default).
PUBLIC_API_URL=https://api.your-domain.example/api/v1 PUBLIC_API_URL=https://api.your-domain.example/api/v1
ENVIRONMENT=production ENVIRONMENT=production
INSTANCE_ID=public-1
DATABASE_URL=postgresql:///smirk_v3_db?host=/var/run/postgresql DATABASE_URL=postgresql:///smirk?host=/var/run/postgresql
# Secrets (REQUIRED; openssl rand -hex 32 each). # Secrets (REQUIRED; openssl rand -hex 32 each).
JWT_SECRET= JWT_SECRET=
@@ -23,7 +24,9 @@ IP_SALT=
ALTCHA_HMAC_KEY= ALTCHA_HMAC_KEY=
ADMIN_KEY_INTEGRITY_SECRET= ADMIN_KEY_INTEGRITY_SECRET=
# Free registration, spam-gated by compute (ALTCHA proof-of-work). # Free registration, spam-gated by compute (ALTCHA proof-of-work). FEATURE_POW is
# the master switch; POW_REQUIRED alone is inert without it.
FEATURE_POW=true
POW_REQUIRED=true POW_REQUIRED=true
REGISTRATION_REQUIRE_INVITE=false REGISTRATION_REQUIRE_INVITE=false
REGISTRATION_REQUIRE_PAYMENT=false REGISTRATION_REQUIRE_PAYMENT=false
+12
View File
@@ -0,0 +1,12 @@
# public-free — a free, open public instance
Anyone can register and use the wallet; spam is gated by proof-of-work, not
payment. This is the shape a free, open public instance runs.
- **Config:** [`.env.example`](.env.example) — `POW_REQUIRED=true`, no invite/payment
gate, `WALLET_RESTORE_POLICY=bounded` + PoW-priced, relay off at launch.
- **Boot:** copy → `.env`, generate secrets, reuse your existing daemons / LWS, run
the binary. Front it with TLS; bind the API to `127.0.0.1`.
- No money changes hands, so there is no payment/consumer-sale surface — just have a
Terms + Privacy that disclose what the backend receives (see
[`../LEGAL_AND_OPS.md`](../LEGAL_AND_OPS.md)).