Compare commits

...

91 Commits

Author SHA1 Message Date
Jon Staab 43eaad1621 Differentiate checkout id/session id 2026-06-03 14:27:57 -07:00
Jon Staab 5e6d5ab7c4 Handle stripe's 50c minimum, avoid lost write 2026-06-03 14:14:54 -07:00
Jon Staab a9f66dc3e5 Fix not charging existing relays on reactivation 2026-06-03 11:29:24 -07:00
Jon Staab ffb1491f00 Void unattached invoice items when churning a tenant 2026-06-03 11:05:00 -07:00
Jon Staab 4dc8ea942d Hide stripe error, remove pdf qr 2026-06-03 10:50:22 -07:00
Jon Staab 0e18d4020a Restructure reconciliation to always reconcile oob payments 2026-06-03 10:36:06 -07:00
Jon Staab b702733559 Add checkout sessions for paying an invoice 2026-06-03 10:02:43 -07:00
Jon Staab 8c44d8cc0f Add backend skill 2026-06-02 15:11:19 -07:00
Jon Staab 3682d0606d Fix period tiling 2026-06-02 15:07:03 -07:00
Jon Staab 6b693e11d3 refactor billing endpoints to separate reads from reconciliation requests 2026-06-02 14:33:26 -07:00
Jon Staab 5e7aa7df10 Add frontend skill 2026-06-02 14:17:27 -07:00
Jon Staab 240304b302 Add rebuild-skill 2026-06-02 13:28:57 -07:00
Jon Staab 4bd469fd17 Fix small bugs 2026-06-02 13:17:05 -07:00
Jon Staab b331a806ca Update readme, move frontend build to build phase in dockerfile 2026-06-02 12:56:52 -07:00
Jon Staab 430f33383b Fix a few possible concurrency bugs 2026-06-02 10:16:14 -07:00
Jon Staab 1d5c825e15 Consolidate dockerfiles 2026-06-02 10:01:43 -07:00
Jon Staab bd5f4b1cd0 Frontend refactor 2026-06-02 09:24:27 -07:00
Jon Staab 08e59e3b40 Add draft invoices 2026-06-01 17:22:44 -07:00
Jon Staab 93bfe8e5a4 Show tenant links, remove plan from relay edit form 2026-06-01 16:37:39 -07:00
Jon Staab 55a0b69089 Convert inline error message to toast, tweak account page 2026-06-01 16:13:25 -07:00
Jon Staab 0b6302b66b Improve payment dialogs 2026-06-01 15:53:23 -07:00
Jon Staab fd38f9cbc0 Fix bolt11 reconciliation 2026-06-01 15:09:03 -07:00
Jon Staab 76fbee6be1 Round prorations to the nearest hour 2026-06-01 14:43:40 -07:00
Jon Staab 572f772ed1 Split payment setup into separate components 2026-06-01 14:39:58 -07:00
Jon Staab fed9387617 Fix login/tenant create race 2026-06-01 13:53:15 -07:00
Jon Staab 9171824ee5 Fix nip98 2026-06-01 13:31:00 -07:00
Jon Staab e4e0172972 Add agents stuff 2026-06-01 13:15:27 -07:00
Jon Staab 31c8e596a6 Avoid spammy payment DMs 2026-06-01 12:59:19 -07:00
Jon Staab f5403b6aef Massive user-story-oriented refactor 2026-06-01 12:38:58 -07:00
Jon Staab 0018a5d4f3 Improve transactionality, align invoice model with frontend 2026-05-29 14:31:58 -07:00
Jon Staab ae3e1c316e Track payment method 2026-05-29 12:24:39 -07:00
Jon Staab d5047dedb1 Add dunning 2026-05-29 11:32:06 -07:00
Jon Staab f7bd3e53fe Add snapshots to activity 2026-05-28 15:53:02 -07:00
Jon Staab eb0123abef Rename tenant fields to tenant_pubkey and plan to plan_id 2026-05-28 15:18:41 -07:00
Jon Staab 9f599d66be Clean up billing a bit 2026-05-28 14:34:19 -07:00
Jon Staab 72b30489b9 Add BillingPeriod helper 2026-05-28 13:20:17 -07:00
Jon Staab b11fb5dc25 Fix possible race condition related to billing an activity 2026-05-28 12:45:21 -07:00
Jon Staab 35d9aab02a Make infra module free functions 2026-05-27 17:26:47 -07:00
Jon Staab 0f47b483aa Update docs 2026-05-27 16:56:34 -07:00
Jon Staab cd70ca6654 Move renewed_at to tenant 2026-05-27 15:35:02 -07:00
Jon Staab f37bb55286 Significant refactor of activity reconciliation 2026-05-27 15:22:33 -07:00
Jon Staab 7a2baf6f82 Refactor billing to manage subscriptions/invoices internally 2026-05-27 10:27:13 -07:00
Jon Staab 28cd7b0a9a remove spec 2026-05-26 14:53:26 -07:00
Jon Staab 9d9192f681 Remove redundant relay.schema field 2026-05-25 16:15:00 -07:00
Jon Staab acf7ae8e0a Remove comments from env template 2026-05-25 14:47:52 -07:00
Jon Staab ebada70c0d Fix cors 2026-05-25 14:47:26 -07:00
Jon Staab 34d5e732f4 Add endpoint for paying an invoice so that users don't get expired qr codes 2026-05-25 11:09:24 -07:00
Jon Staab 384ddbd439 Sync frontend and backend 2026-05-22 11:03:55 -07:00
Jon Staab b4af2f3866 Update spec and readme 2026-05-22 10:15:52 -07:00
Jon Staab f8a0860045 Refactor billing/invoices a bit 2026-05-22 09:41:40 -07:00
Jon Staab 97b1bd9a02 Remove reconciliation step 2026-05-21 17:30:09 -07:00
Jon Staab e7c0e6fdbe Some lightning invoice refactoring 2026-05-21 17:19:35 -07:00
Jon Staab a998c9b833 Collapse multiple invoice tables into one 2026-05-21 16:23:20 -07:00
Jon Staab bf9a768b88 Inline some invoie route logic 2026-05-21 15:11:43 -07:00
Jon Staab f67ef5bca2 Move webhook handlers to stripe routes 2026-05-21 15:07:31 -07:00
Jon Staab c02d834fe0 Remove some stripe proxy methods 2026-05-21 14:29:38 -07:00
Jon Staab e6cbfb361e Simplify subscription syncing 2026-05-21 14:13:51 -07:00
Jon Staab 6d267ed339 Remove relay subscription item column 2026-05-20 11:16:02 -07:00
Jon Staab a654096f25 Refactor stripe module 2026-05-19 18:19:58 -07:00
Jon Staab b49d62f1dd Remove InvoiceLookupError 2026-05-19 17:29:47 -07:00
Jon Staab 2d5eb0ca84 Refactor commands 2026-05-19 17:20:00 -07:00
Jon Staab dde4b981b2 refactor query 2026-05-19 17:04:10 -07:00
Jon Staab 7134915665 Refactor infra 2026-05-15 14:30:26 -07:00
Jon Staab cfa52d739f Clean up relay validation 2026-05-15 13:15:57 -07:00
Jon Staab 6abe62b569 remove invoice auto collection on nwc_url update 2026-05-15 12:54:40 -07:00
Jon Staab cd7b84439e define defaults on the model, simplify create relay payload 2026-05-15 11:25:25 -07:00
Jon Staab 1c3e0d619a Refactor error handling 2026-05-15 11:07:27 -07:00
Jon Staab 5590b14074 Refactor api into different route files 2026-05-15 09:28:12 -07:00
Jon Staab 26f05e8b8f Add env struct 2026-05-14 15:33:28 -07:00
Jon Staab 066c91a4d1 Refactor bitcoin exchange rate fetching and wallet 2026-05-14 12:47:32 -07:00
userAdityaa 3ed021214a feat(infra): pass Blossom S3 config to Zooid with schema key prefix (#69)
Reviewed-on: coracle/caravel#69
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-13 15:47:08 +00:00
Jon Staab c0aff5f7cf Refactor billing module 2026-05-12 16:32:05 -07:00
Jon Staab c9c1dd2c4c Group subscription items by price 2026-05-12 15:53:17 -07:00
Jon Staab 679a56edc3 Add docker publish workflow 2026-05-12 14:48:50 -07:00
userAdityaa e7efd9d08b fix: stripe portal dead-end with callback return flow (#67)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-11 20:48:55 +00:00
userAdityaa 0151762362 chore: improve billing customer name using Nostr kind 0 with pubkey fallback (#66)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-08 22:52:13 +00:00
userAdityaa a79c43e17e feat: open payment modal immediately on relay plan upgrade (#64)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-07 18:35:24 +00:00
Jon Staab dbe25c372f Conflate id and schema 2026-05-05 17:47:13 -07:00
userAdityaa 80a86452d0 chore: encrypt tenant NWC URL at rest and stop secret exposure in tenant APIs (#58)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-05 20:42:12 +00:00
userAdityaa b1e3747ddb fix: manual Lightning payment reconciliation with Stripe invoice state (#54)
Reviewed-on: coracle/caravel#54
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 23:38:57 +00:00
userAdityaa 29f657635c fix: relay sync create/update classification to prevent false create mode on updates (#56)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-05-01 14:21:37 +00:00
userAdityaa 9556a34b19 fix: silent relay state drift when activity bus drops events (#53)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-29 18:36:36 +00:00
userAdityaa 3ecd285290 chore: prevent duplicate Lightning charges by adding durable invoice-level NWC payment guard (#51)
Reviewed-on: coracle/caravel#51
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-25 13:02:56 +00:00
userAdityaa 9f8fe7261f fix: add idempotency keys to all Stripe mutation calls (#49)
Reviewed-on: coracle/caravel#49
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-25 12:34:35 +00:00
userAdityaa 1aeb15971d fix: silent NWC auto-payment failure messaging in invoice.created fallback (#46)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-23 16:09:50 +00:00
userAdityaa 48f20dc1a5 fix: relay sync failures with delayed bounded retries (#45)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-23 15:32:09 +00:00
userAdityaa c261d8a146 fix: enforce relay member capacity limits from plan definitions (#43)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-22 20:56:03 +00:00
userAdityaa 21b36272b8 feat: add missing SQLite indexes for billing and API hot-path queries (#44)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-22 16:40:09 +00:00
userAdityaa a26bc1127d chore: strict Subdomain Validation with Detailed Error Messages (#42)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-21 13:09:37 +00:00
userAdityaa bc79da34cf feat: encourage payment setup for paid relays without making it required (#40)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-21 13:07:27 +00:00
userAdityaa 38e3a64312 feat: add confirmation dialog for relay deactivate/reactivate with explicit warnings (#41)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-20 21:25:23 +00:00
126 changed files with 9042 additions and 4894 deletions
+128
View File
@@ -0,0 +1,128 @@
---
name: backend
description: Architecture and conventions for the Caravel backend — an Axum + SQLite (sqlx) Rust service (edition 2024) with two long-running reactors. Covers the flat module map and where new code goes; the free-function query/command data layer (no repository objects) over a OnceLock global pool; the commit-then-publish activity-broadcast model the relay-sync and billing reactors hang off; auth that is structural (the AuthedPubkey NIP-98 extractor authenticates, but each handler must call require_admin/require_tenant itself — there is NO router-level authz, so a forgotten check fails OPEN); the web.rs response envelope; the env OnceLock singleton (every var required, panics at boot); the leaf integration wrappers (Stripe/NWC/Coinbase/Nostr/zooid) that billing.rs is the primary orchestrator for (though route handlers also call Stripe/Robot directly); and the clippy+build verification gate (prefer the backend-only just recipes over `just check`, which also compiles the frontend). Use this whenever working anywhere in backend/ — adding an endpoint, query, write, model, migration, config var, integration, or reactor — to follow house conventions and avoid fail-open auth, double-billing, and publish-before-commit traps.
---
# Caravel backend
This is the map of the Caravel backend: an Axum HTTP service plus two long-running reactors, persisting to SQLite via sqlx (the crate is edition 2024). It explains how the backend is organized and *why*, and points you at the modules for the *how* — reach for this for orientation and conventions, and reach for the modules it names for implementation detail. The deep, lookup-style material lives in `references/`.
One warning up front, because it is the single most dangerous wrong assumption you can carry into this codebase: **there is no router-level authorization.** Adding the `AuthedPubkey` extractor to a handler only proves *identity* — that the caller signed a valid NIP-98 event. If a handler then forgets to call a `require_*` helper, it is authenticated-but-open to *any* signed-in pubkey. Auth here fails **open**, not closed, so authorization is each handler's own explicit responsibility (`api.rs:13-15,101-137`).
Two more silent traps the body expands on, named here so you carry them in: `publish()` must happen **after** the transaction commits (never inside `with_tx`), or the reactors observe rows that might roll back; and double-billing is prevented by atomic guards rather than naive read-then-write — per-activity charges use a conditional `UPDATE ... WHERE billed_at IS NULL` claim (checking `rows_affected`) backed by a `UNIQUE` index on `invoice_item.activity_id`, while monthly renewals (whose items have `activity_id = NULL`) use a transaction-scoped read-then-write guard on the tenant's `renewed_at` marker re-read inside the same transaction.
## It's free functions and a global pool, not services/repositories
Internalize the actual shape before reaching for defaults, because the wrong default here (a `Service` or `Repository` holding a connection handle) is exactly what an agent reaches for:
- **There are no service/repository objects holding a pool or connection.** Data access is two modules of free functions — `query.rs` (all reads/SELECTs) and `command.rs` (all writes). Reads call `db::pool()` directly; writes either call `pool()` directly for single-statement updates or, for anything multi-step, run through the `db::with_tx` helper and operate on a `&mut Transaction` — instead of threading a handle through call sites (`query.rs:58-262`, `command.rs:14-705`, `db.rs:38-40,56-68`).
- **The pool and the activity broadcast channel are process-wide globals** in `OnceLock`s (`POOL`, `NOTIFY`), set once by `db::init()` at startup; reading before init or setting twice panics. This is deliberate — it is what lets `query`/`command` stay free functions instead of carrying a handle (`db.rs:15-54`).
- **The shared, application-scoped service container is `Api`** (it holds `billing`, `stripe`, `robot`). It is constructed once, wrapped in `Arc` in `Api::router()`, and installed as axum router state; handlers receive a cheaply-cloned reference as `State<Arc<Api>>` (the per-request cost is just a refcount bump, not a new instance). It is a thin authorization-and-orchestration surface, not a data handle (`api.rs:50-99`).
- **The crate is edition 2024**, which is required for the let-chains (`&&`-joined `let` patterns) in `infra.rs`. The `async |tx| { ... }` closures the `with_tx` callers use are *not* edition-gated — they were stabilized in Rust 1.85 and only need a recent toolchain, not edition 2024 specifically (`Cargo.toml:2-4`).
## The module map and where new code goes
The module map is flat under `backend/src` — one job per module: `api`, `billing`, `bitcoin`, `command`, `db`, `env`, `infra`, `models`, `query`, `robot`, `routes`, `stripe`, `wallet`, `web`. `backend` is a dual library+binary crate, so this same set of modules is declared in two roots: `lib.rs` declares them as `pub mod` (the library root, the public/canonical declaration) and `main.rs` re-declares them as private `mod` for the binary entry point (`lib.rs:1-14`, `main.rs:1-14`).
The layering, so you know the call direction: a route handler performs authorization via `Api` helpers (`require_admin` / `require_admin_or_tenant` / `require_tenant`) when needed, then calls `query` (reads) / `command` (writes) / `billing` (orchestration), which call `db`. The integration leaves (`stripe`/`wallet`/`bitcoin`/`robot`) are composed in two places: `billing.rs` holds its own `stripe`/`wallet`/`robot` for the reconciliation loop, while `Api` holds `stripe` and `robot` that route handlers invoke *directly* (e.g. `create_tenant` calls `api.robot.fetch_nostr_name` and `api.stripe.create_customer`; `create_stripe_session` calls `api.stripe.create_portal_session`) — so the leaves are not composed exclusively by billing (`routes/tenants.rs:76-94,263-280`, `billing.rs:25-33`).
Where a *new* thing goes:
- **An endpoint** → a handler fn in the matching `routes/*.rs` **and** a `.route(...)` line in `Api::router()`. Both files are required; "I added a handler but the route 404s" is the number-one gotcha here because the two live in different files (`api.rs:66-99`).
- **A read** → a free async fn in `query.rs`.
- **A write** → a free fn in `command.rs` (a single-statement write runs directly on `pool()`; a multi-step write that must be atomic is composed inside `db::with_tx` and publishes its `Activity` after commit).
- **A model or field** → `models.rs`, plus a numbered migration under `migrations/` (pre-release the change is squashed into the current `0001_init.sql` rather than appended).
- **A config var** → `env.rs`.
- **A third-party call** → the matching leaf module (`stripe`/`wallet`/`bitcoin`/`robot`, or the zooid sync in `infra.rs`).
The full per-module responsibility table, the exact `main()` bootstrap order, and the `lib.rs`/tests note are in [references/module-map-and-layering.md](references/module-map-and-layering.md).
## Request lifecycle: authenticate structurally, authorize explicitly, return an envelope
**Authentication is structural, and there is no middleware.** Adding the `AuthedPubkey(auth)` param to a handler *is* the entire auth mechanism — it is a NIP-98 `FromRequestParts` extractor, and its mere presence makes the route require a signed-in caller. Omitting it makes the route public; the public routes (`GET /plans`, `GET /plans/:id`) simply omit it (`api.rs:206-223`, `routes/plans.rs:9-16`).
**Authorization is the handler's explicit job** via `Api` helpers: `require_admin` / `require_tenant` / `require_admin_or_tenant` (403 on failure) and `get_tenant_or_404` / `get_relay_or_404` (load-or-404). Restating the fail-open *why*: identity is not permission, and the router gates nothing, so a handler that authenticates but never authorizes is open to any signed-in pubkey (`api.rs:103-153`).
The handler shape is fixed: params ordered `State<Arc<Api>>``AuthedPubkey(auth)``Path`/`Query`/`Json`; the body returns `web::ApiResult`; wrap infra/db/external errors with `.map_err(internal)?`; let `require_*`/`get_*_or_404` propagate with a bare `?`; tail with `ok(..)`/`created(..)` (`routes/tenants.rs:61-71,121-141`).
One ordering rule with a security reason. For a path-by-id resource owned by a tenant (a relay, an invoice), **fetch first, then authorize** against the loaded resource's `tenant_pubkey` — you need the row to know whose it is, and this intentionally returns 403 (not 404) to a non-owner of an *existing* resource. For tenant routes keyed by the tenant's own pubkey, the `Path` *is* the `tenant_pubkey`, so authorize on it first (`routes/relays.rs:29-37`, `routes/invoices.rs:19-32`, `routes/tenants.rs:61-71`).
The response envelope: success goes through `web::ok`/`created`/`res`, returning `{ data, code: "ok" }`; errors go through typed builders returning `{ error, code }`. Note the keys differ — `data` on success, `error` on failure. `unauthorized`/`forbidden`/`not_found`/`internal` hardcode their code; `bad_request`/`unprocessable` take a caller-supplied kebab-case domain code. Translate sqlite `UNIQUE` violations to 422 via `map_unique_error` rather than letting them 500 (`web.rs:31-129`, `routes/relays.rs:309-316`).
Flag one deliberate weakness so nobody "fixes" it: the NIP-98 check here is a **session-style variant**. It verifies kind 27235, the signature, and that the last `u` tag equals `SERVER_URL`, but it does **not** bind HTTP method/URL/query, payload hash, timestamp freshness, or keep a replay cache — a valid header is effectively a ~10-minute bearer token. This is intentional (fewer signing prompts); do not add per-request binding (`api.rs:157-203`, `README.md:128-137`).
The exact decode steps, the envelope field shapes, and the full in-use domain-error-code list are in [references/request-lifecycle-and-web.md](references/request-lifecycle-and-web.md).
## The data layer: query/command split, transactions, and the activity log
Reads live in `query.rs` (mostly free async fns over `db::pool()`; `list_plans`/`get_plan` are synchronous). Writes live in `command.rs`: simple single-row writes are free async fns over `db::pool()`, but multi-step writes run inside `with_tx()` and delegate to private `_tx` helpers taking `&mut Transaction`. Tenant-scoped reads take a `tenant_pubkey` param and filter on it; some are suffixed `_for_tenant` (`list_relays_for_tenant`, `list_invoices_for_tenant`) but several are not (`list_open_invoices`, `list_unbilled_invoice_items`, `list_billable_activity`), so the suffix is not a reliable marker of tenant scoping (`query.rs:89-96,165-218`, `command.rs:14-87`).
`with_tx` is the only transaction primitive: it runs an async closure with a `&mut Transaction`, commits on `Ok`, and rolls back **only** via `Transaction`'s `Drop` on `Err` — there is no explicit rollback. The consequence to respect: a closure that swallows an error and returns `Ok` will commit a partial write. Multi-step atomic writes compose private `*_tx` helpers (each taking `&mut Transaction` as its first param) inside one `with_tx` closure (`db.rs:60-68`, `command.rs:466-704`).
The core idiom is the **activity log plus commit-then-publish**: a mutation records an `Activity` row inside the transaction, the `*_tx` helper *returns* that `Activity`, and the public command calls `db::publish(activity)` **after** `with_tx` commits — so reactors only ever observe durable rows. Publishing inside the transaction is the trap, because a subscriber could then act on a row that rolls back (`command.rs:179-182`, `db.rs:47-54`).
**Idempotency and double-billing are prevented by atomic guards, not naive read-then-write checks,** and the guard differs by path. Per-activity charges use a conditional claim: `mark_activity_billed_tx` updates `WHERE billed_at IS NULL` and returns a bool (`rows_affected() > 0`) you must honor, backstopped by `UNIQUE(invoice_item.activity_id)`. Other monotonic flips guard on null markers: `mark_invoice_paid_tx` only flips while `paid_at IS NULL AND voided_at IS NULL`. Renewals are the exception — their line items have `activity_id = NULL`, so neither the WHERE-guard nor the UNIQUE index protects them; their sole protection is a transaction-scoped read-then-write that re-reads `renewed_at` inside the same `with_tx` and only writes if the period hasn't been renewed (`command.rs:279-335,563-630`, `migrations/0001_init.sql:111-112`).
Billing-lifecycle entities model state as **nullable timestamp markers, not status enums**: an invoice is open while `paid_at` and `voided_at` are both null, a tenant is churned once `churned_at` is set, a bolt11 is settled once `settled_at` is set. Filter on the timestamps; these billing tables have no status column. Relay status is the one exception — a free-form `TEXT` column (active/inactive/delinquent) with no `CHECK` and no Rust enum, guarded only by the `RELAY_STATUS_*` consts and filtered/branched on throughout the relay code (`models.rs:4-6,54-60,120-142`, `migrations/0001_init.sql:32,48-60`).
Two cross-cutting gotchas worth stating inline: boolean-ish columns are stored and typed as `i64` 0/1 (`policy_public_join`, the `*_enabled` flags, `synced`), not Rust `bool` — compare against 0/1; and plans are **not** a DB table (`list_plans`/`get_plan` are hardcoded, synchronous in-memory data), so adding a plan is a code edit, not a migration (`models.rs:86-94`, `query.rs:20-54`).
The per-table read helpers, the `Snapshot` enum, the strict-`<` historical lookups, the schema-squash migration rule, and the FK naming convention are in [references/data-layer-and-schema.md](references/data-layer-and-schema.md).
## Background reactors and the broadcast model
Two detached tokio tasks are launched from `main()` after `db::init()` and run for the life of the process alongside the axum server: `billing.start()` (time-driven) and `infra::start()` (event-driven) (`main.rs:56-67`).
**Billing is the hourly poller.** A tokio interval loop calls `reconcile_subscriptions()`, which sweeps all tenants and logs-and-continues per tenant so one failure never aborts the sweep. The same `reconcile_subscription(tenant, attempt_payment)` is shared by the worker (`true`) and the synchronous reconcile route (`false`) — parameterize shared reconciliation rather than duplicating it (`billing.rs:46-130`).
**Infra is the broadcast reactor, and the model for any new background reaction.** It calls `db::subscribe()` to the activity channel, runs a reconcile sweep on *startup* to recover work missed while the process was down, then loops on `recv()`. It must handle `RecvError::Lagged` by running a full reconcile sweep over the DB "pending" query — the channel is best-effort with capacity 64, so you cannot assume you saw every message; `Closed` ends the worker (`infra.rs:21-44`).
The two non-negotiable reactor rules, with their *why*:
- **The top-level reactor driver loops never crash the process on a failure.** The billing poll loop, the per-tenant sweep, and the infra `recv` loop each wrap their unit of work in a `tracing::error!`-logged guard with structured fields and continue (`sync_relay` is even infallible by design). Note this catch-and-continue is only at the driver level: inner batch loops (e.g. the per-activity loop, `reconcile_renewal`, `reconcile_relay_state`) propagate a per-item error via `?` and abandon the rest of the current batch — that error still bubbles up to the nearest wrapped driver, so the process stays alive, but the failing item aborts its enclosing batch rather than being skipped (`billing.rs:52-54,66-74,104-110`, `infra.rs:28-44,83-89`).
- **Correctness comes from the DB reconcile sweep, never from one-message-per-event.** `db::publish` silently drops when there are no subscribers, and the bounded channel drops on lag — the broadcast is a hint, the DB "pending" query is the source of truth (`db.rs:50-54`, `infra.rs:35-41`).
New background reactions should hook the publish/subscribe activity stream rather than adding a new poller; tuning knobs (poll interval, grace/DM windows, retry base/max/attempts) live as module-level consts at the top of the worker file (`db.rs:43-54`, `billing.rs:15-23`, `infra.rs:15-17`).
The relay-sync retry/backoff mechanics, the self-feeding `fail_relay_sync` loop, the POST-vs-PATCH `is_new` logic, the secret-never-stored detail, and the full billing dunning cascade are in [references/reactors-and-relay-sync.md](references/reactors-and-relay-sync.md).
## External integrations: Nostr, Stripe, Lightning/NWC, Coinbase, zooid
Every integration is a **leaf I/O wrapper** that speaks only to the third party, returns `anyhow::Result`, and knows nothing about the DB, routes, or domain (`stripe.rs`'s only `crate` import is `env`, for the API key). `stripe.rs` parses Stripe's JSON internally via a private `send_json -> Result<serde_json::Value>` helper, but its public methods hand back small typed results (e.g. `Result<String>`, `Result<Option<String>>`), not raw `serde_json::Value`. New external calls go in the matching leaf module (`stripe`/`wallet`/`bitcoin`/`robot`, or the zooid sync in `infra.rs`), never inline in a route or mixed with DB logic (`stripe.rs:1-5`, `wallet.rs:7-13`).
`billing.rs` is the **primary orchestrator** that composes integrations against the DB — notably the payment cascade (NWC auto-pay → out-of-band lightning check → Stripe card on file → manual DM), where a failing NWC or Stripe attempt records its error on the tenant but never aborts the cascade and the first success returns early. It is *not* the only place integrations are used: route handlers also call Stripe and the robot directly (e.g. `create_tenant` calls `api.robot.fetch_nostr_name` and `api.stripe.create_customer`; `create_stripe_session` calls `api.stripe.create_portal_session`). And a handler may invoke more than one billing method — `reconcile_tenant` calls both `sync_stripe_customer` and `reconcile_subscription`, `reconcile_invoice` calls both `ensure_bolt11_for_invoice` and `attempt_payment` — and those public billing methods are themselves orchestrators that fan out internally (`billing.rs:29-33,326-377`, `routes/tenants.rs:84-94,182-190`).
Sensitive at-rest values (a tenant's `nwc_url`) are NIP-44 **self-encrypted** with the robot's own keypair via `env.encrypt`/`decrypt` — at-rest confidentiality for the service, *not* a DM to the tenant — encrypted at the write boundary (the route) and decrypted only at point of use (billing). Outbound zooid calls are NIP-98 signed via `env.make_auth` (`env.rs:86-107`, `routes/tenants.rs:130-137`).
Two design intentions not to "fix": an off-session Stripe `PaymentIntent` is treated as **failed unless `status == "succeeded"`**, so the cascade falls through via two distinct paths — an off-session 3DS/authentication demand returns an HTTP 402 error caught earlier by `error_for_status`, while a 2xx response whose status is merely not `"succeeded"` is caught by the explicit status check (do not assume 3DS "comes back 2xx" — for off-session confirmed intents Stripe surfaces it as an HTTP error); and the zooid relay secret is generated fresh and sent **only on first sync** (`is_new`), so Caravel never stores relay secrets, which is why a re-sync must `PATCH`, not `POST` (`stripe.rs:104-106,135-143,194-227`, `infra.rs:168-243`).
The Stripe idempotency-key HMAC scheme, the currency-minor exponent table, Robot's publish-on-construct side effect, the relay-list cache TTL, and the per-integration error-string conventions are in [references/integrations.md](references/integrations.md).
## Config: the env singleton
All config is one process-wide `Env` struct in a `static OnceLock`, loaded once by `env::init()` in `main()` immediately after `dotenv`, *before* `db::init()` — the env → db → services order is load-bearing. Read config **only** through `crate::env::get()` (which returns `&'static Env`); never read `std::env::var` outside `env.rs` (`env.rs:8-20`, `main.rs:28-37`).
**Every variable is required** — there are no optional vars and no graceful degradation. `require_str`/`require_u16`/`require_csv` panic at boot on a missing, blank, or invalid value (and an invalid `ROBOT_SECRET` panics too). Adding an integration var without setting it crashes the process on boot rather than degrading (`env.rs:110-140`).
Adding a config var is **four coordinated edits**: a field on `Env`, a load line in `Env::load` with the right `require_*` helper, README docs, and `.env.template`. Do crypto/auth through `Env` methods (`encrypt`/`decrypt`, `make_auth`), not by reaching for the keys ad hoc (`env.rs:22-84`).
Two traps. NIP-98 host-affinity means `SERVER_URL` must *exactly* equal the client's `u` tag or every authenticated request 401s. And the README uses stale var names that don't exist in `env.rs`: its local-dev table lists `ADMINS` (real name `SERVER_ADMIN_PUBKEYS`) and `ZOOID_API_SECRET` (the backend has no such var — it consumes `ZOOID_API_URL` and signs zooid requests with `ROBOT_SECRET`), while the production `docker run` example sets `PLATFORM_NAME` (a frontend VITE var, not a backend `Env` field) — trust `env.rs` and `.env.template`, not the README (`api.rs:158-202`, `README.md:19,101-102` vs `env.rs:60,76`).
The full variable surface, the `DATABASE_URL`/`CARGO_MANIFEST_DIR` rewrite, and the CORS silent-drop are in [references/config-and-env.md](references/config-and-env.md).
## Building and verifying a change
The `justfile` is the canonical task runner; backend recipes `cd` into `backend/` and run one cargo command. The minimal diff-safe gate for a backend edit is `just build-backend` (`cargo build`) plus `just lint-backend` (`cargo clippy -- -D warnings`, where every warning is a hard error), plus `just test-backend` if the touched area has tests (`justfile:17-30`).
**For a backend-only change prefer the backend-scoped recipes (`just fmt-backend lint-backend build-backend test-backend`) over a full `just check`.** `just check` also runs against the frontend — `build` is `build-backend build-frontend` — so it compiles the frontend even for a backend-only edit. The backend crate is currently both fmt-clean (`cargo fmt --check` exits 0) and clippy-clean (`cargo clippy -- -D warnings` exits 0), so running fmt is fine; verify fmt state with `cargo fmt --check` rather than assuming drift (`justfile:39,43`).
There are currently **zero tests** in the backend — no `#[cfg(test)]`/`#[tokio::test]` under `backend/src`, no `tests/` dir — so `cargo test` and `cargo test api::tests::` both pass trivially with 0 tests run. A green `cargo test` does *not* mean your change is exercised. The scaffolding exists (the `tower`/`util` dev-dep for `ServiceExt::oneshot`, the `api` module path the `test-backend-api` filter expects), so new behavior should add tests under `api::tests::` and drive the `Router` via tower's `oneshot` (`justfile:26-27`, `Cargo.toml:29-30`).
`lint-backend` runs `cargo clippy -- -D warnings`, where every warning is a hard error; the crate currently lints clean, so keep your additions warning-free rather than churning unrelated code to silence nits (`justfile:20-21`).
## House style (brief)
- **Comments are minimal**, one line where possible; a doc comment states a function's *purpose*, not its implementation. There is one canonical place for any fact — model/field semantics in `models.rs` doc comments only, DB index rationale in migration SQL comments — so don't duplicate across layers (root `AGENTS.md:3-12`).
- **Naming.** FK columns are `{model}_{pk}` (`relay.tenant_pubkey`, and in `models.rs` `invoice_item.activity_id`, `bolt11.invoice_id`); a tenant's pubkey is `tenant_pubkey` except in already-tenant-scoped contexts like `tenant.pubkey` or `get_tenant(pubkey)` (root `AGENTS.md:16,18`). Separately, some tenant-scoped query/command fns are suffixed `_for_tenant` (a codebase convention in `query.rs`/`command.rs`, not stated in `AGENTS.md`; see the data-layer reference for why it is not a reliable marker).
- **Rust idioms.** Prefer `&str` over `&String` params; avoid passing `&mut` into functions — return results and let the caller manage mutability; resist over-DRY — extract only for a distinct concern, 3+ repetitions, or genuine clarity (the inline zooid body and the longhand `update_relay` merge are deliberate) (root `AGENTS.md:30,32,34`).
- **Markdown.** Do not hard-wrap at a fixed column — write one logical line per paragraph (root `AGENTS.md:24-26`).
@@ -0,0 +1,59 @@
# Config and the env singleton (deep detail)
This is the lookup-depth companion to the SKILL.md "config" section: the full variable surface, the `require_*` helpers, the `DATABASE_URL` rewrite, and the stale-README traps. All of it lives in `env.rs`, `db.rs`, `main.rs`, `api.rs`, `.env.template`, and the README.
## The variable surface
`Env` is `#[derive(Clone)]` with 24 fields plus a parsed `keys: Keys`. The full grouped surface:
- **server** — `SERVER_URL`, `SERVER_PORT` (u16), `SERVER_ADMIN_PUBKEYS` (csv), `SERVER_ALLOW_ORIGINS` (csv), `APP_URL`, `DATABASE_URL`
- **robot identity** — `ROBOT_SECRET` (parsed into `Keys`), `ROBOT_NAME`, `ROBOT_DESCRIPTION`, `ROBOT_PICTURE`, `ROBOT_WALLET`, `ROBOT_OUTBOX_RELAYS`, `ROBOT_INDEXER_RELAYS`, `ROBOT_MESSAGING_RELAYS`
- **zooid/livekit** — `ZOOID_API_URL`, `RELAY_DOMAIN`, `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`
- **blossom S3** — `BLOSSOM_S3_ENDPOINT`, `BLOSSOM_S3_REGION`, `BLOSSOM_S3_BUCKET`, `BLOSSOM_S3_ACCESS_KEY`, `BLOSSOM_S3_SECRET_KEY`
- **billing** — `STRIPE_SECRET_KEY`
Source: `env.rs:22-82`, `.env.template:1-38`.
## The require_* helpers
- `require_str` reads the var, panics `"{key} is required"` if unset, trims it, and panics again if the trimmed value is empty.
- `require_u16` calls `require_str` then `.parse()`, panicking `"{key} is invalid"` on a parse failure.
- `require_csv` uses `std::env::var(key).unwrap_or_default()`, splits on `,`, trims each element, drops empties, and panics `"{key} is required"` if the result is empty — so an *unset* csv var and a *present-but-all-blank* one both say "required", which can mislead debugging.
`ROBOT_SECRET` is parsed via `Keys::parse(...).expect(...)`, so an invalid key panics too. Pick the helper by type: scalars use `require_str`, the port uses `require_u16`, lists use `require_csv`. Source: `env.rs:53-55,110-140`.
## Field semantics worth noting
- `SERVER_URL` is the NIP-98 host-affinity value — the `u` tag must equal it exactly or every authenticated request 401s.
- `SERVER_PORT` binds `127.0.0.1` only (localhost, not `0.0.0.0`).
- `SERVER_ADMIN_PUBKEYS` is checked by membership for `is_admin`.
- `SERVER_ALLOW_ORIGINS` is parsed into `CorsLayer` `HeaderValue`s, with unparseable origins **silently dropped** via `filter_map(...ok())` — a typo won't error at startup, it just won't allow that origin and surfaces later as a browser CORS block.
- `APP_URL` is the only var trailing-slash-trimmed at load (`zooid_api_url` is trimmed at use).
Source: `api.rs:104,158-202`, `main.rs:44-65`, `env.rs:62`.
## DATABASE_URL normalization
`normalize_sqlite_url` rewrites a relative `sqlite://<rel>` to `sqlite://{CARGO_MANIFEST_DIR}/<rel>` — and `CARGO_MANIFEST_DIR` is baked in at **compile** time via `env!`, so the path resolves relative to the build-time backend crate dir, not the process cwd. `:memory:`, absolute, and non-sqlite URLs pass through unchanged; the parent dir is `create_dir_all`'d; connection uses `create_if_missing` plus WAL plus `./migrations`. In the Docker image the backend is compiled with `WORKDIR /app`, so `CARGO_MANIFEST_DIR` = `/app`; the documented **relative** `DATABASE_URL=sqlite://data/caravel.db` therefore resolves to `/app/data/caravel.db`, lining up with the `-v my-caravel-data:/app/data` volume mount. The resolution comes from the build-time crate dir, not the runtime working directory, and the deployment URL is relative (not absolute). Source: `db.rs:70-108`, `Dockerfile:6`, `README.md:18,23`.
## Adding a config var, and doing crypto through Env
Adding a var is four coordinated edits: an `Env` field, a load line in `Env::load` with the right `require_*` helper, README docs, and `.env.template`. Do crypto/auth through `Env` methods (`encrypt`/`decrypt`, `make_auth`), not by reaching for `keys` ad hoc. Normalize URL-shaped config at the edge the way existing code does. Source: `env.rs:22-107`.
## Stale-README traps to ignore
- The README's local-dev table uses `ADMINS` (the real var is `SERVER_ADMIN_PUBKEYS`) and `ZOOID_API_SECRET` (no such var — zooid auth is `ROBOT_SECRET` via NIP-98).
- The README docker example sets `PLATFORM_NAME`, which is a frontend VITE var, not a backend `Env` field.
- Trust `env.rs` and `.env.template`, not the README.
- `.env` is gitignored (root `.env` and `**/.env`), as are `data/` and `target/`; there is no backend-level `.gitignore`.
Source: `README.md` vs `env.rs:54,60,76`, `.gitignore:5-8`.
## Sources
- variable surface — `backend/src/env.rs:22-82`, `backend/.env.template:1-38`
- require_* helpers — `backend/src/env.rs:53-55,110-140`
- field semantics — `backend/src/api.rs:104,158-202`, `backend/src/main.rs:44-65`, `backend/src/env.rs:62`
- DATABASE_URL normalization — `backend/src/db.rs:70-108`
- adding a var + crypto via Env — `backend/src/env.rs:22-107`
- stale-README traps — `README.md` vs `backend/src/env.rs:54,60,76`, `.gitignore:5-8`
@@ -0,0 +1,56 @@
# Data layer and schema (deep detail)
This is the lookup-depth companion to the SKILL.md "data layer" section: read-helper assembly, transaction-helper conventions, the idempotency idioms in full, the `Snapshot` enum, the i64/timestamp modeling, the strict-`<` historical reads, and the schema/migration rules. All of it lives in `query.rs`, `command.rs`, `models.rs`, `db.rs`, and `migrations/0001_init.sql`.
## Read assembly
Reads use `query_as::<_, T>` with `T` deriving `sqlx::FromRow`, returning typed structs (`Tenant`, `Relay`, `Activity`, `Invoice`, `InvoiceItem`, `Bolt11`). The `SELECT *` body is built by per-table `select_tenant`/`select_relay`/`select_activity` string helpers that append a trailing `WHERE`/`ORDER` clause; one-off reads inline the SQL but still go through `query_as`. Tenant-scoped reads take a `tenant_pubkey` param and filter on `tenant_pubkey`, but the `_for_tenant` suffix is **not** a reliable marker of tenant scoping: only two reads carry it (`list_relays_for_tenant`, `list_invoices_for_tenant`), while `list_open_invoices`, `list_unbilled_invoice_items`, and `list_billable_activity` are tenant-scoped without the suffix. (Note also that `list_plans`/`get_plan` are synchronous, not async.) Source: `query.rs:6-16,58-262`.
## Transaction conventions
`with_tx` is the only primitive: it begins a tx on the pool, runs the async closure with a `&mut Transaction`, commits on `Ok`, and relies on `Transaction`'s `Drop` to roll back on `Err` — there is no explicit `rollback()` call, so a closure that swallows an error and returns `Ok` will commit a partial write. Source: `db.rs:60-68`.
The `*_tx` helpers are private, suffixed `_tx`, take `&mut Transaction` as their first param, and run via `.execute(&mut **tx)`. Public commands compose them inside one `with_tx` closure and never take a transaction. A `*_tx` that records a state change *returns* the constructed `Activity`, and the public command publishes it after commit. Some single-statement writes run directly on `pool()` with no transaction at all: `create_tenant`, `update_tenant`, the `set_tenant_*` setters, `mark_invoice_notified`, and `insert_bolt11`. Source: `command.rs:14-82,146-183,466-704`, `db.rs:60-68`.
## The idempotency idioms in full
- `mark_activity_billed_tx` runs `UPDATE ... WHERE id = ? AND billed_at IS NULL` and returns `rows_affected() > 0` — a bool you must honor, because ignoring a `false` and inserting the invoice item anyway hits the `UNIQUE(activity_id)` index and fails.
- `insert_invoice_item_for_activity` claims the activity first and only inserts the line item when the claim won, so a concurrent reconcile never double-bills.
- Conditional monotonic `UPDATE`s are guarded on null markers: `mark_invoice_paid_tx`, `mark_bolt11_settled_tx`, and `void_open_invoices_tx`.
- `insert_intent_tx` records the Stripe `PaymentIntent` with `INSERT ... ON CONFLICT(id) DO NOTHING`, making settlement idempotent on retried webhooks.
- Renewal re-reads `renewed_at` inside the tx and advances it, so it is idempotent per period.
- `UNIQUE(invoice_item.activity_id)` is the database backstop behind the claim.
- `insert_activity_tx` requires the relay row to already exist in the same tx — it fetches the relay's `tenant_pubkey` — so insert/update the relay before logging.
Source: `command.rs:279-335,563-704`, `migrations/0001_init.sql:111-112`.
## The Snapshot type
`Snapshot` is a serde-tagged enum keyed on `resource_type`, with one variant per resource that logs activity, wrapped in `sqlx::types::Json` on insert. Each `Activity` carries a JSON snapshot of the resource's plan+status. Add a variant (and its `resource_type()`) when a new resource type starts logging activity. Source: `models.rs:8-24`, `command.rs:174-178`.
## Timestamp-vs-enum modeling and the i64 convention
Lifecycle is nullable timestamp markers, not status enums: an invoice is open while `paid_at` and `voided_at` are both null, a tenant is churned once `churned_at` is set, a bolt11 is settled once `settled_at` is set; `invoice.method` records provenance only when paid. Filter on the timestamps. Relay status is the exception — a free-form `TEXT` column with **no** `CHECK` constraint and no Rust enum, guarded only by the `RELAY_STATUS_*` consts (by contrast `invoice.method` *does* have a `CHECK`), so a typo'd status string would persist silently. Boolean-ish columns are `i64` 0/1, not Rust `bool``list_relays_pending_sync` uses `synced = 0 OR TRIM(sync_error) != ''`. Source: `models.rs:54-94,120-142`, `query.rs:81-121,206-240`, `migrations/0001_init.sql:32,48-60`.
## Strict-`<` historical lookups
`get_relay_plan_before` / `get_latest_relay_activity_before` use `created_at < before` (strict `<`, not `<=`) when reconstructing historical relay state from the activity log. A relay created exactly at a period boundary is intentionally not counted active in the prior period (its own creation/change charge covers that period); using `<=` would double-charge the creation period. `list_billable_activity` does **not** use a timestamp boundary at all — it has no `before` param and selects a tenant's unbilled activity via the `billed_at IS NULL` marker (plus an `activity_type` filter), reconciling off a precise marker rather than a timestamp watermark. Source: `query.rs:108-121,206-218,225-240`.
## Schema and migration rules
The whole schema lives in a single migration, `0001_init.sql`. Pre-release, schema changes are **squashed** into that file rather than appended as new files; migrations become append-only only after release. `db::init` runs create-if-missing plus WAL plus `./migrations`. Relative `sqlite://` `DATABASE_URL` paths are rewritten against `CARGO_MANIFEST_DIR` (the compile-time backend crate dir), not the process cwd. FK columns are named `{model}_{pk}`. Plans are hardcoded in-memory and synchronous — `list_plans`/`get_plan` are not a DB table, so a new plan is a code edit, not a migration. Source: `db.rs:70-108`, `migrations/0001_init.sql:1-115`, `query.rs:20-54`, root `AGENTS.md:20-22`.
## A few "Ok with no write" cases
`create_invoice` and `insert_invoice_items_for_renewal` can legitimately return `Ok` with no write: a non-positive outstanding balance returns `Ok(None)` (credit carries forward) and empty renewal items returns early `Ok(())`. Don't treat a missing invoice as an error. `insert_bolt11` uses `INSERT ... RETURNING *` with `fetch_optional` and returns `Option<Bolt11>`, so handle the `Option` rather than unwrapping. `set_relay_status_tx` (and `update_relay`) always reset `synced = 0` as a side effect of any status/field change, re-queuing the relay for the infra reactor. Source: `command.rs:185-223,300-335,443-464`.
## Sources
- read assembly + `_for_tenant` suffix — `backend/src/query.rs:6-16,58-262`
- transaction conventions — `backend/src/command.rs:14-82,146-183,466-704`, `backend/src/db.rs:60-68`
- idempotency idioms — `backend/src/command.rs:279-335,563-704`, `backend/migrations/0001_init.sql:111-112`
- `Snapshot``backend/src/models.rs:8-24`, `backend/src/command.rs:174-178`
- timestamp/i64/status modeling — `backend/src/models.rs:54-94,120-142`, `backend/src/query.rs:81-121`, `backend/migrations/0001_init.sql:32,48-60`
- strict-`<` historical reads (and the marker-based `list_billable_activity`) — `backend/src/query.rs:108-121,206-218,225-240`
- schema/migration rules — `backend/src/db.rs:70-108`, `backend/migrations/0001_init.sql:1-115`, `backend/src/query.rs:20-54`, root `AGENTS.md:20-22`
- Ok-with-no-write + side effects — `backend/src/command.rs:185-223,300-335,443-464`
@@ -0,0 +1,45 @@
# External integrations (deep detail)
This is the lookup-depth companion to the SKILL.md "external integrations" section: per-leaf behavior, the Stripe idempotency/error/currency details, the NWC per-call pattern, Robot's caches and side effects, the at-rest encryption, and the payment cascade. All of it lives in `stripe.rs`, `wallet.rs`, `bitcoin.rs`, `robot.rs`, `env.rs`, `infra.rs`, and `billing.rs`.
## Stripe leaf
A thin `reqwest` wrapper, no SDK. `get()`/`post()` build a `RequestBuilder` against the Stripe API and attach `.bearer_auth(&stripe_secret_key)` on every call. The `StripeRequest` trait provides `send_ok()` (runs `error_for_status`) and `send_json()`; all methods end in `.send_json()`. `error_for_status` parses Stripe's JSON error envelope into `message [type-or-code] (param: ...)`, falling back to the raw body.
The `Idempotency-Key` is `HMAC-SHA256(stripe_secret_key, parts joined by ':')` with a stable per-operation prefix: `create_customer` keys on `[create_customer, tenant_pubkey]`; the charge (`create_payment_intent`) keys on `[payment_intent, invoice_id, payment_method_id]``payment_method_id` is in the key on purpose, so a fall-back to a different card for the same invoice produces a distinct key instead of colliding with (and replaying) the original charge. Reuse `idempotency_key()` with a descriptive prefix for any new mutating call; `get_saved_payment_method` and `create_portal_session` send no idempotency key.
`create_payment_intent` posts `off_session=true`, `confirm=true`, and **requires** `status == "succeeded"`, so the billing cascade falls through via **two distinct paths**: (1) an off-session 3DS/authentication demand is returned by Stripe as an HTTP 402 error, caught earlier by `error_for_status` (do **not** assume `requires_action`/3DS "comes back 2xx" — for off-session confirmed intents Stripe surfaces it as an HTTP error, as the file's own doc comment states); (2) a 2xx response whose status is merely not `"succeeded"` is converted to `Err` by the explicit status check. Don't relax this expecting Stripe to retry off-session. `get_saved_payment_method` returns the **first** card listed (no Stripe-default notion). `create_portal_session` is called directly from the route handler, not via billing. Source: `stripe.rs:1-225` (3DS-as-HTTP-error doc at `104-106`; status check at `135-143`; `error_for_status` at `194-227`), `routes/tenants.rs:263-280`.
## Wallet (NWC) leaf
A parsed `NostrWalletConnectURI` with a short-lived **new-then-shutdown-per-call** pattern: every method does `NWC::new(...)`, awaits the op, then `nwc.shutdown()`. Nothing is pooled across awaits. `is_settled` treats an invoice as settled if `state == Settled` *or* `settled_at.is_some()`; `make_invoice` takes msats + description + expiry and returns a bolt11; `pay_invoice` takes a bolt11. Source: `wallet.rs:7-61`.
## Bitcoin (Coinbase) leaf
Converts fiat-minor to msats: it fetches the Coinbase spot price for `BTC-<CURRENCY>`, divides minor units by `10^exponent`, then `/ price * 1e11`, rounded to `u64`. `currency_minor_exponent` encodes Stripe's currency table (0 decimals for the zero-decimal currencies, 3 for BHD/JOD/KWD/OMR/TND, 2 for any other 3-letter alpha code) and must stay aligned with what Stripe expects. Trap: the Coinbase client is built with **no timeout** (unlike the 5s zooid and robot clients), so a hung response can stall `fiat_to_msats` indefinitely. The Stripe charge currency is hardcoded `"usd"` in billing despite both modules supporting arbitrary currencies, so a non-USD invoice would be charged as a USD-minor amount. Source: `bitcoin.rs:5-50`, `billing.rs:406-416`.
## Robot leaf
The service's Nostr identity, built on `env::get().keys`. `Robot::new()` has a **network side effect** — it `publish_identity().await?` (kind 0 metadata, kind 10002 outbox, kind 10050 messaging relays) before returning, so it is not a pure constructor and it propagates relay send errors. `send_dm` discovers the recipient's relays (kind 10002, then kind 10050) and then NIP-17 `send_private_msg`s; empty relay lists are an error. `fetch_nostr_name` swallows errors via `.ok()` and returns `Option`, so a relay outage looks identical to "no name set" (callers fall back to the first 8 chars of the pubkey). Relay-list caches are positive-only with a 5-minute TTL and cache even an empty `Vec`, so a recipient who just published 10002/10050 keeps failing `send_dm` for up to 5 minutes. Source: `robot.rs:11-211`.
## At-rest encryption
`env.encrypt`/`decrypt` are NIP-44 v2 **self-encryption** — the robot's own secret and public key, sender equals recipient — so they provide at-rest confidentiality for the service, not a DM the tenant can read. A tenant's `nwc_url` is encrypted at the route on write and decrypted only at point of use in billing. Outbound zooid auth is NIP-98 via `env.make_auth`, the sole zooid auth mechanism. Source: `env.rs:86-107`, `routes/tenants.rs:130-137`, `billing.rs:381`.
## The payment cascade
`attempt_payment` (the cascade orchestrator, defined in `billing.rs`) runs the cascade in order: (1) NWC auto-pay (decrypt `nwc_url` → per-call `Wallet`), (2) out-of-band lightning `is_settled` via the robot wallet, (3) Stripe card on file, (4) manual DM link. A failing NWC or Stripe attempt records its error on the tenant but never aborts the cascade, and the first success returns `Ok`. Lightning pricing flows through `bitcoin::fiat_to_msats(amount, "usd")` in `ensure_bolt11_for_invoice`, which mints a 3600s bolt11. Note `billing.rs` is *not* the only place integrations are used: route handlers call Stripe and the robot directly too (e.g. `create_tenant` calls `api.robot.fetch_nostr_name` and `api.stripe.create_customer`; `create_stripe_session` calls `api.stripe.create_portal_session`). And a handler may invoke **more than one** billing method — `reconcile_tenant` calls both `sync_stripe_customer` and `reconcile_subscription`, `reconcile_invoice` calls both `ensure_bolt11_for_invoice` and `attempt_payment` — and those public billing methods are themselves orchestrators that fan out internally, not leaf methods. Source: `billing.rs:29-33,326-377,400-502`, `routes/tenants.rs:84-94,182-190`, `routes/invoices.rs:54-62`.
## Per-integration error-string convention
Error-string quality varies, so prefer actionable strings when adding calls. Stripe builds `message [code] (param)` (actionable); zooid builds `method path returned status: body` (actionable). But the leaves are not uniform: NWC's `make_invoice`/`is_settled` add `anyhow!` context (`failed to create invoice: {e}` / `failed to lookup invoice: {e}`), while `pay_invoice` passes the raw error through unchanged (`anyhow!("{e}")`); and Coinbase only wraps the price-*parse* failure (`invalid BTC spot quote for {currency}: {e}`) — a non-2xx Coinbase API response is surfaced as a bare `reqwest` `error_for_status()` error with no added context. (NWC lives in `wallet.rs`, not the files cited below.) Source: `stripe.rs:191-225`, `infra.rs:289-293`, `bitcoin.rs:24-33`, `wallet.rs:39,47,59`.
## Sources
- Stripe leaf — `backend/src/stripe.rs:1-225`, `backend/src/routes/tenants.rs:263-280`
- Wallet (NWC) leaf — `backend/src/wallet.rs:7-61`
- bitcoin leaf — `backend/src/bitcoin.rs:5-50`, `backend/src/billing.rs:406-416`
- Robot leaf — `backend/src/robot.rs:11-211`
- at-rest encryption — `backend/src/env.rs:86-107`, `backend/src/routes/tenants.rs:130-137`, `backend/src/billing.rs:381`
- payment cascade — `backend/src/billing.rs:29-33,326-377,400-502`
- error-string convention — `backend/src/stripe.rs:191-225`, `backend/src/infra.rs:289-293`, `backend/src/bitcoin.rs:24-33`, `backend/src/wallet.rs:39,47,59`
@@ -0,0 +1,66 @@
# Module map and layering (deep detail)
This is the lookup-depth companion to the SKILL.md "module map" section: the full per-module responsibility map, the exact `main()` bootstrap and spawn order, the layering direction with concrete traces, and the test-target note. It exists so the SKILL.md "where things live" section can stay prose-only.
## The flat module map
Everything lives flat under `backend/src`, one job per module. `backend` is a dual library+binary crate, so this same set of modules is declared in two roots: `lib.rs` declares them as `pub mod` (the library root, the public/canonical declaration) and `main.rs` re-declares them as private `mod` for the binary entry point:
- **`api`** — the router, the `Api` service container, the authorization helpers (`require_*`, `get_*_or_404`, `is_admin`), and the `AuthedPubkey` NIP-98 extractor.
- **`billing`** — the orchestrator: it composes the integration leaves against the DB to reconcile activity into invoice items, renew subscriptions, and collect payment.
- **`bitcoin`** — the Coinbase fiat↔msats conversion leaf.
- **`command`** — all writes, as free async fns: single-statement writes run directly on `db::pool()`, while multi-step writes run inside `db::with_tx`.
- **`db`** — the global `SqlitePool`, the activity broadcast channel, and `with_tx`.
- **`env`** — the config singleton.
- **`infra`** — the relay-sync reactor plus the zooid client.
- **`models`** — the domain and sqlite-row structs plus the relay status string constants.
- **`query`** — all reads, plus the hardcoded plans.
- **`robot`** — the service's Nostr identity and DM sender.
- **`routes/*`** — the HTTP handlers, grouped by resource (`identity`, `plans`, `tenants`, `relays`, `invoices`).
- **`stripe`** — the Stripe HTTP leaf.
- **`wallet`** — the NWC (Nostr Wallet Connect) leaf.
- **`web`** — the response envelope and its success/error builders.
Source: `lib.rs:1-14`, `main.rs:1-14`, and each module head.
## The `main()` bootstrap order
The order is strict, and each service is built from the ones before it:
1. `dotenvy::dotenv().ok()`
2. tracing setup
3. `env::init()` — loads and validates all config, panicking on any missing var
4. `db::init().await` — normalizes the sqlite URL, opens the pool, sets WAL, runs migrations, and installs the broadcast channel
5. `Robot::new().await` — builds the Nostr identity (and publishes it; see the integrations reference)
6. `Stripe::new()`
7. `Billing::new(robot.clone())`
8. `Api::new(billing, stripe, robot)`
Then it builds the router (`api.router().layer(cors)`), spawns `infra::start()` and `billing.start()` as detached tokio tasks, and only then binds `127.0.0.1:{SERVER_PORT}` and calls `axum::serve`. The HTTP server and the two workers run concurrently for the life of the process.
Source: `main.rs:27-67`.
## The layering direction
The call direction is: route handler → (when needed) `Api` authorization helpers (`require_admin` / `require_admin_or_tenant` / `require_tenant`) → `query` (reads) / `command` (writes) / `billing` (orchestration) → `db`. There is no single strict linear layer: a handler may skip the authz helpers entirely, and it may call integration leaves directly rather than going through `billing`. The integration leaves (`stripe`/`wallet`/`bitcoin`/`robot`) are composed in **two** places — `billing.rs` holds its own `stripe`/`wallet`/`robot` for the reconciliation loop, while `Api` holds `stripe` and `robot` that route handlers invoke directly — so the leaves are *not* composed exclusively by `billing.rs`.
Two concrete traces:
- **`create_tenant`** calls no `require_*` helper (only the `AuthedPubkey` extractor); it calls `query::get_tenant`, then `api.robot.fetch_nostr_name` and `api.stripe.create_customer` directly (two integration leaves, not via `billing`), then `command::create_tenant` (`routes/tenants.rs:76-109`).
- **`reconcile_invoice`** calls `query::get_invoice`, then `billing.ensure_bolt11_for_invoice` and `billing.attempt_payment` — here the handler delegates the multi-integration orchestration to `billing` rather than calling the leaves itself (`routes/invoices.rs:35-60`).
## Router assembly
`Api::router()` is the single place every route string is wired to a handler fn imported from `routes/*`, after which `Arc<Api>` is attached via `.with_state`. An endpoint therefore needs *both* a handler fn (in the matching `routes/*.rs`) and a `.route(...)` line (in `api.rs`) — two different files, both required (`api.rs:66-99`).
## The test-target note
`lib.rs` re-exports every module `pub` solely for an integration-test target that does not exist yet — there is no `backend/tests` dir — so nothing currently consumes those re-exports. If you add an integration-test crate, this is the surface it reads (`lib.rs:1-14`).
## Sources
- flat module list — `backend/src/lib.rs:1-14`, `backend/src/main.rs:1-14`, and each module head
- `main()` bootstrap and spawn order — `backend/src/main.rs:27-67`
- layering traces — `backend/src/routes/tenants.rs:76-109`, `backend/src/routes/invoices.rs:35-60`
- router assembly — `backend/src/api.rs:66-99`
- test-target note — `backend/src/lib.rs:1-14`
@@ -0,0 +1,47 @@
# Reactors and relay sync (deep detail)
This is the lookup-depth companion to the SKILL.md "background reactors" section: the relay-sync retry/backoff machinery, the self-feeding failure loop, the zooid POST-vs-PATCH request, and the billing dunning cascade timing. All of it lives in `infra.rs`, `billing.rs`, `db.rs`, and `query.rs`.
## The infra recv loop
`infra::start()` calls `db::subscribe()` once, runs `reconcile_relay_state("startup")` to recover relays left unsynced from a prior run, then loops on `rx.recv().await`, handling all three broadcast outcomes:
- **`Ok(activity)`** → `handle_activity`, which filters to `resource_type == "relay"` *and* an `activity_type` in `{create_relay, update_relay, activate_relay, deactivate_relay, fail_relay_sync}`; everything else is ignored. A `fail_relay_sync` routes to `schedule_relay_sync_retry`; the others load the relay via `query::get_relay` and call `sync_relay`.
- **`Lagged(n)`** → `warn` plus a full `reconcile_relay_state("lagged")` sweep to recover the dropped messages.
- **`Closed`** → break out of the loop, terminating the worker. Because the broadcast `Sender` lives in a `static OnceLock` for the whole process, `Closed` effectively never happens in normal operation — but if it did, `infra::start` returns and is not restarted by `main`, leaving relay provisioning dead until the process restarts.
`reconcile_relay_state` queries `list_relays_pending_sync` (`synced = 0 OR TRIM(sync_error) != ''`), returns early if empty, and otherwise routes blank-error relays to an immediate `sync_relay` and error-carrying ones through backoff. Source: `infra.rs:28-92`, `query.rs:81-83`.
## Backoff
`schedule_relay_sync_retry` counts `consecutive_failures` via `take_while` over `fail_relay_sync` activities at the **head** of the resource history (ordered `created_at DESC`) — any non-failure activity at the head resets the count to 0, which is what lets a recovered relay restart backoff from the base delay. The delay is `BASE(30s) << (attempt - 1)`, capped at `MAX(15min)`; after `MAX_ATTEMPTS(6)` it returns `None`, logs "retries exhausted; awaiting manual intervention", and stops. The retry itself is a fire-and-forget `tokio::spawn` that sleeps the computed delay, re-fetches the relay, and calls `sync_relay` (a missing relay is a silent no-op). Source: `infra.rs:15-17,94-148`, `query.rs:242-249`.
## The self-feeding loop
`sync_relay` never returns an error: on `Ok` it calls `command::complete_relay_sync` (sets `synced = 1`, `sync_error = ''`); on `Err` it calls `command::fail_relay_sync` (sets `synced = 0`, `sync_error = ...`), which publishes a `fail_relay_sync` activity after commit, which re-enters `handle_activity` and re-schedules backoff. The retry chain terminates when **any** of these happen: the sync succeeds (`complete_relay_sync` resets `synced = 1` and breaks the consecutive-failure streak counted by `take_while`, so no further retry is scheduled), the relay no longer exists (`get_relay` returns `None`, a silent no-op), a `get_relay` query errors (logged and stopped), or the consecutive-failure count exceeds `MAX_ATTEMPTS(6)` — after which the relay sits with `synced = 0` and a set `sync_error` until manual intervention or another activity touches it. Note `set_relay_status_tx` and `update_relay` always reset `synced = 0` as a side effect, so a "pure" status flip is never sync-neutral. Source: `infra.rs:57-60,136-146,151-166`, `command.rs:185-273,580-596`.
## The zooid request
`try_sync_relay` assembles the request body inline as a `serde_json::json!`: `host` (subdomain + `relay_domain`), `schema` (`relay.id`), an `inactive` flag, and the `info`/`policy`/`groups`/`management`/`push`/`roles` blocks, plus a conditional blossom S3 block and a livekit block — each gated on the relay's `*_enabled` i64 flag and falling back to `{enabled: false}`.
`is_new` is true **only** when `synced != 1` *and* there is no prior `complete_relay_sync` activity. `is_new` alone decides `POST` (with a freshly generated `Keys::generate` secret inserted into the body) vs `PATCH` (secret omitted). Because `update_relay` resets `synced = 0`, a re-sync after an update would look "new" by the `synced` flag alone — the second condition (no prior `complete_relay_sync`) is what makes it a `PATCH`, so the relay is not re-created and its secret is not clobbered. Caravel never persists the secret, so this check is load-bearing.
All zooid calls go through `request(method, path, body)`: a 5-second `reqwest` client, base from `zooid_api_url` (trailing slash trimmed), NIP-98 `Authorization` via `env.make_auth`, and a non-2xx response is turned into an `anyhow::bail!` carrying the status and body. Source: `infra.rs:168-295`.
## Billing worker timing
`POLL_INTERVAL` is 1 hour, so dunning runs at hour granularity. The DM guards exist specifically so the hourly tick doesn't re-DM on every pass:
- `GRACE_PERIOD_SECS` = 7 days (dunning grace before churn)
- `FRESH_INVOICE_DM_GRACE_SECS` = 24h (hold the manual-payment DM until an open invoice is at least this old, because a fresh invoice is surfaced in-app first)
- `MANUAL_PAYMENT_DM_INTERVAL_SECS` = 12 days (minimum spacing between reminder DMs)
`attempt_payment_using_dm` checks both `invoice.created_at` and `invoice.notified_at` before sending. `reconcile_subscription` clones the tenant and mutates the local copy (billing anchor, churn, payment method), updating the DB via explicit `command` calls, so the synchronous reconcile route re-reads the tenant afterward to reflect the changes. Source: `billing.rs:15-23,46-130,436-449`.
## Sources
- infra recv loop + reconcile — `backend/src/infra.rs:28-92`, `backend/src/query.rs:81-83`
- backoff — `backend/src/infra.rs:15-17,94-148`, `backend/src/query.rs:242-249`
- self-feeding loop — `backend/src/infra.rs:57-60,136-146,151-166`, `backend/src/command.rs:185-273,580-596`
- zooid request + POST-vs-PATCH — `backend/src/infra.rs:168-295`
- billing worker timing — `backend/src/billing.rs:15-23,46-130,436-449`
@@ -0,0 +1,60 @@
# Request lifecycle and the web envelope (deep detail)
This is the lookup-depth companion to the SKILL.md "request lifecycle" section: the exact NIP-98 decode, the success/error envelope field shapes, every builder's fixed-vs-supplied code, the full in-use domain-error-code list, and the handful of authorization quirks. All of it lives in `api.rs`, `web.rs`, and the `routes/*` handlers.
## The NIP-98 decode, step by step
`decode_nip98_pubkey` does the following, and every failure collapses to a single 401 `unauthorized` via `extract_auth_pubkey`'s `.map_err(unauthorized)`:
1. require an `Authorization: Nostr <base64>` header
2. base64-decode it to a JSON Nostr event
3. assert `event.kind == HttpAuth` (kind 27235)
4. call `event.verify()` (the signature/id check)
5. take the **last** `u` tag (`.last()`, not `.first()`)
6. assert it equals `env::get().server_url`
7. return `event.pubkey.to_hex()`
Because all of these collapse to one 401, you cannot distinguish a missing header from a bad signature from a host mismatch at the response level. Source: `api.rs:163-203`.
## The deliberate non-strictness
The check binds signer identity and host affinity only. It does **not** verify the HTTP method, the exact request URL/path/query, a payload hash, timestamp freshness, or maintain any replay cache. Per the README rationale, the frontend signs one kind-27235 event with `u = VITE_API_URL` and caches the header ~10 minutes; the tradeoff is a reusable ~10-minute bearer header (fewer wallet-signing prompts, no cookie sessions) at the cost of weaker request-intent binding than strict NIP-98. Do not "fix" it to per-request binding — it is a design choice. Source: `api.rs:167-203`, `README.md:128-137`.
## The envelope structs
- **Success:** `DataResponse { data: T, code: "ok" }`, serialized as `{ "data": ..., "code": "ok" }`. The field is `data`, and `code` is a `&'static str`.
- **Error:** `ErrorResponse { error: String, code: String }`, serialized as `{ "error": ..., "code": ... }`. The field is `error`, and `code` is an owned `String`.
Note the top-level keys differ — `data` on success, `error` on failure — so a client must branch on success-vs-error rather than reading one fixed key. The only HTTP statuses the success builders emit are 200 (`ok`) and 201 (`created`); there is no 204/no-content builder in this file. Source: `web.rs:33-43`.
Success builders return `ApiResult` (= `Result<Response, ApiError>`) already wrapped in `Ok`, so they sit at the tail of a handler with no `Ok(..)`: `res(status, data)`, `ok(data)` (= `res(OK, ..)`), `created(data)` (= `res(CREATED, ..)`). `ApiError` is a boxed `Response` with the status baked in at construction — it carries no separate status field. Source: `web.rs:17-57`.
## Error builders and their codes
The named error builders fix both status and code: `unauthorized` → 401/`unauthorized`, `forbidden` → 403/`forbidden`, `not_found` → 404/`not-found`, `internal` → 500/`internal`. The two domain builders take a **caller-supplied** kebab-case code: `bad_request(code, msg)` → 400, `unprocessable(code, msg)` → 422. Passing the wrong status builder silently emits the wrong HTTP status with your intended code. Source: `web.rs:61-103`.
`map_unique_error` downcasts `sqlx::Error::Database` and matches the raw message *substring*: contains `pubkey``pubkey-exists`, contains `subdomain``subdomain-exists`, else `None`. Because it matches on the message text rather than the constraint name, a column rename can silently regress the 422 to a 500. `map_relay_write_error` is a **private** helper inside `routes/relays.rs` (not exported from `web.rs`) that wraps `map_unique_error`: a `subdomain-exists` hit becomes a 422, anything else a 500. Source: `web.rs:115-129`, `routes/relays.rs:309-316`.
## The full in-use domain-error-code list
Beyond the framework codes (`ok`/`unauthorized`/`forbidden`/`not-found`/`internal`), the domain codes actually surfaced to clients are: `subdomain-exists`, `invalid-subdomain`, `invalid-plan`, `premium-feature`, `member-limit-exceeded` (all 422), and `relay-is-active` / `relay-is-inactive` / `relay-is-delinquent` (all 400, via `bad_request`). `pubkey-exists` is *defined* in `map_unique_error` alongside `subdomain-exists` but is **not** surfaced as an error: its only call site (`routes/tenants.rs:105`) intercepts the unique-constraint violation and returns 200 OK with the existing tenant (idempotent re-fetch). Source: status helpers `bad_request` (400) / `unprocessable` (422) at `web.rs:89-95`, code strings at `web.rs:122-127` and `routes/relays.rs:204,225,230,249,253,283,287,293,311-312`, `pubkey-exists` interception at `routes/tenants.rs:103-110`.
## Load-vs-authorize ordering
For a path-by-id resource owned by a tenant, fetch the resource **first** (via `get_*_or_404`), then authorize against its `tenant_pubkey` — you need the loaded row to know whose it is. This also intentionally leaks existence: a non-owner of an *existing* relay/invoice gets a 403, and a 404 only for a truly missing id. For tenant routes keyed by the tenant's own pubkey, the `Path` *is* the `tenant_pubkey`, so authorize on it first, then fetch. Don't reorder these. Source: `routes/relays.rs:29-37`, `routes/invoices.rs:19-32`, `routes/tenants.rs:61-71`.
## Authorization quirks
- **`create_tenant`** authorizes nobody beyond authentication: it uses the `AuthedPubkey` *as* the tenant identity, so a caller can only ever create or return their own tenant. It is idempotent and even swallows a `pubkey-exists` race by re-reading; a missing row after that race is a 500 with "tenant row missing after unique-constraint race". Source: `routes/tenants.rs:76-114`.
- **`GET /tenants/:pubkey/stripe/session`** is the only same-tenant-*only* route (it uses `require_tenant`, not `require_admin_or_tenant`). Source: `routes/tenants.rs:263-269`.
- **`get_plan`** is synchronous: `query::get_plan` returns a `Result` (no `.await`, no `Option`), so a missing plan is mapped to `not_found` via an `Err`, not a `None`. Don't pattern-match plans for a `None` case. Source: `routes/plans.rs:13-15`.
- **`update_relay`** enforces `member-limit-exceeded` (422) only when the plan actually changes and the new plan has a `members` limit; it fetches live member counts from zooid, which returns empty for unsynced relays, so an unsynced relay appears to have 0 members for the limit check. Source: `routes/relays.rs:190-207,263-269`.
## Sources
- NIP-98 decode + non-strictness — `backend/src/api.rs:163-203`, `backend/README.md:128-137`
- envelope structs + success builders — `backend/src/web.rs:17-57`
- error builders + `map_unique_error``backend/src/web.rs:61-129`, `backend/src/routes/relays.rs:309-316`
- domain-error-code list — `backend/src/web.rs:89-95,122-127`, `backend/src/routes/relays.rs:204,224-296`, `backend/src/routes/tenants.rs:103-110`
- load-vs-authorize ordering — `backend/src/routes/relays.rs:29-37`, `backend/src/routes/invoices.rs:19-32`, `backend/src/routes/tenants.rs:61-71`
- authorization quirks — `backend/src/routes/tenants.rs:76-114,263-269`, `backend/src/routes/plans.rs:13-15`, `backend/src/routes/relays.rs:190-207,263-269`
+84
View File
@@ -0,0 +1,84 @@
---
name: billing-model
description: Conceptual, user-story-level overview of how Caravel bills tenants for their relays — plans, proration, invoices, automatic payment collection, payment-method errors, dunning, churn/delinquency, and reactivation. Use this when working on or reasoning about anything billing-related, to understand the intended domain behavior before touching the code.
---
# Caravel billing model
This explains *what* the billing system is supposed to do and *why*, in plain domain terms. It is deliberately free of implementation detail — no functions, tables, or fields — so it stays true as the code evolves. Reach for the code for the *how*; reach for this for the *intent*.
## The cast
- **Tenant** — a customer account, identified by a Nostr pubkey. Everything is billed to a tenant.
- **Relay** — a hosted relay a tenant runs. A tenant can have many. Each relay sits on one plan and is either active, inactive, or delinquent.
- **Plan** — a tier (e.g. free, basic, growth). The free tier costs nothing; paid tiers have a flat monthly price and unlock features.
Only active relays on a paid plan ever cost money. Free relays, inactive relays, and delinquent relays are not charged.
## The guiding idea: bill from history, not from "now"
Every meaningful change to a relay — created, plan changed, deactivated, reactivated — is recorded as a dated event that also captures what the relay looked like at that instant (its plan and status). Billing is computed by replaying these events, never by reading the relay's *current* settings.
Why this matters: if a customer was on the growth plan last week and downgraded today, last week must still be billed at the growth price. Pricing the past from the present would overcharge or undercharge. Treat the event history as the source of truth for money.
## Billing periods
Each tenant is billed in monthly cycles. The cycle is anchored to the moment of their first billable activity, and each period is a whole calendar month from that anchor. A customer who starts on the 7th is billed on the 7th, and so on.
## How charges arise (the user stories)
**"I created a paid relay mid-month."** The customer is charged immediately, but only for the slice of the current period that remains — a prorated charge, not a full month.
**"I upgraded (or downgraded) a relay mid-month."** The customer is charged (or credited) the prorated *difference* between the old and new plan for the rest of the period. Downgrades and removals can produce credits.
**"I deactivated a relay mid-month."** The customer receives a prorated credit for the unused remainder of the period.
**"A new month started."** Every relay that was active on a paid plan at the moment the new period began is charged a full month. A relay created partway through the previous month already paid its prorated slice, so the renewal and the proration compose to exactly one fair month — never double-charged.
Credits and charges accumulate together. A customer's net balance can be zero or negative; in that case nothing is billed and the credit simply carries forward to the next time there is something to pay.
## Invoices
When a tenant has a positive outstanding balance, the pending charges and credits are gathered into a single invoice for the period. An invoice moves through a simple lifecycle expressed as *when* things happened rather than as a status label:
- **Open** — issued and awaiting payment.
- **Paid** — settled (and we remember how: Lightning, card, or a manual/out-of-band payment).
- **Void** — forgiven and no longer collectible (used when an account churns).
## Collecting payment (the cascade)
When an invoice is open, the system tries to collect on the customer's behalf, in order of least friction:
1. **The customer's Lightning wallet**, if they've connected one for automatic payments.
2. **A saved card**, if they have one on file.
3. **A manual nudge** — if automatic methods don't go through, the customer is sent a direct message with a link to pay the invoice themselves, by Lightning or card.
The system also guards against double payment: if an invoice was already settled out of band (e.g. the customer paid the Lightning request directly), that is recognized and the invoice is not collected again.
## When automatic payment fails
**The failure reason is remembered and shown to the customer.** If the Lightning wallet or the card is declined, we keep the most recent error for each method so the UI can warn the customer that something is wrong with their payment setup — separately for the wallet and the card, since they can fail independently.
**A stored error never stops us from trying again.** Recording the problem is purely informational. The next collection attempt still runs; the relevant warning is cleared automatically the moment that method succeeds.
**Unpaid invoices are retried, within a grace period.** An open invoice is re-attempted on each billing cycle. The customer has a **7-day grace period** from when the invoice was issued to get payment working.
## Churn and delinquency
If an invoice is still unpaid once its grace period has elapsed, the account **churns**:
- The tenant's active relays are marked **delinquent**, pausing service.
- The outstanding balance is **forgiven** (the invoice is voided). We stop chasing money the customer clearly isn't going to pay; the unpaid amount is not carried as a debt.
Delinquency is the visible signal — to the customer and to admins — that the account lapsed for non-payment.
## Coming back (reactivation)
A churned customer who re-engages with the service is welcomed back automatically: their churn is cleared and their delinquent relays are restored to active. Critically, **old unpaid invoices do not have to be settled to return** — the past balance was already forgiven at churn, so reactivation is a clean slate rather than a debt collection.
## Principles to preserve
- **Bill the past at its historical price.** Always price a change from the state captured when it happened, not from the relay's current settings.
- **Never double-charge.** Proration and renewal must compose to one fair month; collection must tolerate retries and out-of-band payments idempotently.
- **Errors inform, they don't block.** Surface payment problems to the customer, but keep retrying.
- **Forgive on churn, don't accrue debt.** A lapsed customer's old balance is written off, and returning never requires paying it.
+107
View File
@@ -0,0 +1,107 @@
---
name: frontend
description: Architecture and conventions for the Caravel frontend — a SolidJS + Vite + TypeScript app (NOT React, NOT TanStack Query, NOT nonboard despite the README). Covers the pages/components/lib layout, the `@/` import alias, strict TS rules (verbatimModuleSyntax), the createResource + api.ts data layer, lazy tenant provisioning, applesauce Nostr singletons, the Tailwind v4 brand-remap (blue utilities render brown), the shared component/modal/toast kit, and the `bun run build` verification gate. Use this whenever working anywhere in frontend/ — adding a page, hook, API call, component, or style — to follow house conventions and avoid stale-README traps.
---
# Caravel frontend
This is the map of the Caravel frontend: a SolidJS + Vite + TypeScript single-page app living under `frontend/src`. It explains how the frontend is organized and *why*, and points you at the code for the *how* — so reach for this for orientation and conventions, and reach for the modules it names for implementation detail. The deep, lookup-style topics live in `references/`.
One warning up front, because it is the single most dangerous thing about this codebase: **`frontend/README.md` is stale and lies about the stack.** Do not trust it for what libraries are in use, how to run the app, or what routes exist. Its specific traps are called out below.
## It's SolidJS, not React — and the README lies about the stack
Before touching anything, internalize the actual stack, because the wrong defaults here are exactly the ones an agent reaches for:
- **SolidJS, not React.** Use `class`, not `className`. Use `<Show>`/`<For>` for conditional and list rendering, not ternaries and `.map()`. Access props lazily as `props.foo` — never destructure props at the top, because that breaks reactivity (see `src/components/relay/PlanGatedToggle.tsx`, whose own header comment says so). State is `createSignal`/`createResource`/`createMemo`/`createEffect`.
- **Data fetching is hand-rolled `fetch` in `lib/api.ts` wrapped in SolidJS `createResource` — NOT TanStack Query.** `@tanstack/solid-query` is in `package.json` and the README, but it is imported *nowhere* in `src` (verified: zero references). Do not introduce it.
- **Login is built directly on `applesauce-accounts` / `applesauce-signers` — NOT "nonboard".** There is no such dependency; the README invented it.
- **`applesauce-wallet-connect` and `@tailwindcss/forms` are declared dependencies that are never imported.** NWC on the frontend is just a text input whose value is POSTed to the backend; form inputs are styled with explicit utility classes.
Bottom line: follow the `createResource` + applesauce patterns the codebase actually uses.
## Where things live
There are four top-level code directories under `src`, each with one job (plus an `assets/` dir and loose root files `App.tsx`, `index.tsx`, `index.css`, `global.d.ts`):
- **`pages/`** — route components. Subfolders group screens: `relays/` (tenant relay screens) and `admin/` (the admin console).
- **`components/`** — shared, reusable UI, with feature subfolders `login/`, `payment/`, `account/`, `relay/`.
- **`lib/`** — all non-UI logic.
- **`views/`** — holds only `Login.tsx` (see the quirk below).
Within `lib/`, four modules are the load-bearing pillars:
- **`api.ts`** — the *only* place backend wire types and `fetch` wrappers live.
- **`state.ts`** — app-lifetime singletons plus global signals/resources.
- **`hooks.ts`** — the `use*` `createResource` read hooks and the active-tenant action helpers.
- **`nostr.ts`** — the `useNostr()` DI seam for reaching the Nostr singletons.
The rest of `lib/` is camelCase pure-helper modules and `use*` hook files. One quirk to know: **Login lives in `src/views/Login.tsx`, not `pages/`** — it is the lone occupant of `views/`.
Where a *new* thing goes: a route page → `pages/`; shared UI → `components/`; a hook → `lib/use*.ts`; a backend call → an `api.ts` wrapper; pure decision logic → its own `lib` module. A concrete file inventory and the entrypoint/route wiring are in [references/file-inventory.md](references/file-inventory.md) if you need it.
## Naming, imports, and TypeScript rules
To compile and fit in, an edit must satisfy these:
- **File naming.** Components/pages/views are PascalCase `.tsx` with one default-exported function named after the file. Lib modules are camelCase `.ts` (no default exports). Hooks are camelCase `use*.ts`. Files that export multiple hooks/symbols use named exports (e.g. `PaymentSetupShell.tsx` exports `PaymentSetupShell` plus sibling components). Single-hook files are inconsistent: `useRelayToggles.ts` and `useMinLoading.ts` default-export, but `useInvoicePdf.ts` uses a named export — so don't assume a single-purpose hook default-exports.
- **Imports always use the `@/` alias** (maps to `src/`), e.g. `import RelayList from "@/pages/relays/RelayList"`. There is not a single relative `./`/`../` cross-module import in the tree (the only relative import is `index.tsx`'s `import "./index.css"`). The alias is configured in **two places that must stay in sync**: `vite.config.ts` and `tsconfig.app.json`.
- **No barrel `index` files.** The only `index.{ts,tsx,js,jsx}` under `src/` is the entrypoint `index.tsx`, which renders the app rather than re-exporting a module. Import each symbol from its concrete module. (`index.css` also exists, so `index.tsx` isn't literally the only `index.*` file.)
- **One canonical export per symbol — no re-export shims.** When code moves, update every call site (root `AGENTS.md`). Note one existing wrinkle: `hooks.ts` re-exports four `api.ts` wire types (`Activity`, `Invoice`, `Relay`, `Tenant`), so each is importable from two paths — **prefer `@/lib/api`** for wire types. `hooks.ts` also re-exports `ProfileContent`, but that type comes from `applesauce-core/helpers/profile` (not `api.ts`), so import it from there.
- **Strict TS.** `verbatimModuleSyntax` is on, so type-only symbols need `import type` or an inline `type` specifier, e.g. `import { invoiceStatus, type Invoice } from "@/lib/api"`. `noUnusedLocals`/`noUnusedParameters` and `erasableSyntaxOnly` are also on.
- **`tenant_pubkey` naming.** Name a tenant's pubkey `tenant_pubkey` on FK fields/inputs; the exception is already-tenant-scoped contexts (`Tenant.pubkey`, `getTenant(pubkey)`). See root `AGENTS.md`.
- **Don't over-DRY.** Extract a helper only when it's a distinct concern, repeated 3+ times, or complex enough that naming it clarifies the flow (root `AGENTS.md`). `relayFlags.ts` is the canonical example — it was extracted because the 0/1↔bool conversion recurred across toggle UI and mutations.
A strong idiom: keep **pure decision logic** (payload shapes, validation ladders, discriminated-union decisions) in dedicated `lib` modules with no signals/effects/awaits (`relayPlanFlow.ts`, `loginInput.ts`), and keep the effect layer in the importing hook/component. These modules carry a header comment stating they are pure.
## The data layer: api.ts + createResource
Every backend call goes through `lib/api.ts`. A generic `callApi(method, path, body)` attaches a cached NIP-98 auth header, unwraps the `{ data, code }` envelope (returning `.data`), and throws `ApiError(message, status)` on a non-2xx response. Each endpoint is a thin exported wrapper with explicit generics; add new endpoints the same way (use an `undefined` request type for bodyless requests — both GETs and parameterless POSTs). The lone exception is `listPlans`, which bypasses `callApi` and does its own raw `fetch` with no auth machinery. (The public/unauthed routes are `/plans` and `/plans/:id` — the two backend handlers that omit the `AuthedPubkey` extractor every other route requires; `getPlan` still goes through `callApi`, which simply omits the `Authorization` header when logged out.)
Wire types are snake_case. Two modeling choices to respect: relay boolean settings are **numeric 0/1 flags**, not JS booleans — toggle them through the `relayFlags` helpers; and an invoice's lifecycle is **derived from nullable timestamps** via `invoiceStatus`, not read from a status column.
Screen reads expose a `use*` `createResource` hook in `hooks.ts` (again: not TanStack Query). Pass an accessor as the resource source when it should refetch on id change; pass a thunk reading `account()!.pubkey` for active-tenant reads. Render with `ResourceState` + `useMinLoading` + `Show`/`For`, defaulting the value with `?? []`.
Mutations are optimistic and follow one shape: `mutate(next)``await update``await refetch()`, and on error `mutate(previous)` + `setToastMessage(...)` to roll back and report.
The deeper mechanics — the `ApiOk` envelope and `ApiError` shape, NIP-98 auth caching, and the `api.ts``state.ts` circular-import DI seam — are in [references/data-and-state-lifecycle.md](references/data-and-state-lifecycle.md).
## Global state, the session, and lazy tenant provisioning
`state.ts` holds app-lifetime singletons and global reactive state: the `account` signal, the `plans`/`identity` resources, the `billing*` resources, the toast signals, and `PLATFORM_NAME`.
The one ordering rule you must respect: **a tenant row is provisioned lazily.** `ensureSessionTenant()` does a `POST /tenants`, run by the account-activation subscriber on every account switch (it's idempotent — later runs just return the existing row). Some tenant-scoped reads 404 before the row exists: `getTenant` and `getDraftInvoice` 404 via `get_tenant_or_404`. (Note `listTenantInvoices` and `listTenantRelays` do *not* 404 — they query directly and return empty results — so the gate mainly protects the tenant/draft reads.) That is why the billing resources are gated on a separate `billingPubkey` signal rather than directly on `account()``billingPubkey` is cleared (set to `undefined`) on every account switch and re-set only once the tenant is ensured. Convention: gate any new tenant-scoped resource behind `billingPubkey` (or run `ensureSessionTenant()` first), and after a billing-affecting mutation call `refetchBilling()` where the flow needs it.
For the billing *domain* (why proration, dunning, churn, and reactivation behave as they do), read the [billing-model skill](../billing-model/SKILL.md) — that skill owns the intent. This skill only documents the frontend data-layer wiring. The detailed account-switch lifecycle is in [references/data-and-state-lifecycle.md](references/data-and-state-lifecycle.md).
## Nostr integration (applesauce)
Most agents touch this rarely. Three app-lifetime applesauce singletons live in `state.ts`: `eventStore`, `pool` (a `RelayPool`), and `accountManager`. The reactive `account` signal mirrors `accountManager.active$` — never set `account` directly; activate accounts through `accountManager`.
To reach the singletons: inside a SolidJS reactive scope use `const nostr = useNostr()` (the DI seam in `nostr.ts`, which falls back to the module singletons when no provider is mounted); outside one (event handlers, plain async, `api.ts`) import the module singletons directly.
Render profiles through the `useProfileMetadata` / `useProfileMetadataMap` / `useProfilePicture` hooks, which subscribe to `eventStore.profile(...)` and optionally prime over the network — never hand-roll relay queries. When a parent batch-primes a list, children subscribe with `{ prime: false }` so they don't each re-prime.
Pubkeys are hex strings; shorten them for display with the canonical `shortenPubkey` in `lib/pubkey.ts`. Gotcha: `AppShell.tsx` has its own *duplicate* `shortenPubkey` with a different format (8/6 split with a `…` character vs. the canonical 8/8 split with `...`) — **prefer the canonical `lib/pubkey.ts` one.** Auth is a session-style NIP-98 variant: a kind-27235 event signed by the active account, with a `u`-tag equal to `VITE_API_URL`, cached ~10 minutes.
## Routing and auth gating
All routes are declared centrally in `App.tsx` under one `Router` with `root={Layout}`. Protected pages are wrapped in `requireTenant()` (logged in) or `requireAdmin()` (`identity().is_admin`), both built on a `requireCondition` HOC that redirects to `/` when the check fails and waits on the `identity` resource to finish loading. A new gated page must be wired into `App.tsx` with the right wrapper. `AppShell` wraps the authenticated paths (matched by a path regex in `Layout`).
The gate is **rendering-only** (it just conditionally renders the page and client-redirects). Real security is server-side in two distinct layers: (1) **authentication** — the backend decodes the NIP-98 `Authorization` header, verifies the kind-27235 event's signature, and checks the `u`-tag equals `SERVER_URL` for host affinity, recovering the signer pubkey; (2) **authorization** — each handler then calls `require_admin` / `require_tenant` / `require_admin_or_tenant` to compare that pubkey against the configured admin pubkeys or the tenant's pubkey. The `u`-tag/`server_url` check is authentication/host-binding, not the authorization decision. And note: the README's `/login` route does not exist — login is a modal/embedded component, not a route.
## Styling: Tailwind v4 + the brand remap + shared kit
Styling is inline Tailwind v4 utility strings in `class=` — no CSS modules, no `@apply`, no `tailwind.config.js` (config is CSS-first, in `src/index.css`, wired via the `@tailwindcss/vite` plugin).
**THE pitfall:** `index.css` remaps the entire `--color-blue-*` scale to a brown brand palette, so `bg-blue-600`, `text-blue-700`, and `focus:ring-blue-500` render **brown** (`#c18254`) and *are* the intended brand accent. Do not "fix" them to a brown hex, and do reach for blue utilities for any primary accent.
Compose from the shared kit rather than bespoke markup: `PageContainer` (page wrapper), `ResourceState`/`LoadingState` (paired with `createResource` + `useMinLoading`), `Modal` plus `ConfirmDialog`/`PaymentDialog`, and the form primitives `Field`/`ToggleField`/`Checkbox`/`SearchInput`/`ToggleButton`/`RelayForm`. Most transient/global errors and successes go through `setToastMessage(message, variant)` (a single bottom-right `Toast` in `components/Toast.tsx`, mounted once in `App.tsx`; default variant `"error"` = red, `"success"` = green) — prefer it over ad-hoc alert UI. It is not the only error surface, though: contextual/inline errors bypass the toast — e.g. Login's local `error()` signal (`views/Login.tsx`), the payment flow (`PaymentDialog` bolt11 error, `PaymentSetupShell`), resource-load failures (`ResourceState` errorText), relay provisioning/sync errors (`RelayCardHeader`), and the `PromptBanner` banner.
Preline's `HSStaticMethods.autoInit()` is re-run on pathname changes by a `createEffect` in `Layout` (`App.tsx`). However, this codebase does not actually use any `data-hs-*` markup — all dropdowns, tabs, and overlays are hand-rolled with SolidJS signals and manual listeners (see `RelayCardHeader.tsx`, `LoginTabsScreen.tsx`, `PaymentSetup.tsx`). Don't assume `data-hs-*` needs no wiring: the effect fires only on full-path changes (not same-path query/hash navigation), and because SolidJS renders DOM lazily via `<Show>`, any Preline markup that mounts after the effect last ran will be uninitialized. If you do use Preline `data-hs-*` components, wire initialization explicitly rather than relying on this route-level effect.
## Building and verifying a change
There is no lint or test step for the frontend. The verification gate is the **build**, which runs a strict typecheck (`tsc -b`, project references, `noEmit`) then a production bundle (`vite build`). Verify with `just build-frontend` (which runs `bun i && bun run build`) or, inside `frontend/`, `bun run build`. **Bun is the package manager** (`bun.lock`; no npm/yarn/pnpm lockfile) — ignore the README's `npm` instructions. `just build` also compiles the Rust backend, so prefer `build-frontend` to verify only frontend changes.
Three `VITE_*` vars (`VITE_API_URL`, `VITE_RELAY_DOMAIN`, optional `VITE_PLATFORM_NAME`) are read once into named lib constants (`API_URL`, `RELAY_DOMAIN`, `PLATFORM_NAME`) and baked at build time. A fourth, `VITE_PORT`, is read only by `vite.config.ts` and is undocumented (absent from both `.env.template` and `global.d.ts`). Adding a new client env var means: prefix it `VITE_`, declare it in `src/global.d.ts`, add it to `.env.template`, and (for Docker) wire the placeholder + entrypoint `sed` substitution. The full env/Docker substitution matrix is in [references/build-env-and-docker.md](references/build-env-and-docker.md).
@@ -0,0 +1,53 @@
# Build, TypeScript config, env vars, and Docker (deep detail)
Lookup-depth companion to the SKILL.md "Building and verifying" section.
## Build and typecheck
The only `package.json` scripts are `dev` (`vite`), `build` (`tsc -b && vite build`), and `preview` (`vite preview`). There is no lint or test script — the build *is* the verification gate.
`tsc -b` builds via TypeScript **project references**: the root `tsconfig.json` has `files: []` and references `tsconfig.app.json` (compiles `src`) and `tsconfig.node.json` (compiles `vite.config.ts`). Both set `noEmit: true`, so `tsc -b` is purely a typecheck; the actual emit comes from `vite build`.
The strict flags a change must satisfy (in `tsconfig.app.json`): `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`, plus `verbatimModuleSyntax` and `moduleDetection: force`. JSX is `preserve` with `jsxImportSource: solid-js`. The `@/*``src/*` path alias is declared here (and mirrored in `vite.config.ts`).
## Commands and the package manager
Bun is the package manager: `bun.lock` is present and there is no `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`.
- `just build-frontend``cd frontend && bun i && bun run build` — the canonical way to verify a frontend change compiles.
- `just build``build-backend build-frontend` — also compiles the Rust backend, so it needs a working Rust toolchain. Use `build-frontend` to verify only frontend changes.
- `just dev-frontend``cd frontend && bun run dev`.
- There is **no** frontend lint or test target in the `justfile`; the aggregate `lint`/`test`/`fmt` tasks are backend-only.
## The four VITE_* env vars
| Var | Declared in `global.d.ts`? | In `.env.template`? | Where it's read | Role |
| --- | --- | --- | --- | --- |
| `VITE_API_URL` | yes | yes (`http://127.0.0.1:2892`) | `api.ts``API_URL` | backend base URL for requests; also the NIP-98 `u`-tag value |
| `VITE_RELAY_DOMAIN` | yes | yes (`spaces.coracle.social`) | `subdomain.ts``RELAY_DOMAIN` | relay subdomain suffix; must match the backend's `RELAY_DOMAIN` |
| `VITE_PLATFORM_NAME` | yes (optional) | yes (`Caravel`) | `state.ts``PLATFORM_NAME` | platform display name (title, login, invoice PDF) |
| `VITE_PORT` | **no** | **no** | `vite.config.ts` only (`Number(env.VITE_PORT) || 5173`) | dev-server port; never reaches client code |
Convention: read each client var once at module top-level into a named `const` typed via `global.d.ts`'s `ImportMetaEnv`, rather than scattering `import.meta.env` reads through components. A new client-readable var must be `VITE_`-prefixed, declared in `ImportMetaEnv` in `src/global.d.ts`, and added to `.env.template` with a default. Non-client build-tool vars (like `VITE_PORT`) are read only in `vite.config.ts` via `loadEnv(mode, process.cwd(), '')`.
`global.d.ts` is the single ambient-declarations file: it also declares `Window.HSStaticMethods` (Preline) and `Window.nostr?` (NIP-07). Extend the existing interfaces there rather than re-declaring elsewhere.
## Build-time bake vs. Docker runtime substitution
`VITE_*` values are **baked into the bundle at build time**, so editing `frontend/.env` requires a rebuild to take effect.
In Docker this is sidestepped: the frontend is built once with sentinel placeholder values (`VITE_API_URL=__VITE_API_URL__`, etc.). The container entrypoint then maps runtime env (`SERVER_URL`, `RELAY_DOMAIN`, `PLATFORM_NAME`) onto those placeholders and `sed`-substitutes them into every `*.js`/`*.html` in `/app/dist` at startup, before serving with `serve -s /app/dist -l 3000`. So one image can be deployed with any config without a rebuild, but runtime config there does *not* require a rebuild — unlike a local `.env` change.
When adding a new build-time-substituted `VITE_` var intended for Docker, mirror the existing pattern: declare the placeholder `ENV` in the build stage and add the matching `sed -e` mapping in the entrypoint.
## Sources
- scripts, no lint/test — `frontend/package.json:6-10`
- project references, `noEmit``frontend/tsconfig.json:1-7`
- strict flags + alias — `frontend/tsconfig.app.json:12-30`
- bun lockfile — `frontend/bun.lock` (no other lockfile present)
- `just` targets — `justfile:11-12,32-43`
- env var declarations — `frontend/src/global.d.ts:17-31`
- env defaults — `frontend/.env.template:1-8`
- `VITE_PORT` dev-server port — `frontend/vite.config.ts:6-18`
- Docker placeholder build + entrypoint `sed``Dockerfile` frontend-build stage and entrypoint
@@ -0,0 +1,60 @@
# Data and state lifecycle (deep detail)
This is the lookup-depth companion to the SKILL.md "data layer" and "global state" sections. It documents the exact mechanics of the API client, NIP-98 auth, the circular-import seam, the session/provisioning lifecycle, and the optimistic-mutation flow. All of it lives in `frontend/src/lib/api.ts`, `state.ts`, and `useRelayToggles.ts`.
## The API envelope and errors
`callApi<TRequest, TResponse>(method, path, body?)` is the single fetch wrapper (`api.ts`). It calls `makeAuth()`, builds the URL with `new URL(path, API_URL)`, sets `Content-Type: application/json` and an `Authorization` header *only when auth is present*, and JSON-stringifies the body — an `undefined` body sends no body at all.
Responses are unwrapped from an `ApiOk<T> = { data: T; code: string }` envelope: `callApi` returns `payload.data`, not the raw payload. A `204` returns `undefined` cast to `TResponse`.
On a non-2xx response it throws `new ApiError(message, response.status)`. `ApiError extends Error` and carries a numeric `status`. The message comes from the JSON body's `error` field when present, otherwise `Request failed (<status>)`.
`listPlans()` deliberately bypasses `callApi`: it does its own raw `fetch` with no auth and unwraps `{ data: Plan[] }` directly. It is the only wrapper that bypasses `callApi`, but not the only public/unauthed endpoint — `/plans` and `/plans/:id` are both public (their backend handlers omit the `AuthedPubkey` extractor); `getPlan` reaches its public route through `callApi`, which omits the `Authorization` header when logged out.
## NIP-98 auth and the 10-minute cache
`makeAuth()` builds a session-style NIP-98 header. It reads the active account via the injected getter (see the DI seam below), and if none is logged in it returns `undefined` — so `callApi` silently sends an *unauthenticated* request rather than failing fast, and authed endpoints then 401 from the backend.
When an account is present it signs a **kind-27235** event with empty content and a single `["u", API_URL]` tag, base64-encodes the JSON, and returns `Nostr <base64>`. The result is cached per-pubkey for **10 minutes** (`expiresAt = now + 10 * 60 * 1000`). Switching accounts invalidates the cache via the pubkey check. Within the cache window the event's `created_at` is fixed at sign time, so freshness relies on the backend's own NIP-98 window.
## The api.ts ↔ state.ts circular-import seam
`api.ts` needs the active account to build auth, but `state.ts` (which owns the `account` signal) imports from `api.ts` — a cycle. It is broken by dependency injection: `api.ts` holds a private module-level `getAccount` and exposes `registerAccountGetter(fn)`; `state.ts` calls `registerAccountGetter(account)` to inject the signal.
Side-effecting initialization in `state.ts` (localStorage hydration + the `accountManager.active$` subscription) is wrapped in `queueMicrotask(...)` to defer it past module evaluation and avoid temporal-dead-zone errors from the cycle. When adding new cross-module state that both files touch, follow the same pattern: a `register*Getter` injection plus deferred init.
## Session and lazy tenant provisioning
The tenant row is created lazily, which drives the whole session ordering.
`ensureSessionTenant()` is idempotent lazy provisioning: it reads the active pubkey, `await`s `createTenant()` (`POST /tenants`), then — only if the active pubkey is still the same — sets `billingPubkey` to unlock billing reads. The in-flight promise is shared per-pubkey via a module-level `tenantEnsure` object, so the login flow and the activation subscriber don't double-provision; `createTenant` is itself idempotent.
The `accountManager.active$` subscriber runs on **every account switch** and performs this ordering: `setAccount(account)` → persist accounts + active id to `localStorage``refetchIdentity()``setBillingPubkey(undefined)` to **lock** billing reads → `void ensureSessionTenant()` to re-unlock once provisioned. Locking first is what stops billing reads from firing against a not-yet-provisioned tenant during signup.
Login's `completeLogin` (`views/Login.tsx`) `await`s `ensureSessionTenant()` and rolls back via `accountManager.removeAccount(...)` if it throws — so a failed provision aborts the login.
This is why the billing resources (`billingTenant`, `billingInvoices`, `billingRelays`, `billingDraftInvoice`) are gated on a `billingPubkey` signal, not on `account()` directly. The gate matters because `getTenant` and `getDraftInvoice` (which use `get_tenant_or_404`) 404 before the row exists; `listTenantInvoices` and `listTenantRelays` instead return empty results, so the gate primarily protects the tenant/draft reads.
`refetchBilling()` refetches all four via `Promise.allSettled`, and on any rejected result it `console.error`s and toasts `"Failed to refresh billing data"`. It is the pure billing refresh that callers invoke after select billing-affecting flows (payment dialog/setup close, paid relay create, `autopayBilling`'s `finally` block) — it is **not** called after every billing-affecting mutation: relay deactivate/reactivate, plan toggles, and plan changes (including downgrade-to-free) in `useRelayToggles` only run the per-relay `refetch()`, and creating a free relay in `Home.tsx` skips it.
## The optimistic mutation flow
The canonical mutation shape (`useRelayToggles.ts`):
1. `mutate(next)` — optimistically push the new value into the resource.
2. `await updateRelayById(...)` — perform the API write.
3. `await refetch()` — reconcile against the server's truth.
4. On error: `mutate(previous)` to roll back, and `setToastMessage(e instanceof Error ? e.message : <fallback>)`.
`setToastMessage` defaults the variant to `"error"`; pass `"success"` for confirmations and `setToastMessage("")` to clear. Rethrow only when a caller needs to react (e.g. the plan-upgrade flow continues into `resolvePostPaidFlow()` for paid upgrades).
## Sources
- API client, envelope, `ApiError`, `listPlans` bypass — `frontend/src/lib/api.ts:9-32,208-248`
- NIP-98 `makeAuth` + 10-minute cache — `frontend/src/lib/api.ts:181-206`
- circular-import DI seam — `frontend/src/lib/api.ts:3-7` and `frontend/src/lib/state.ts:48,168-211`
- billing-resource gating + `refetchBilling``frontend/src/lib/state.ts:74-103`
- `ensureSessionTenant` + activation ordering — `frontend/src/lib/state.ts:149-165,187-208`
- login rollback — `frontend/src/views/Login.tsx:57-67`
- optimistic mutation — `frontend/src/lib/useRelayToggles.ts:27-36`
@@ -0,0 +1,57 @@
# File inventory and route wiring
A concrete map of `frontend/src`, so the SKILL.md "where things live" section can stay prose-only. Use this when you need to find the right existing module instead of guessing.
## Entry points
- `index.tsx` — bootstrap. Imports `index.css`, dynamically imports Preline, then `render`s `<App />`.
- `App.tsx``Router` + central route table + the auth-gate HOCs (see "Routes" below).
- `index.css` — Tailwind v4 CSS-first config and the brand color remap.
- `global.d.ts` — ambient declarations (`ImportMetaEnv`, `Window.HSStaticMethods`, `Window.nostr`).
## `pages/` — route components
- top level: `Home.tsx`, `Account.tsx`
- `relays/` (tenant relay screens): `RelayList`, `RelayNew`, `RelayDetail`, `RelayEdit`
- `admin/` (admin console): `AdminTenantList`, `AdminTenantDetail`, `AdminRelayList`, `AdminRelayDetail`, `AdminRelayEdit`, `AdminInvoiceList`, `AdminInvoiceDetail`
## `views/` — the lone exception
- `Login.tsx` — the login flow, the only thing under `views/` (not `pages/`).
## `components/` — shared UI
- shared kit: `PageContainer`, `ResourceState`, `LoadingState`, `Modal`, `ConfirmDialog`, `PaymentDialog`, `Field`, `ToggleField`, `Checkbox`, `SearchInput`, `ToggleButton`, `RelayForm`, `Toast`, `PromptBanner`, `PricingTable`, `BackLink`, plus small icon components
- `AppShell` — the authenticated layout shell (note: carries a duplicate local `shortenPubkey`; prefer `lib/pubkey.ts`)
- feature subfolders:
- `login/``LoginTabsScreen`, `LoginKeyScreen`, `LoginSignerScreen`, `QrScannerOverlay`
- `payment/``InvoiceItemsList`, `LightningPayBody`
- `account/``InvoiceListItem`, `PaymentMethodRow`
- `relay/``PlanGatedToggle`, `RelayCardHeader`
- plus billing/payment/relay cards at the components top level (`BillingPrompts`, `InvoiceDetailCard`, `RelayDetailCard`, `RelayListItem`, `PaymentSetup*`, `ActivityFeed`, `AdminInvoiceListItem`)
## `lib/` — non-UI logic
- **pillars:** `api.ts` (wire types + fetch wrappers), `state.ts` (singletons + global signals/resources), `hooks.ts` (`use*` read hooks + active-tenant action helpers), `nostr.ts` (`useNostr` DI seam)
- **`use*` hooks:** `useMinLoading`, `useRelayToggles`, `usePaymentSetup`, `useInvoicePdf`
- **pure helpers:** `relayPlanFlow`, `relayFlags`, `paymentMethod`, `loginInput`, `subdomain`, `slugify`, `search`, `format`, `clipboard`, `validation`, `pubkey`, `billing`
## Routes (`App.tsx`)
All declared under one `Router root={Layout}`:
- `/``Home` (ungated)
- `/relays`, `/relays/new`, `/relays/:id`, `/relays/:id/edit``requireTenant(...)`
- `/account``requireTenant(Account)`
- `/admin/tenants`, `/admin/tenants/:id`, `/admin/relays`, `/admin/relays/:id`, `/admin/relays/:id/edit`, `/admin/invoices`, `/admin/invoices/:id``requireAdmin(...)`
`requireTenant(Page)` and `requireAdmin(Page)` are both built on a `requireCondition(Page, condition)` HOC: it waits for the `identity` resource to finish loading, redirects to `/` when the condition is false, and otherwise renders the page inside a `<Show>`. `requireTenant` checks `Boolean(identity())`; `requireAdmin` checks `Boolean(identity()?.is_admin)`. `Layout` wraps the page in `AppShell` when the path matches `/^\/(relays|account|admin)/` and identity has loaded.
There is **no `/login` route** — login is rendered as a modal/embedded component (`views/Login.tsx`, mounted as a `<Modal>` in `pages/Home.tsx`), contrary to `frontend/README.md` (not the repo-root README), which lists a `/login` route.
## Sources
- src tree — verified live listing of `frontend/src`
- route table + guards — `frontend/src/App.tsx:43-81`
- entry bootstrap — `frontend/src/index.tsx:3-13`
- `AppShell` duplicate `shortenPubkey``frontend/src/components/AppShell.tsx:11-14`
+74
View File
@@ -0,0 +1,74 @@
---
name: rebuild-skill
description: Overhaul an existing skill by becoming an expert in what it does, then rewriting it from verified ground truth. Use whenever the user wants to rebuild, improve, fix, refresh, or modernize a specific skill — e.g. "/rebuild-skill billing-model", "this skill is stale, fix it", "make the deploy skill correct again", "the X skill's instructions are wrong". Kicks off a dynamic multi-agent workflow that researches the skill's domain before touching its prose, so improvements rest on what's actually true rather than a copyedit of what was already there.
---
# Rebuild a skill
You are handed the name of an existing skill and asked to make it genuinely better. The argument is the skill name (e.g. `billing-model` from `/rebuild-skill billing-model`). If no name was given, ask which skill to rebuild before going further.
The trap to avoid: treating this as a writing task. A skill encodes how to do something well, so you can only improve it as far as you actually understand the thing it does. A rewrite that polishes the prose but never checks the claims against reality usually makes the skill *worse* — more confident and more wrong. So the order here is deliberate: gain real expertise and gather ground truth first, rewrite from that position second, and verify every claim against its source third.
The bulk of that work fans out across many agents, so this skill runs as a **dynamic Workflow** — "dynamic" because the prerequisite research is shaped by what the skill actually does, which you discover by reading it. You scout inline to find the skill and figure out *what expertise the rebuild needs*, then hand that work-list to a Workflow. Invoking the Workflow tool here is expected — these instructions are your opt-in.
## Step 0 — Locate the skill
Find the skill directory before anything else. Skills live under `.agents/skills`:
```bash
NAME="<the-name-argument>"
find .agents/skills -maxdepth 2 -type d -name "$NAME" 2>/dev/null
```
If exactly one match, that's your target. If none, stop and ask the user — rebuilding the wrong copy wastes the whole run. Note the directory and the `name:` in its frontmatter; both are preserved unchanged through the rebuild.
## Step 1 — Understand it (inline)
Read the target `SKILL.md` end to end, plus every bundled resource (`scripts/`, `references/`, `assets/`). Build a clear picture of:
- **Purpose** — what capability is this supposed to give Claude?
- **Triggering** — when is it meant to fire? Is the description tuned for that, or does it over/under-trigger?
- **Claims** — what factual or procedural assertions does it make? (API behaviors, commands, file layouts, domain rules, step sequences.) These are what you'll verify.
- **Domain** — what does Claude need to be expert in to do this task well? This is the seed for the prerequisite work.
## Step 2 — Scope the prerequisite work (inline)
Decide *what gaining expertise looks like for this particular skill*, and break it into independent facets — one investigable chunk each. The right facets depend entirely on the skill's type:
- A skill that **documents a codebase domain** (like `billing-model`) → trace the actual code paths it describes; confirm each documented rule still matches the implementation.
- A skill that **runs a procedure** (deploy, release, migration) → walk the real procedure and its tools; find steps that are stale, reordered, or missing.
- A skill that **relies on an external API or library** → fetch the authoritative docs and verify every behavior the skill claims. The user expects third-party behavior backed by docs, never asserted from memory.
- A skill that **produces an artifact** (a doc, chart, config) → actually produce one with its current instructions and note where it breaks or falls short.
Most skills are a blend. Aim for 36 facets that together cover the skill's claims and its domain. Each facet should be answerable by one agent reading real sources (code, docs, tools) — not by guessing.
## Step 3 — Run the dynamic workflow
Read [references/workflow-template.js](references/workflow-template.js), adapt the `facets` and prompts to the skill you scoped, and run it with the Workflow tool. Pass the skill path, name, and facets via the `args` field so the script stays generic.
The workflow runs these phases:
1. **Investigate** (fan-out, one agent per facet) — each agent reads real sources to gather ground truth: what is actually true, what the skill currently claims, and exactly where the two diverge. Every finding cites a file path or URL.
2. **Synthesize** — one agent folds the findings and the current skill into a concrete rebuild plan (what to fix, add, cut, restructure), grounded in the verified facts and in skill-writing principles.
3. **Draft** — one agent rewrites `SKILL.md` and any resources in place, preserving the directory and the `name`. It returns the list of claims a reviewer should check, each with its supporting evidence.
4. **Verify** (fan-out, one agent per claim) — each agent adversarially tries to *refute* its claim by going back to the cited source. Anything unsupported, outdated, or contradicted is flagged.
5. **Correct** (only if anything was refuted) — one agent fixes the flagged claims in the files: correct them to match ground truth, or cut them.
The workflow returns the files written and the verification verdicts. This is "ensuring correctness" made concrete: nothing survives in the rebuilt skill that an independent skeptic couldn't confirm against its source.
## Step 4 — Review with the user
Snapshot the original before the workflow writes anything (`cp -r <skill-dir> /tmp/<name>-before`) so you can show a clean diff. After the workflow returns:
- Show `git diff` (or a diff against the snapshot) of the skill files.
- Summarize what changed and the ground truth behind each substantive change — especially anything the verify phase had to correct.
- Surface any claim the workflow *couldn't* confirm rather than quietly keeping it.
If the user wants quantitative confidence that the rebuild triggers and performs better, offer to hand off to the `skill-creator` skill, which runs the eval/benchmark loop. This skill's job is the correctness-grounded rewrite; `skill-creator` is the measurement loop.
## Principles to preserve
- **Expertise before prose.** Never edit a claim you haven't grounded in a real source. The investigate phase exists so the rewrite comes from knowledge, not from rephrasing the old text.
- **Cite or cut.** Every technical claim in the rebuilt skill traces to a file path or URL. If verification can't confirm it, fix it or remove it — don't ship confident guesses.
- **Preserve identity.** Keep the directory name and the frontmatter `name` so the skill stays the same skill. Improve the description for triggering; don't rename the thing.
- **Apply skill-writing craft.** Progressive disclosure (lean `SKILL.md`, detail in `references/`), explain the *why* rather than piling on MUSTs, and a description that states both what it does and when to fire.
@@ -0,0 +1,194 @@
// Dynamic workflow template for the rebuild-skill skill.
//
// Adapt `facets` to the skill being rebuilt (see SKILL.md Step 2), then run with the Workflow
// tool. Keep the script generic and pass per-run values through `args`:
//
// Workflow({
// scriptPath: "<this file, or a copy you edited>",
// args: {
// skillPath: "/abs/path/to/skills/<name>",
// skillName: "<name>",
// facets: [
// { key: "code-paths", prompt: "Trace the billing event-replay code this skill describes ..." },
// { key: "api-docs", prompt: "Fetch the Lightning/LNbits docs and verify the payment-cascade claims ..." },
// // 36 facets that together cover the skill's claims and its domain
// ],
// },
// })
//
// Phase shape: Investigate (fan-out) -> Synthesize -> Draft -> Verify (fan-out) -> Correct (conditional).
// The point of the structure: gather ground truth before rewriting, then let independent skeptics
// confirm every claim against its source. Nothing unverifiable survives.
export const meta = {
name: 'rebuild-skill',
description: 'Investigate a skill\'s domain from real sources, rewrite it from verified ground truth, and adversarially verify every claim',
phases: [
{ title: 'Investigate', detail: 'one agent per facet gathers ground truth from code/docs/tools' },
{ title: 'Synthesize', detail: 'fold findings + current skill into a grounded rebuild plan' },
{ title: 'Draft', detail: 'rewrite SKILL.md and resources in place' },
{ title: 'Verify', detail: 'one skeptic per claim tries to refute it against its source' },
{ title: 'Correct', detail: 'fix or cut any refuted claim (only if needed)' },
],
}
const { skillPath, skillName, facets } = args
const FINDINGS = {
type: 'object',
additionalProperties: false,
required: ['truths', 'divergences'],
properties: {
truths: {
type: 'array',
description: 'Ground-truth facts established by reading real sources.',
items: {
type: 'object',
additionalProperties: false,
required: ['fact', 'source'],
properties: {
fact: { type: 'string' },
source: { type: 'string', description: 'file path with line, or URL' },
},
},
},
divergences: {
type: 'array',
description: 'Places where the skill\'s current text is stale, vague, wrong, or missing.',
items: {
type: 'object',
additionalProperties: false,
required: ['kind', 'skillSays', 'realitySays', 'source'],
properties: {
kind: { type: 'string', enum: ['stale', 'vague', 'wrong', 'missing'] },
skillSays: { type: 'string' },
realitySays: { type: 'string' },
source: { type: 'string' },
},
},
},
},
}
const PLAN = {
type: 'object',
additionalProperties: false,
required: ['changes', 'descriptionAdvice'],
properties: {
changes: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['action', 'what', 'groundedOn'],
properties: {
action: { type: 'string', enum: ['fix', 'add', 'cut', 'restructure'] },
what: { type: 'string' },
groundedOn: { type: 'string', description: 'the verified fact + source this change rests on' },
},
},
},
descriptionAdvice: { type: 'string', description: 'how to tune the frontmatter description for correct triggering' },
},
}
const DRAFT = {
type: 'object',
additionalProperties: false,
required: ['files', 'claims'],
properties: {
files: { type: 'array', items: { type: 'string' }, description: 'absolute paths written' },
claims: {
type: 'array',
description: 'Every technical claim in the rebuilt skill a reviewer should check.',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'claim', 'evidence'],
properties: {
id: { type: 'string' },
claim: { type: 'string' },
evidence: { type: 'string', description: 'file path with line, or URL, supporting the claim' },
},
},
},
},
}
const VERDICT = {
type: 'object',
additionalProperties: false,
required: ['refuted', 'reason'],
properties: {
refuted: { type: 'boolean', description: 'true if the claim is unsupported, outdated, or contradicted by its source' },
reason: { type: 'string' },
shouldSayInstead: { type: 'string', description: 'the correct statement, if refuted' },
},
}
phase('Investigate')
// Fan out: each agent gains the expertise for one facet by reading real sources, never guessing.
const findings = (await parallel(
facets.map(f => () =>
agent(
`You are gaining the expertise needed to judge and rewrite the "${skillName}" skill at ${skillPath}.\n` +
`Facet: ${f.prompt}\n\n` +
`Read the ACTUAL code, docs, and tools — do not speculate or rely on memory for third-party behavior. ` +
`Gather ground truth: what is genuinely true, what the skill currently claims, and every place the two diverge ` +
`(stale, vague, wrong, or missing). Cite a file path (with line) or URL for every fact and every divergence.`,
{ label: `investigate:${f.key}`, phase: 'Investigate', schema: FINDINGS },
).then(r => ({ facet: f.key, ...r })),
),
)).filter(Boolean)
log(`Investigated ${findings.length}/${facets.length} facets; ${findings.reduce((n, f) => n + f.divergences.length, 0)} divergences found.`)
phase('Synthesize')
// One agent needs ALL findings + the current skill together to plan a coherent rebuild — a real barrier.
const plan = await agent(
`Read the current skill at ${skillPath} and these investigation findings:\n${JSON.stringify(findings)}\n\n` +
`Produce a concrete plan to rebuild the skill: what to fix, add, cut, and restructure, with the verified fact ` +
`(and its source) each change rests on. Apply skill-writing principles: progressive disclosure (lean SKILL.md, ` +
`detail in references/), explain the WHY instead of stacking MUSTs, and a description tuned for correct triggering. ` +
`Preserve the directory and the frontmatter \`name\`.`,
{ phase: 'Synthesize', schema: PLAN },
)
phase('Draft')
// Single writer, so no worktree isolation needed. It enumerates the claims it makes for verification.
const draft = await agent(
`Rebuild the "${skillName}" skill at ${skillPath} per this plan:\n${JSON.stringify(plan)}\n\n` +
`Edit SKILL.md and any bundled resources in place; preserve the directory and the frontmatter \`name\`. ` +
`Every technical claim must trace to ground truth from the findings — cite or cut. ` +
`Return the files you wrote and the full set of factual claims a reviewer should check, each with its supporting evidence.`,
{ phase: 'Draft', schema: DRAFT },
)
phase('Verify')
// Fan out: each claim gets an independent skeptic prompted to refute it against its own source.
const verdicts = (await parallel(
draft.claims.map(c => () =>
agent(
`Adversarially check this claim from the rebuilt "${skillName}" skill: "${c.claim}".\n` +
`Evidence cited: ${c.evidence}\n\n` +
`Go read the cited source yourself and TRY TO REFUTE the claim. Default to refuted=true if you cannot ` +
`independently confirm it from the source. If it is unsupported, outdated, or contradicted, say what the skill should say instead.`,
{ label: `verify:${c.id}`, phase: 'Verify', schema: VERDICT },
).then(v => ({ ...c, ...v })),
),
)).filter(Boolean)
const broken = verdicts.filter(v => v.refuted)
log(`Verified ${verdicts.length} claims; ${broken.length} refuted.`)
if (broken.length) {
phase('Correct')
await agent(
`These claims in the rebuilt skill at ${skillPath} were refuted during verification:\n${JSON.stringify(broken)}\n\n` +
`Fix each one in the skill files: correct the statement to match ground truth (use \`shouldSayInstead\`), or cut it. ` +
`Do not introduce any new unverified claim.`,
{ phase: 'Correct', schema: { type: 'object', additionalProperties: false, required: ['fixed'], properties: { fixed: { type: 'array', items: { type: 'string' } } } } },
)
}
return { skillPath, filesWritten: draft.files, claimsChecked: verdicts.length, refuted: broken }
+6
View File
@@ -0,0 +1,6 @@
.git
**/node_modules
**/.env
**/.DS_Store
frontend/dist
backend/target
-2
View File
@@ -1,5 +1,3 @@
ref
target
.agents
.playwright-cli
node_modules
+50
View File
@@ -0,0 +1,50 @@
name: Docker
on:
push:
branches: [master]
env:
REGISTRY: gitea.coracle.social
IMAGE: coracle/caravel
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: hodlbod
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -1,7 +1,10 @@
ref
todo.md
node_modules
target
data
.env
.claude
**/.env
.playwright-cli
.agents/settings.local.json
+50
View File
@@ -0,0 +1,50 @@
# Style guide
## Comments
Keep comments minimal, one line if possible.
There is one right place to document any given information:
- Functions should have a doc comment explaining the purpose of the function, not its implementation.
- Only very strange behavior should be documented using non-doc comments.
- Models and their fields are documented in models.rs, not in migrations, implementations, or anywhere on the frontend.
- Database indexes are documented in migrations.
## Data Modeling
When naming a foreign key, always use `{model}_{pk}`, for example `relay.tenant_pubkey`.
When referring to a tenant's pubkey, always name it `tenant_pubkey`, not `tenant` or `pubkey`. The exception to this is when we're in a context where we're already talking about tenants, e.g. `tenant.pubkey`, `get_tenant(pubkey)` or any tenant-related routes.
## Migrations
Pre-release: squash schema changes into `0001_init.sql` rather than adding new migration files. Once released, migrations become append-only.
Document indexes (what use cases they support), but not tables (those are documented in `models.rs`).
## Markdown
Do not hard-break markdown files at a certain number of characters. Allow readers to implement line wrapping naturally instead.
## Rust
Prefer `&str` over `&String` for function parameters — `&str` accepts both `&String` (via deref coercion) and string literals, so it's strictly more flexible. Only take `String` when you need ownership (storing in a struct, mutating, or transferring ownership).
Avoid passing `&mut` to functions. The performance improvement often comes at the cost of poor abstraction boundaries and error prone business logic. Instead, return results to the caller which can manage mutability itself, or re-calculate/fetch mutable data.
Don't be overly DRY. Deep call trees are harder to read; factoring functions into many tiny pieces means that function boundaries are defined less by the domain or the responsibility of a given piece of code than by coincidental similarity. New functions should be created when 1. they represent a different concern that is the responsibility of a different part of the codebase, 2. the contained logic is repeated 3+ times, or 3. the contents of the function are complex and naming them makes the logical flow of the code easier to follow.
## Typescript
Don't add re-export shims to preserve old import paths. When something moves, import it from its new canonical location at every call site and update all the imports. Each symbol has exactly one place it's exported from.
## Verification
Check justfile and frontend/package.json for common commands for linting/building.
## Skills
Skills should be created and maintained when updating the codebase. Before creating a skill, check to see if a relevant one already exists.
Avoid including code in skills unless the purpose of the skill is to illustrate coding principles; architecture and domain should be explained in plain English and provide a high-level overview of the topic without going into implementation details.
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
# ---------- Build the Rust backend ----------
FROM rust:1.94-bookworm AS backend-build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
COPY backend/migrations ./migrations
RUN cargo build --release
# ---------- Build the frontend with placeholder config ----------
# The frontend is compiled once, here, with sentinel VITE_* values. The runtime
# entrypoint find-and-replaces those sentinels with the real configuration when
# the container starts, so one image can be deployed with any config — no rebuild
# and no build step at startup.
FROM node:20-slim AS frontend-build
RUN npm install -g bun
WORKDIR /app/frontend
COPY frontend/package.json frontend/bun.lock ./
RUN bun install
COPY frontend ./
ENV VITE_API_URL=__VITE_API_URL__ \
VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__
RUN bun run build
# ---------- Runtime ----------
# node:20-slim is bookworm-based, so it is ABI-compatible with the backend binary.
# No bun / frontend sources here — just the prebuilt bundle and a static server.
FROM node:20-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g serve
WORKDIR /app
COPY --from=backend-build /app/target/release/backend /app/backend
COPY --from=frontend-build /app/frontend/dist /app/dist
# Single entrypoint: substitute the real config into the prebuilt bundle, then
# run both processes and exit (so the orchestrator restarts us) if either dies.
RUN cat > /app/entrypoint.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
VITE_API_URL="${SERVER_URL:-}"
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
# Escape characters that are special in a sed replacement.
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
echo "Applying runtime configuration to the frontend bundle..."
while IFS= read -r -d '' f; do
sed -i \
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
"$f"
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
echo "Starting backend (:2892) and frontend (:3000)..."
/app/backend &
backend_pid=$!
serve -s /app/dist -l 3000 &
serve_pid=$!
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
# Exit as soon as either process exits.
wait -n
EOF
RUN chmod +x /app/entrypoint.sh
EXPOSE 2892 3000
CMD ["/app/entrypoint.sh"]
+59 -2
View File
@@ -2,7 +2,64 @@
A multi-tenant platform for hosting Nostr community relays, built on top of [zooid](https://github.com/coracle-social/zooid).
## Quick Start (Local Development)
## Deployment
Caravel ships as a single Docker image, built from the repository-root [`Dockerfile`](./Dockerfile) and published by [`.gitea/workflows/docker-publish.yml`](./.gitea/workflows/docker-publish.yml) as `gitea.coracle.social/coracle/caravel`. One container runs both services: the backend API on port `2892` and the frontend on port `3000`.
Both the backend and the frontend are compiled into the image at build time. The frontend is built with placeholder config that the entrypoint replaces with the real values when the container starts, so one image can be deployed with any configuration — no rebuild required.
Caravel needs a reachable [zooid](https://github.com/coracle-social/zooid) instance (the [Local Development](#local-development) section below shows how to run one). Substitute your own values for the placeholders below:
```sh
docker run -d \
--name caravel \
-p 2892:2892 \
-p 3000:3000 \
-v my-caravel-data:/app/data \
-e PLATFORM_NAME=Caravel \
-e RELAY_DOMAIN=example.com \
-e APP_URL=https://example.com \
-e ZOOID_API_URL=http://zooid:3334 \
-e DATABASE_URL=sqlite://data/caravel.db \
-e SERVER_URL=https://api.example.com \
-e SERVER_PORT=2892 \
-e SERVER_ADMIN_PUBKEYS=<your-hex-pubkey> \
-e SERVER_ALLOW_ORIGINS=https://example.com \
-e ROBOT_SECRET=<hex-nostr-secret-key> \
-e ROBOT_NAME=Caravel \
-e ROBOT_DESCRIPTION="Relay manager bot" \
-e ROBOT_PICTURE=https://example.com/robot.png \
-e ROBOT_WALLET=<nwc-connection-uri> \
-e ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol \
-e ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social \
-e ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub \
-e BLOSSOM_S3_ENDPOINT=https://s3.example.com \
-e BLOSSOM_S3_REGION=us-east-1 \
-e BLOSSOM_S3_BUCKET=caravel-blossom \
-e BLOSSOM_S3_ACCESS_KEY=<s3-access-key> \
-e BLOSSOM_S3_SECRET_KEY=<s3-secret-key> \
-e LIVEKIT_URL=wss://livekit.example.com \
-e LIVEKIT_API_KEY=<livekit-api-key> \
-e LIVEKIT_API_SECRET=<livekit-api-secret> \
-e STRIPE_SECRET_KEY=<stripe-secret-key> \
gitea.coracle.social/coracle/caravel
```
Notes:
- Every backend variable above is **required** — the server exits on startup if any is missing or empty. See [`backend/.env.template`](./backend/.env.template) for what each one means.
- `ROBOT_SECRET` is the robot account's hex nostr secret key; its pubkey must be in zooid's `API_WHITELIST` (the backend signs zooid requests with it via NIP-98).
- `SERVER_ALLOW_ORIGINS` must include the frontend's public origin, or browsers will be blocked by CORS.
- The frontend's `VITE_` prefixed env variables are automatically populated from the provided env variables.
- `-v my-caravel-data:/app/data` persists the SQLite database.
To build the image yourself instead of pulling it:
```sh
docker build -t caravel .
```
## Local Development
### Prerequisites
@@ -26,7 +83,7 @@ docker run -it \
-v ./config:/app/config \
-v ./media:/app/media \
-v ./data:/app/data \
ghcr.io/coracle-social/zooid
gitea.coracle.social/coracle/zooid
```
### 2. Configure the backend
+20 -14
View File
@@ -1,32 +1,38 @@
# Server
HOST=127.0.0.1
PORT=2892
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
SERVER_URL=http://127.0.0.1:2892
SERVER_PORT=2892
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
SERVER_ADMIN_PUBKEYS=
# Auth
ADMINS= # Comma-separated hex pubkeys with admin access
# Frontend
APP_URL=http://127.0.0.1:5173
# Database
DATABASE_URL=sqlite://data/caravel.db
# Robot identity (published as kind 0)
ROBOT_SECRET= # Nostr private key (hex)
# Robot identity
ROBOT_SECRET=
ROBOT_NAME=
ROBOT_DESCRIPTION=
ROBOT_PICTURE=
ROBOT_OUTBOX_RELAYS=relay.damus.io,relay.primal.net,nos.lol
ROBOT_INDEXER_RELAYS=purplepag.es,relay.damus.io,indexer.coracle.social
ROBOT_MESSAGING_RELAYS=auth.nostr1.com,relay.keychat.io,relay.ditto.pub
ROBOT_WALLET=
ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol
ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social
ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub
# Zooid
ZOOID_API_URL=http://127.0.0.1:3334
ZOOID_API_SECRET=
RELAY_DOMAIN=spaces.coracle.social
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# Blossom S3
BLOSSOM_S3_ENDPOINT=
BLOSSOM_S3_REGION=
BLOSSOM_S3_BUCKET=
BLOSSOM_S3_ACCESS_KEY=
BLOSSOM_S3_SECRET_KEY=
# Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
STRIPE_SECRET_KEY=
+13
View File
@@ -210,6 +210,7 @@ dependencies = [
"nostr-sdk",
"nwc",
"rand 0.8.5",
"regex",
"reqwest",
"serde",
"serde_json",
@@ -1894,6 +1895,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
+1
View File
@@ -24,6 +24,7 @@ hmac = "0.12"
sha2 = "0.10"
dotenvy = "0.15.7"
base64 = "0.22"
regex = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
-31
View File
@@ -1,31 +0,0 @@
FROM rust:1.85-bookworm AS build
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/target/release/backend /app/backend
EXPOSE 2892
CMD ["/app/backend"]
+82 -39
View File
@@ -8,7 +8,7 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
- Axum (HTTP API)
- SQLx + SQLite
- Tokio (async runtime + workers)
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect)
- Nostr SDK (NIP-98 auth, NIP-17 DMs, NIP-47 wallet connect, NIP-44 encryption at rest)
## Layout
@@ -17,48 +17,82 @@ backend/
migrations/
0001_init.sql
src/
api.rs # Axum routes + NIP-98 auth checks
billing.rs # Invoice generation + collection worker
infra.rs # Zooid sync worker
main.rs # App bootstrap
models.rs # DB models
repo.rs # Data access layer
robot.rs # Nostr robot identity + DM sending
main.rs # App bootstrap: load Env, build services, serve + spawn workers
env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing)
api.rs # Shared Api state, router, NIP-98 auth + authorization helpers
web.rs # HTTP response envelope + helpers
routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices)
models.rs # Domain models + sqlite rows
query.rs # Database reads
command.rs # Database writes + activity broadcast
db.rs # SQLite pool, migrations, activity broadcast channel
billing.rs # Stripe subscription reconciliation + Lightning collection worker
stripe.rs # Thin Stripe REST client
wallet.rs # NWC wallet handle (NIP-47)
bitcoin.rs # Fiat ↔ BTC/msats conversion
infra.rs # Zooid relay-sync worker
robot.rs # Nostr robot identity + DM sending
```
## Configuration
Environment variables:
All configuration is read from the environment by `Env::load()` at startup. **Every variable below is required**: `Env::load()` panics if any is missing or blank, and comma-separated lists must contain at least one entry. Copy `.env.template` to `.env` to get started.
| Variable | Description | Default |
| ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `2892` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
**Server**
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly |
| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) |
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
| `APP_URL` | Frontend base URL; used to build links in DMs and invoices |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
**Robot identity**
| Variable | Description |
| ------------------------ | -------------------------------------------------------------------------------------------------- |
| `ROBOT_SECRET` | Robot Nostr secret key; used for signing, NIP-44 encryption of stored NWC URLs, and NIP-98 auth |
| `ROBOT_NAME` | Robot display name (kind `0`) |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) |
| `ROBOT_WALLET` | System NWC URL used to issue and look up BOLT11 invoices |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays the robot publishes its profile and kind `10002` relay list to |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay/profile discovery |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated DM relays published as kind `10050` |
**Relay hosting (zooid / livekit)**
| Variable | Description |
| -------------------- | ------------------------------------------------------- |
| `ZOOID_API_URL` | Zooid API base URL used by the infra sync worker |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when a relay enables livekit |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid |
**Blossom S3** — sent to zooid as the S3 adapter config (with `key_prefix` = relay id) when a relay enables blossom.
| Variable | Description |
| ----------------------- | --------------------- |
| `BLOSSOM_S3_ENDPOINT` | S3 endpoint URL |
| `BLOSSOM_S3_REGION` | S3 region |
| `BLOSSOM_S3_BUCKET` | S3 bucket name |
| `BLOSSOM_S3_ACCESS_KEY` | S3 access key ID |
| `BLOSSOM_S3_SECRET_KEY` | S3 secret access key |
**Billing (Stripe)**
| Variable | Description |
| ------------------- | ----------------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
Comma-separated list variables are split on commas and trimmed; empty entries are dropped.
## Schema and Architecture
See [spec](spec) for more details
The database schema lives in [migrations](migrations); see the module-level doc comments in `src/` for architecture details.
## API Routes
@@ -68,26 +102,35 @@ Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
- `GET /tenants` — list tenants (admin)
- `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
- `PUT /tenants/:pubkey` — update tenant `nwc_url` (admin or same tenant)
- `GET /tenants/:pubkey/relays` — list tenant relays (admin or same tenant)
- `GET /relays` — list relays (admin)
- `POST /relays` — create relay (admin or relay tenant)
- `GET /relays/:id` — get relay (admin or relay tenant)
- `GET /relays/:id/members` — list relay members from zooid (admin or relay tenant)
- `PUT /relays/:id` — update relay (admin or relay tenant)
- `GET /relays/:id/activity` — list relay activity (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
- `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant)
- `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant)
- `GET /tenants/:pubkey/invoices/draft` — get the tenant's synthetic draft invoice for the current period, or `null` if nothing is due (admin or same tenant)
- `GET /tenants/:pubkey/invoices/draft/items` — list the draft invoice's line items (admin or same tenant)
- `GET /invoices/:id` — get invoice (admin or same tenant)
- `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant)
- `GET /invoices/:id/items` — list invoice line items (admin or same tenant)
- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (same tenant only)
## API Auth Model
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
- Backend verifies event kind, signature, and that `u` contains configured `HOST`.
- Backend verifies event kind, signature, and that `u` equals configured `SERVER_URL`.
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
+110 -16
View File
@@ -1,29 +1,34 @@
CREATE TABLE IF NOT EXISTS activity (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
created_at INTEGER NOT NULL,
activity_type TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tenant (
pubkey TEXT PRIMARY KEY,
nwc_url TEXT NOT NULL DEFAULT '',
nwc_error TEXT,
stripe_error TEXT,
created_at INTEGER NOT NULL,
billing_anchor INTEGER,
stripe_customer_id TEXT NOT NULL,
stripe_subscription_id TEXT,
past_due_at INTEGER
stripe_payment_method_id TEXT,
renewed_at INTEGER,
churned_at INTEGER
);
CREATE TABLE IF NOT EXISTS activity (
id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
activity_type TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
billed_at INTEGER,
snapshot TEXT NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS relay (
id TEXT PRIMARY KEY,
tenant TEXT NOT NULL,
schema TEXT NOT NULL,
tenant_pubkey TEXT NOT NULL,
subdomain TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL,
stripe_subscription_item_id TEXT,
plan_id TEXT NOT NULL,
status TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0,
sync_error TEXT NOT NULL DEFAULT '',
@@ -37,5 +42,94 @@ CREATE TABLE IF NOT EXISTS relay (
blossom_enabled INTEGER NOT NULL DEFAULT 0,
livekit_enabled INTEGER NOT NULL DEFAULT 0,
push_enabled INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (tenant) REFERENCES tenant(pubkey)
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS invoice (
id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
method TEXT CHECK (method IS NULL OR method IN ('nwc','stripe','oob')),
amount INTEGER NOT NULL,
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL,
created_at INTEGER NOT NULL,
paid_at INTEGER,
voided_at INTEGER,
notified_at INTEGER,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS invoice_item (
id TEXT PRIMARY KEY,
invoice_id TEXT,
activity_id TEXT,
tenant_pubkey TEXT NOT NULL,
relay_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
voided_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id),
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE TABLE IF NOT EXISTS bolt11 (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
lnbc TEXT NOT NULL,
msats INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE TABLE IF NOT EXISTS intent (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
payment_method_id TEXT NOT NULL,
payment_intent_id TEXT,
created_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE TABLE IF NOT EXISTS checkout (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
session_id TEXT NOT NULL,
url TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
settled_at INTEGER,
FOREIGN KEY (invoice_id) REFERENCES invoice(id)
);
CREATE INDEX IF NOT EXISTS idx_activity_tenant_created ON activity (tenant_pubkey, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_resource_created ON activity (resource_id, created_at);
CREATE INDEX IF NOT EXISTS idx_activity_unbilled ON activity (tenant_pubkey, created_at) WHERE billed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_relay_tenant_pubkey ON relay (tenant_pubkey);
CREATE INDEX IF NOT EXISTS idx_invoice_tenant_created ON invoice (tenant_pubkey, created_at);
-- Dunning scans a tenant's still-open invoices oldest-first to retry payment.
CREATE INDEX IF NOT EXISTS idx_invoice_open ON invoice (tenant_pubkey, created_at) WHERE paid_at IS NULL AND voided_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_invoice_item_invoice ON invoice_item (invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_item_outstanding ON invoice_item (tenant_pubkey) WHERE invoice_id IS NULL AND voided_at IS NULL;
-- At most one line item per billable activity to ensure no double-billing.
CREATE UNIQUE INDEX IF NOT EXISTS idx_invoice_item_activity ON invoice_item (activity_id);
CREATE INDEX IF NOT EXISTS idx_bolt11_invoice_created ON bolt11 (invoice_id, created_at);
CREATE INDEX IF NOT EXISTS idx_checkout_invoice_created ON checkout (invoice_id, created_at);
-- At most one unsettled write-ahead intent per invoice: enforces the invariant
-- and is the ON CONFLICT target for the get-or-create in `ensure_pending_intent`.
CREATE UNIQUE INDEX IF NOT EXISTS idx_intent_unsettled ON intent (invoice_id) WHERE settled_at IS NULL;
-223
View File
@@ -1,223 +0,0 @@
# `pub struct Api`
Api manages the HTTP interface for the application
Members:
- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST`
- `admins: Vec<String>` - a list of admin pubkeys from `ADMINS`
- `query: Query`
- `command: Command`
- `billing: Billing`
Notes:
- Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request
- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant`
- Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code.
- Unsuccessful API responses should be of the form `{error, code}` with an appropriate http status code. `code` is a short error code (e.g. `duplicate-subdomain`) and `error` is a human-readable error message.
## `pub fn new() -> Self`
- Reads environment and populates members
## `pub fn router(&self) -> Result<()>`
- Returns an `axum::Router`
--- Plan routes
## `async fn list_plans(...) -> Response`
- Serves `GET /plans`
- No authentication required
- Return `data` is a list of plan structs from `Query::list_plans`
## `async fn get_plan(...) -> Response`
- Serves `GET /plans/:id`
- No authentication required
- Return `data` is a single plan struct matching `id`
- If plan does not exist, return `404` with `code=not-found`
--- Identity routes
## `async fn get_identity(...) -> Response`
- Serves `GET /identity`
- Authorizes anyone, but must be authorized
- Side-effect-free: returns `{ pubkey, is_admin }` only
- Clients must call `POST /tenants` before any tenant-scoped write
- Return `data` is an `Identity` struct
--- Tenant routes
## `async fn list_tenants(...) -> Response`
- Serves `GET /tenants`
- Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants`
## `async fn create_tenant(...) -> Response`
- Serves `POST /tenants`
- Authorizes anyone, but must be authorized
- No request body; target pubkey is derived from NIP-98 auth
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- If Stripe customer creation fails, return `code=stripe-customer-create-failed`
- Always returns `200` (create-or-get is uniform)
- Return `data` is a single `Tenant` struct
## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey`
- Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `query.get_tenant`
## `async fn update_tenant(...) -> Response`
- Serves `PUT /tenants/:pubkey`
- Authorizes admin or matching tenant
- Updates tenant using `command.update_tenant`
- Return `data` is the updated tenant struct
## `async fn list_tenant_relays(...) -> Response`
- Serves `GET /tenants/:pubkey/relays`
- Authorizes admin or matching tenant
- Return `data` is a list of relay structs from `query.list_relays_for_tenant`
--- Relay routes
## `async fn list_relays(...) -> Response`
- Serves `GET /relays`
- Authorizes admin only
- Return `data` is a list of relay structs from `query.list_relays`
## `async fn get_relay(...) -> Response`
- Serves `GET /relays/:id`
- Authorizes admin or relay owner
- Return `data` is a single relay struct from `query.get_relay`
## `async fn create_relay(...) -> Response`
- Serves `POST /relays`
- Authorizes admin or matching tenant pubkey in request body
- Validates/prepares the relay data to be saved using `prepare_relay`
- Creates a new relay using `command.create_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct. Use HTTP `201`.
## `async fn update_relay(...) -> Response`
- Serves `PUT /relays/:id`
- Authorizes admin or relay owner
- Validates/prepares the relay data to be saved using `prepare_relay`
- Updates the given relay using `command.update_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct.
## `async fn list_relay_activity(...) -> Response`
- Serves `GET /relays/:id/activity`
- Authorizes admin or relay owner
- Get activity from `query.list_activity_for_relay`
- Return `data` is `{activity}`
## `async fn deactivate_relay(...) -> Response`
- Serves `POST /relays/:id/deactivate`
- Authorizes admin or relay owner
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
- Call `command.deactivate_relay`
- Return `data` is empty
## `async fn reactivate_relay(...) -> Response`
- Serves `POST /relays/:id/reactivate`
- Authorizes admin or relay owner
- If relay is already active, return a `400` with `code=relay-is-active`
- Call `command.activate_relay`
- Return `data` is empty
--- Invoice routes
## `async fn list_tenant_invoices(...) -> Response`
- Serves `GET /tenants/:pubkey/invoices`
- Authorizes admin or matching tenant
- Looks up tenant by pubkey, fetches invoices from Stripe API using `stripe_customer_id`
- Return `data` is a list of Stripe invoice objects: `{ id, status, amount_due, currency, hosted_invoice_url, period_start, period_end }`
## `async fn get_invoice(...) -> Response`
- Serves `GET /invoices/:id`
- Fetches invoice from Stripe API by ID
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
- Return `data` is a single Stripe invoice object
- If invoice does not exist, return `404` with `code=not-found`
## `async fn get_invoice_bolt11(...) -> Response`
- Serves `GET /invoices/:id/bolt11`
- Fetches invoice from Stripe API by ID
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
- If invoice `status` is not `open`, return `400` with `code=invoice-not-open`
- Creates a bolt11 Lightning invoice for the invoice's `amount_due` using `billing.create_bolt11(amount_due)`
- Return `data` is `{ bolt11 }`
--- Stripe session route
## `async fn create_stripe_session(...) -> Response`
- Serves `GET /tenants/:pubkey/stripe/session`
- Authorizes admin or matching tenant
- Looks up tenant by pubkey
- Creates a Stripe Customer Portal session for the tenant's `stripe_customer_id`
- Return `data` is `{ url }` — the portal session URL
--- Stripe webhook route
## `async fn stripe_webhook(...) -> Response`
- Serves `POST /stripe/webhook`
- No NIP-98 authentication — uses Stripe signature verification instead
- Reads raw request body and `Stripe-Signature` header
- Calls `billing.handle_webhook(payload, signature)`
- Returns `200` on success, `400` on signature verification failure
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
--- Utilities
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- Parses `Authorization` header
- Validates event kind (`27235`) and signature using `nostr_sdk`
- Validates event `u` contains configured `HOST`
- Intentionally does **not** enforce exact request URL/method/query matching
- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
- Returns pubkey if header all checks pass
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
## `require_admin(&self, authorized_pubkey: &str)`
- Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error
## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)`
- Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey`
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- Validate `subdomain`
- Validate that `plan` matches a known plan id from `Query::list_plans`
- If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature`
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
- Populate `schema` if not already set
- Populate missing fields using reasonable defaults
-114
View File
@@ -1,114 +0,0 @@
# `pub struct Billing`
Billing encapsulates logic related to synchronizing state with Stripe, processing payments via NWC, and managing subscription lifecycle.
Members:
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
- `query: Query`
- `command: Command`
- `robot: Robot`
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Reads environment and populates members
- Panics if `STRIPE_SECRET_KEY` is missing/empty
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
## `pub fn start(&self)`
- Subscribes to `command.notify.subscribe()`
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, and `complete_relay_sync`, call `self.sync_relay_subscription`.
## `pub fn sync_relay_subscription(&self, activity: &Activity)`
Manages the Stripe subscription and subscription items for a relay's tenant. Only paid (non-free) relays interact with Stripe. Free-only tenants have no subscription. Must be idempotent.
- Fetch the relay and tenant associated with the `activity`
- **If relay plan is `free`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `inactive` or `delinquent`**: if the relay has a `stripe_subscription_item_id`, delete it via the Stripe API and call `command.delete_relay_subscription_item`. Then check cleanup (below). Return early.
- **If relay is `active` and on a paid plan**:
- **Ensure subscription exists**: If the tenant has no `stripe_subscription_id`, create a Stripe subscription for the customer with `collection_method: "charge_automatically"` and the relay's price as the first item. Save the subscription ID via `command.set_tenant_subscription` and the item ID via `command.set_relay_subscription_item`. Return early.
- **Sync the subscription item**: If the tenant already has a subscription, create or update the relay's Stripe subscription item to the plan's `stripe_price_id` via the Stripe API, then call `command.set_relay_subscription_item`.
- **Clean up empty subscription**: After any item deletion, check if the tenant has any remaining active paid relays. If none and the tenant has a `stripe_subscription_id`, cancel the Stripe subscription immediately and call `command.clear_tenant_subscription`.
## `pub fn handle_webhook(&self, payload: &str, signature: &str) -> Result<()>`
- Verify the webhook signature using `self.stripe_webhook_secret`
- Parse the event and dispatch by type:
- `invoice.created` -> `self.handle_invoice_created`
- `invoice.paid` -> `self.handle_invoice_paid`
- `invoice.payment_failed` -> `self.handle_invoice_payment_failed`
- `invoice.overdue` -> `self.handle_invoice_overdue`
- `customer.subscription.updated` -> `self.handle_subscription_updated`
- `customer.subscription.deleted` -> `self.handle_subscription_deleted`
- Unknown event types are ignored (return Ok)
## `pub async fn stripe_list_invoices(&self, customer_id: &str) -> Result<Value>`
- Fetches invoices from Stripe API for the given customer
- Returns the `data` array from the Stripe response
## `pub async fn stripe_get_invoice(&self, invoice_id: &str) -> Result<Value>`
- Fetches a single invoice from Stripe API by ID
- Returns the full Stripe invoice object
## `pub async fn create_bolt11(&self, amount_due_cents: i64) -> Result<String>`
- Creates a bolt11 Lightning invoice for the given amount using the system NWC wallet (`self.nwc_url`)
- Returns the bolt11 invoice string
## `pub async fn stripe_create_portal_session(&self, customer_id: &str) -> Result<String>`
- Creates a Stripe Customer Portal session for the given customer
- Returns the portal session URL
## `fn handle_invoice_created(&self, invoice: &Invoice)`
Attempts to pay a new subscription invoice. Payment priority:
1. **NWC auto-pay**: If the tenant has a `nwc_url`:
- Create a bolt11 Lightning invoice for the invoice amount using `self.nwc_url` (the receiving/system wallet)
- Pay the bolt11 invoice using the tenant's `nwc_url` (the spending/tenant wallet)
- If payment succeeds: call Stripe `POST /v1/invoices/:id/pay` with `paid_out_of_band: true`. Clear `nwc_error` via `command.clear_tenant_nwc_error`.
- If payment fails: set `nwc_error` on tenant via `command.set_tenant_nwc_error`. Fall through to next option.
2. **Card on file**: If the tenant has a payment method on the Stripe customer, do nothing — Stripe will charge automatically.
3. **Manual payment**: If neither NWC nor card is available, send a DM via `robot.send_dm` notifying the tenant that payment is due with a link to the application for manual Lightning payment.
Skip invoices with `amount_due` of 0.
## `fn handle_invoice_paid(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- If tenant has `past_due_at` set:
- Clear `past_due_at` via `command.clear_tenant_past_due`
- Find all `delinquent` relays on paid plans for the tenant (relays marked delinquent by the billing system due to non-payment)
- Reactivate each one via `command.activate_relay`
## `fn handle_invoice_payment_failed(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- If tenant does not already have `past_due_at` set:
- Set `past_due_at` to now via `command.set_tenant_past_due`
- Send a DM via `robot.send_dm` notifying the tenant that their payment has failed and their relays may be deactivated if not resolved.
## `fn handle_invoice_overdue(&self, invoice: &Invoice)`
- Look up tenant by `stripe_customer_id`
- Mark all active relays on paid plans as delinquent via `command.mark_relay_delinquent` (sets status to `delinquent`, distinct from user-initiated `deactivate_relay`)
- Send a DM via `robot.send_dm` notifying the tenant that their paid relays have been deactivated due to non-payment
## `fn handle_subscription_updated(&self, subscription: &Subscription)`
- Look up tenant by `stripe_customer_id`
- If subscription status is `canceled` or `unpaid`:
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
- Mark all active paid relays as delinquent via `command.mark_relay_delinquent`
## `fn handle_subscription_deleted(&self, subscription: &Subscription)`
- Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
-109
View File
@@ -1,109 +0,0 @@
# `pub struct Command`
Command writes to the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
- `pub notify: broadcast::Sender<Activity>` - callers can subscribe via `command.notify.subscribe()`
Notes:
- All public write methods should be atomic
- All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`
- `insert_activity` builds and returns the `Activity` struct (using `chrono::Utc::now()` for `created_at`)
- After each successful commit, sends the `Activity` on the broadcast channel
## `pub fn new(&self, pool: SqlitePool) -> Self`
- Assigns pool to self
- Creates the broadcast channel
## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- Creates tenant, may throw sqlite uniqueness error on pubkey
- Logs activity as `(create_tenant, tenant_id)`
## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates tenant
- Logs activity as `(update_tenant, tenant_id)`
## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay, may throw sqlite uniqueness error on subdomain
- Sets relay status to `active`
- Logs activity as `(create_relay, relay_id)`
## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
- Updates relay, may throw sqlite uniqueness error on subdomain
- Logs activity as `(update_relay, relay_id)`
## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)`
- Used for user/admin-initiated deactivation only
## `pub fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent`
- Logs activity as `(deactivate_relay, relay_id)`
- Used exclusively by the billing system when a relay's subscription becomes past due
- `delinquent` relays are automatically reactivated via `activate_relay` when payment is received
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `active`
- Logs activity as `(activate_relay, relay_id)`
## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
- Sets `sync_error` on the relay
- Logs activity as `(fail_relay_sync, relay_id)`
## `pub fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 1`, clears `sync_error`
- Logs activity as `(complete_relay_sync, relay_id)`
## `pub fn delete_relay_subscription_item(&self, relay_id: &str) -> Result<()>`
- Sets `stripe_subscription_item_id = null`
- Does not log activity
## `pub fn set_relay_subscription_item(&self, relay_id: &str, stripe_subscription_item_id: &str) -> Result<()>`
- Sets `stripe_subscription_item_id`
- Does not log activity
## `pub fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
- Sets `stripe_subscription_id` on the tenant
- Does not log activity
## `pub fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
- Sets `stripe_subscription_id = null` on the tenant
- Does not log activity
## `pub fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
- Sets `nwc_error` on the tenant
- Does not log activity
## `pub fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
- Sets `nwc_error = null` on the tenant
- Does not log activity
## `pub fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at` to the current timestamp
- Does not log activity
## `pub fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at = null` on the tenant
- Does not log activity
-35
View File
@@ -1,35 +0,0 @@
# `pub struct Infra`
Infra is a service which listens for activity and synchronizes relay updates to a remote zooid instance via `api_url`.
Members:
- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
- `query: Query`
- `command: Command`
## `pub fn new(query: Query, command: Command) -> Self`
- Reads environment and populates members
## `pub async fn start(self)`
- Subscribes to `command.notify`
- Loops on `rx.recv()`, calling `handle_activity` for each received `Activity`.
## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
- Calls `sync_relay` and on success calls `command.complete_relay_sync`.
- On failure calls `command.fail_relay_sync`.
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
- Otherwise, sends `PATCH /relay/:id` to update it.
- Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
-9
View File
@@ -1,9 +0,0 @@
# `async fn main() -> Result<()>`
- Configures logging
- Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra`
- Get an axum router from `api.router`
- Adds CORS middleware based on `origins`
- Calls `axum::serve` with a listener
- Spawns `infra.start`
- Spawns `billing.start`
-92
View File
@@ -1,92 +0,0 @@
This file describes the domain model. This description should be translated into standard structs and sqlite schemas in a way that makes sense.
- Fields marked as private should use `#[serde(skip_serializing)]` in their definition.
- Fields marked as readonly should use `#[serde(skip_deserializing)]` in their definition.
# Identity
Identity is a description of a user.
- `pubkey` - the user's nostr pubkey
- `is_admin` - whether the user is an admin
# Activity
Activity is an audit log of all actions performed by a user or a worker process. This allows us to trace history to create invoices, synchronize actions to external services, and debug system behavior.
- `id` - a random activity ID
- `tenant` - a tenant ID
- `created_at` - unix timestamp when the activity was created
- `activity_type` is one of:
- `create_tenant`
- `update_tenant`
- `create_relay`
- `update_relay`
- `activate_relay`
- `deactivate_relay`
- `fail_relay_sync`
- `complete_relay_sync`
- `resource_type` is a string identifying the resource type being modified.
- `resource_id` is a string identifying the resource id being modified.
# Plan
A plan represents a rate charged for relays at a given feature/usage limit. Plans aren't saved to the database, but are simply hardcoded. However, they are exposed through the API so they can be used as a single source of truth.
- `id` - the plan slug
- `name` - the plan name
- `amount` - the plan monthly cost in USD
- `members` - the max number of members a relay can have before needing to upgrade. If empty, membership is not limited.
- `blossom` - whether blossom media hosting is available on this plan
- `livekit` - whether livekit audio/video calls are available on this plan
- `stripe_price_id` (nullable) - the identifier of the price in Stripe. Null for the free plan.
There are three plans available:
- `free` - $0/mo, up to 10 members, no blossom/livekit
- `basic` - $5/mo, up to 100 members, includes blossom/livekit
- `growth` - $25/mo, unlimited members, includes blossom/livekit
# Tenant
Tenants are customers of the service, identified by a nostr `pubkey`. Public metadata like name etc are pulled from the nostr network. They also have associated billing information.
- `pubkey` is the nostr public key identifying the tenant
- `nwc_url` (private) a nostr wallet connect URL used for **paying** invoices generated by the system on the tenant's behalf
- `nwc_error` (private) a string indicating the most recent NWC payment error, if any. Cleared on successful NWC payment.
- `created_at` unix timestamp identifying tenant creation time
- `stripe_customer_id` a string identifying the associated stripe customer
- `stripe_subscription_id` (nullable) a string identifying the associated stripe subscription. Created when the first paid (non-free) relay is activated, deleted when the last paid relay is removed or deactivated. Free-only tenants have no subscription.
- `past_due_at` (nullable) unix timestamp when the tenant's subscription became past due. Set on payment failure, cleared on payment success.
# Relay
A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid instance. Relay subdomains MUST be unique.
- `id` - a random ID identifying the relay
- `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read_only, calculated based on `subdomain` + `id`)
- `subdomain` - the relay's subdomain
- `plan` - the relay's plan
- `stripe_subscription_item_id` (nullable) - the Stripe subscription item id. Only set for relays on paid plans.
- `status` - one of `active|inactive|delinquent`. Only `active` relays count toward billing. `delinquent` is set by the billing system when a relay's subscription becomes past due; `inactive` is set when a user or admin manually deactivates a relay.
- `synced` - whether the relay has been successfully synced to zooid at least once.
- `sync_error` - a string indicating any errors encountered when synchronizing.
- `info_name` - the relay's name
- `info_icon` - the relay's icon image URL
- `info_description` - the relay's description
- `policy_public_join` - whether to allow non-members to join the relay without an invite code
- `policy_strip_signatures` - whether to remove signatures when serving events to non-admins
- `groups_enabled` - whether NIP 29 groups are enabled
- `management_enabled` - whether NIP 86 management API is enabled
- `blossom_enabled` - whether blossom file storage is enabled
- `livekit_enabled` - whether livekit calls are enabled
- `push_enabled` - whether relay push is enabled
Some attributes persisted to zooid via API have special handling:
- The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
- The value of `inactive` is calculated based on `status`
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
- The relay's `roles` are hard-coded for now.
-14
View File
@@ -1,14 +0,0 @@
# `pub async fn create_pool() -> Result<SqlitePool>`
Creates and returns a sqlite connection pool.
Notes:
- Database table names are singular: `activity`, `tenant`, `relay`
Steps:
- Reads `DATABASE_URL` from environment
- Ensures that any directories referred to in `DATABASE_URL` exist
- Initializes the sqlx pool
- Runs migrations found in the `migrations` directory
-49
View File
@@ -1,49 +0,0 @@
# `pub struct Query`
Query reads from the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
## `pub fn new(&self, pool: SqlitePool) -> Self`
- Assigns pool to self
## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns all tenants
## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
- Returns matching tenant
## `pub fn list_plans() -> Vec<Plan>`
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
- This is the source of truth for plan metadata exposed via API
## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays
## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays belonging to the given tenant
## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
- Returns matching relay
## `pub fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Tenant>`
- Returns the tenant matching the given `stripe_customer_id`
## `pub fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool>`
- Returns true if the tenant has any relays where `status = 'active'` and `plan != 'free'`
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
- Ordered newest-first
-25
View File
@@ -1,25 +0,0 @@
# `pub struct Robot`
Robot is a nostr identity which acts on behalf of the application.
Members:
- `secret: String` - a nostr secret key, from `ROBOT_SECRET`
- `name: String` - the name of the bot, from `ROBOT_NAME`
- `description: String` - the description of the bot, from `ROBOT_DESCRIPTION`
- `picture: String` - the picture URL for the bot, from `ROBOT_PICTURE`
- `outbox_client: nostr_sdk::Client` - used for publishing relay lists and metadata, connects to `ROBOT_OUTBOX_RELAYS`
- `indexer_client: nostr_sdk::Client` - used for publishing relay lists, connects to `ROBOT_INDEXER_RELAYS`
- `messagins_client: nostr_sdk::Client` - used for sending and receiving dms, connects to `ROBOT_MESSAGING_RELAYS`
## `pub fn new() -> Self`
- Reads environment and populates members. Relay urls should be split and normalized.
- Publishes a `kind 0` nostr profile, a `kind 10002` relay list, and `kind 10050` relay selections
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
- Fetches recipient's outbox relays from `indexer_relays` (cached)
- Fetches recipient's messaging relays from their outbox relays (cached)
- Sends DM to recipient via their messaging relays
- If no outbox/messaging relays are found, return an error
+156 -891
View File
File diff suppressed because it is too large Load Diff
+659 -1075
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
use anyhow::{Result, anyhow};
/// Convert a fiat amount in minor units (e.g. USD cents) to millisatoshis at the
/// current spot price, for pricing a Lightning invoice from an invoice total.
pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64> {
let price = get_bitcoin_price(&currency.to_uppercase()).await?;
let divisor = 10_f64.powi(currency_minor_exponent(currency)? as i32);
let amount_fiat = (amount_fiat_minor as f64) / divisor;
let amount_msats = (amount_fiat / price * 100_000_000_000.0).round();
Ok(amount_msats as u64)
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceResponse {
data: CoinbaseSpotPriceData,
}
#[derive(serde::Deserialize)]
struct CoinbaseSpotPriceData {
amount: String,
}
/// The current Bitcoin spot price in `currency`, from Coinbase.
pub async fn get_bitcoin_price(currency: &str) -> Result<f64> {
let http = reqwest::Client::new();
let url = format!("https://api.coinbase.com/v2/prices/BTC-{currency}/spot");
let resp = http.get(url).send().await?;
let body: CoinbaseSpotPriceResponse = resp.error_for_status()?.json().await?;
body.data
.amount
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))
}
/// Number of decimal places in `currency`'s minor unit, following Stripe's
/// currency conventions (most are 2, JPY/KRW/… are 0, BHD/KWD/… are 3).
fn currency_minor_exponent(currency: &str) -> Result<u8> {
let normalized = currency.to_uppercase();
let exponent = match normalized.as_str() {
// Zero-decimal currencies in Stripe.
"BIF" | "CLP" | "DJF" | "GNF" | "JPY" | "KMF" | "KRW" | "MGA" | "PYG" | "RWF" | "UGX"
| "VND" | "VUV" | "XAF" | "XOF" | "XPF" => 0,
// Three-decimal currencies in Stripe.
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3,
_ if normalized.chars().all(|c| c.is_ascii_alphabetic()) && normalized.len() == 3 => 2,
_ => return Err(anyhow!("invalid currency code: {currency}")),
};
Ok(exponent)
}
+770 -309
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
use std::path::Path;
use std::str::FromStr;
use std::sync::OnceLock;
use anyhow::Result;
use sqlx::{
Sqlite, SqlitePool, Transaction,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use tokio::sync::broadcast;
use crate::env;
use crate::models::Activity;
/// Process-wide connection pool. Set once at startup via [`init`]; read
/// everywhere else via [`pool`], so command/query stay free functions instead of
/// threading a handle through every service.
static POOL: OnceLock<SqlitePool> = OnceLock::new();
/// Process-wide activity broadcast. Mutations record an [`Activity`] and call
/// [`publish`] after their transaction commits; reactors (billing, infra)
/// [`subscribe`] to react to durable changes.
static NOTIFY: OnceLock<broadcast::Sender<Activity>> = OnceLock::new();
/// Create the connection pool from `env`, run migrations, and store it as the
/// process-wide global. Panics if called more than once.
pub async fn init() -> Result<()> {
let pool = create_pool(&env::get().database_url).await?;
POOL.set(pool).expect("pool already initialized");
let (notify, _) = broadcast::channel(64);
NOTIFY.set(notify).expect("notify already initialized");
Ok(())
}
/// The global pool. Panics if [`init`] hasn't run yet.
pub fn pool() -> &'static SqlitePool {
POOL.get().expect("pool not initialized")
}
/// Subscribe to the activity stream. Panics if [`init`] hasn't run yet.
pub fn subscribe() -> broadcast::Receiver<Activity> {
NOTIFY.get().expect("notify not initialized").subscribe()
}
/// Broadcast an activity to subscribers. Called after the writing transaction
/// commits, so reactors only ever observe durable rows. A send with no current
/// subscribers is intentionally ignored.
pub fn publish(activity: Activity) {
if let Some(notify) = NOTIFY.get() {
let _ = notify.send(activity);
}
}
/// Run `f` inside a transaction, commit on success, and roll back (on drop) if
/// it returns an error. Returns whatever `f` produces. Callers compose the
/// transaction-scoped `command`/`query` functions inside `f` to make a
/// multi-step write atomic.
pub async fn with_tx<F, T>(f: F) -> Result<T>
where
F: AsyncFnOnce(&mut Transaction<'_, Sqlite>) -> Result<T>,
{
let mut tx = pool().begin().await?;
let value = f(&mut tx).await?;
tx.commit().await?;
Ok(value)
}
async fn create_pool(database_url: &str) -> Result<SqlitePool> {
let database_url = normalize_sqlite_url(database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+140
View File
@@ -0,0 +1,140 @@
use std::sync::OnceLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
/// Process-wide configuration, loaded once from the environment at startup via
/// [`init`] and read everywhere else via [`get`].
static ENV: OnceLock<Env> = OnceLock::new();
/// Load configuration from the environment and store it as the global. Panics
/// if a required variable is missing or if called more than once.
pub fn init() {
ENV.set(Env::load())
.unwrap_or_else(|_| panic!("env already initialized"));
}
/// The global configuration. Panics if [`init`] hasn't run yet.
pub fn get() -> &'static Env {
ENV.get().expect("env not initialized")
}
#[derive(Clone)]
pub struct Env {
pub server_url: String,
pub server_port: u16,
pub server_admin_pubkeys: Vec<String>,
pub server_allow_origins: Vec<String>,
pub app_url: String,
pub database_url: String,
pub robot_name: String,
pub robot_wallet: String,
pub robot_picture: String,
pub robot_description: String,
pub robot_outbox_relays: Vec<String>,
pub robot_indexer_relays: Vec<String>,
pub robot_messaging_relays: Vec<String>,
pub blossom_s3_region: String,
pub blossom_s3_bucket: String,
pub blossom_s3_endpoint: String,
pub blossom_s3_access_key: String,
pub blossom_s3_secret_key: String,
pub zooid_api_url: String,
pub relay_domain: String,
pub livekit_url: String,
pub livekit_api_key: String,
pub livekit_api_secret: String,
pub stripe_secret_key: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys,
}
impl Env {
fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key");
Self {
server_url: require_str("SERVER_URL"),
server_port: require_u16("SERVER_PORT"),
server_admin_pubkeys: require_csv("SERVER_ADMIN_PUBKEYS"),
server_allow_origins: require_csv("SERVER_ALLOW_ORIGINS"),
app_url: require_str("APP_URL").trim_end_matches('/').to_string(),
database_url: require_str("DATABASE_URL"),
robot_name: require_str("ROBOT_NAME"),
robot_wallet: require_str("ROBOT_WALLET"),
robot_picture: require_str("ROBOT_PICTURE"),
robot_description: require_str("ROBOT_DESCRIPTION"),
robot_outbox_relays: require_csv("ROBOT_OUTBOX_RELAYS"),
robot_indexer_relays: require_csv("ROBOT_INDEXER_RELAYS"),
robot_messaging_relays: require_csv("ROBOT_MESSAGING_RELAYS"),
blossom_s3_region: require_str("BLOSSOM_S3_REGION"),
blossom_s3_bucket: require_str("BLOSSOM_S3_BUCKET"),
blossom_s3_endpoint: require_str("BLOSSOM_S3_ENDPOINT"),
blossom_s3_access_key: require_str("BLOSSOM_S3_ACCESS_KEY"),
blossom_s3_secret_key: require_str("BLOSSOM_S3_SECRET_KEY"),
zooid_api_url: require_str("ZOOID_API_URL"),
relay_domain: require_str("RELAY_DOMAIN"),
livekit_url: require_str("LIVEKIT_URL"),
livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
keys,
}
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
nip44::encrypt(
self.keys.secret_key(),
&self.keys.public_key(),
plaintext,
nip44::Version::V2,
)
.map_err(|e| anyhow!("encryption failed: {e}"))
}
pub fn decrypt(&self, ciphertext: &str) -> Result<String> {
nip44::decrypt(self.keys.secret_key(), &self.keys.public_key(), ciphertext)
.map_err(|e| anyhow!("decryption failed: {e}"))
}
pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&self.keys)
.await?;
Ok(auth)
}
}
fn require_str(key: &str) -> String {
let v = std::env::var(key)
.unwrap_or_else(|_| panic!("{key} is required"))
.trim()
.to_string();
if v.is_empty() {
panic!("{key} is required")
}
v
}
fn require_u16(key: &str) -> u16 {
require_str(key)
.parse()
.unwrap_or_else(|_| panic!("{key} is invalid"))
}
fn require_csv(key: &str) -> Vec<String> {
let v: Vec<String> = std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if v.is_empty() {
panic!("{key} is required");
}
v
}
+240 -148
View File
@@ -1,177 +1,190 @@
//! The relay-provisioning reactor: it keeps the external relay backend (the
//! zooid API) in sync with our relay rows, reacting to relay activity and
//! retrying failed syncs with backoff.
use anyhow::Result;
use nostr_sdk::prelude::*;
use std::time::Duration;
use crate::command::Command;
use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::query::Query;
use crate::command;
use crate::db;
use crate::env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query;
#[derive(Clone)]
pub struct Infra {
api_url: String,
relay_domain: String,
livekit_url: String,
livekit_api_key: String,
livekit_api_secret: String,
api_secret: String,
query: Query,
command: Command,
const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
/// Run the reactor for the life of the process: reconcile any relays left
/// unsynced from a previous run, then sync each relay as its activity arrives.
pub async fn start() {
let mut rx = db::subscribe();
if let Err(error) = reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
loop {
match rx.recv().await {
Ok(activity) => {
if let Err(e) = handle_activity(&activity).await {
tracing::error!(error = %e, "infra handle_activity failed");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged");
if let Err(error) = reconcile_relay_state("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay state after lag");
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
impl Infra {
pub fn new(query: Query, command: Command) -> Self {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
let livekit_api_key = std::env::var("LIVEKIT_API_KEY").unwrap_or_default();
let livekit_api_secret = std::env::var("LIVEKIT_API_SECRET").unwrap_or_default();
let api_secret = std::env::var("ZOOID_API_SECRET").unwrap_or_default();
Self {
api_url,
relay_domain,
livekit_url,
livekit_api_key,
livekit_api_secret,
api_secret,
query,
command,
}
async fn handle_activity(activity: &Activity) -> Result<()> {
let needs_sync = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay" | "fail_relay_sync"
);
if activity.resource_type != "relay" || !needs_sync {
return Ok(());
}
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
loop {
match rx.recv().await {
Ok(activity) => {
if let Err(e) = self.handle_activity(&activity).await {
tracing::error!(error = %e, "infra handle_activity failed");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
if activity.activity_type == "fail_relay_sync" {
schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
return Ok(());
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
return Ok(());
};
if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
sync_relay(&relay).await;
Ok(())
}
let is_new = relay.synced == 0;
self.sync_and_report(&relay, is_new).await;
}
async fn reconcile_relay_state(source: &str) -> Result<()> {
let relays = query::list_relays_pending_sync().await?;
Ok(())
if relays.is_empty() {
return Ok(());
}
async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
match self.sync_relay(relay, is_new).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = self.command.complete_relay_sync(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
}
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
if let Err(e2) = self.command.fail_relay_sync(relay, e.to_string()).await {
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
}
}
}
}
tracing::info!(
source,
relay_count = relays.len(),
"reconciling pending relay state"
);
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let keys = Keys::parse(&self.api_secret)?;
let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method)
.to_authorization(&keys)
.await?;
Ok(auth)
}
async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/');
let host = if self.relay_domain.is_empty() {
relay.subdomain.clone()
for relay in relays {
if relay.sync_error.trim().is_empty() {
sync_relay(&relay).await;
} else {
format!("{}.{}", relay.subdomain, self.relay_domain)
};
schedule_relay_sync_retry(&relay.id, source).await?;
}
}
let livekit = if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": self.livekit_url,
"api_key": self.livekit_api_key,
"api_secret": self.livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
};
Ok(())
}
let body = relay_sync_body(
relay,
host,
livekit,
is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
async fn schedule_relay_sync_retry(relay_id: &str, source: &str) -> Result<()> {
fn get_retry_delay(consecutive_failures: usize) -> Option<Duration> {
let retry_attempt = consecutive_failures.max(1);
if retry_attempt > RELAY_SYNC_RETRY_MAX_ATTEMPTS {
return None;
}
let exponent = (retry_attempt - 1).min(31);
let multiplier = 1u64 << exponent;
let delay_secs = RELAY_SYNC_RETRY_BASE_DELAY_SECS
.saturating_mul(multiplier)
.min(RELAY_SYNC_RETRY_MAX_DELAY_SECS);
Some(Duration::from_secs(delay_secs))
}
let activities = query::list_activity_for_resource(relay_id).await?;
let consecutive_failures = activities
.iter()
.take_while(|activity| activity.activity_type == "fail_relay_sync")
.count();
let Some(delay) = get_retry_delay(consecutive_failures) else {
tracing::warn!(
relay = relay_id,
consecutive_failures,
max_attempts = RELAY_SYNC_RETRY_MAX_ATTEMPTS,
"relay sync retries exhausted; awaiting manual intervention"
);
return Ok(());
};
let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, zooid_sync_http_method(is_new)).await?;
tracing::info!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
let request = if is_new {
client.post(&url)
} else {
client.patch(&url)
};
let relay_id = relay_id.to_string();
let response = request
.header("Authorization", auth)
.json(&body)
.send()
.await?;
tokio::spawn(async move {
tokio::time::sleep(delay).await;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("zooid sync returned {status}: {body}")
match query::get_relay(&relay_id).await {
Ok(Some(relay)) => sync_relay(&relay).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay_id, error = %e, "relay sync retry task failed");
}
}
});
Ok(())
}
async fn sync_relay(relay: &Relay) {
match try_sync_relay(relay).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
if let Err(e) = command::complete_relay_sync(relay).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark sync complete");
}
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
if let Err(e2) = command::fail_relay_sync(relay, e.to_string()).await {
tracing::error!(relay = %relay.id, error = %e2, "failed to record sync failure");
}
}
Ok(())
}
}
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
}
}
async fn try_sync_relay(relay: &Relay) -> Result<()> {
// A relay is "new" (POST with a freshly generated secret) only if it has
// never completed a sync. `synced == 1` short-circuits the activity lookup;
// otherwise check the activity history so that a re-sync after an update
// (which resets `synced` to 0) PATCHes instead of clobbering the secret.
let is_new = relay.synced != 1
&& query::get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await?
.is_none();
fn relay_sync_body(
relay: &Relay,
host: String,
livekit: serde_json::Value,
secret: Option<String>,
) -> serde_json::Value {
let mut body = serde_json::json!({
"host": host,
"schema": relay.schema,
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
"schema": relay.id,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
"description": relay.info_description,
"pubkey": relay.tenant,
"pubkey": relay.tenant_pubkey,
},
"policy": {
"public_join": relay.policy_public_join == 1,
@@ -179,8 +192,32 @@ fn relay_sync_body(
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": { "enabled": relay.blossom_enabled == 1 },
"livekit": livekit,
"blossom": if relay.blossom_enabled == 1 {
serde_json::json!({
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": env::get().blossom_s3_endpoint,
"region": env::get().blossom_s3_region,
"bucket": env::get().blossom_s3_bucket,
"access_key": env::get().blossom_s3_access_key,
"secret_key": env::get().blossom_s3_secret_key,
"key_prefix": relay.id,
},
})
} else {
serde_json::json!({ "enabled": false })
},
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": env::get().livekit_url,
"api_key": env::get().livekit_api_key,
"api_secret": env::get().livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
},
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
@@ -188,16 +225,71 @@ fn relay_sync_body(
},
});
if let (Some(secret), Some(body_obj)) = (secret, body.as_object_mut()) {
body_obj.insert("secret".to_string(), serde_json::Value::String(secret));
// Only provide a secret if the relay is new. This allows us to not store the relay secrets on our side.
if is_new && let Some(obj) = body.as_object_mut() {
obj.insert(
"secret".to_string(),
serde_json::Value::String(Keys::generate().secret_key().to_secret_hex()),
);
}
body
let method = if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
};
request(method, &format!("relay/{}", relay.id), Some(&body)).await?;
Ok(())
}
fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
)
/// Fetch the member pubkeys of a relay from the zooid API.
pub async fn list_relay_members(relay_id: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct MembersResponse {
members: Vec<String>,
}
let response = request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None).await?;
let parsed: MembersResponse = response.json().await?;
Ok(parsed.members)
}
/// Sends an authenticated request to the zooid API at `path` (relative to
/// `env.zooid_api_url`). Returns the response on 2xx; bails with the body
/// text otherwise.
async fn request(
method: HttpMethod,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<reqwest::Response> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let base = env::get().zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let url = format!("{base}/{path}");
let auth = env::get().make_auth(&url, method).await?;
let reqwest_method = match method {
HttpMethod::GET => reqwest::Method::GET,
HttpMethod::POST => reqwest::Method::POST,
HttpMethod::PUT => reqwest::Method::PUT,
HttpMethod::PATCH => reqwest::Method::PATCH,
};
let mut req = client
.request(reqwest_method, &url)
.header("Authorization", auth);
if let Some(body) = body {
req = req.json(body);
}
let response = req.send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("zooid {method} {path} returned {status}: {text}");
}
Ok(response)
}
+7 -1
View File
@@ -1,8 +1,14 @@
pub mod api;
pub mod billing;
pub mod bitcoin;
pub mod command;
pub mod db;
pub mod env;
pub mod infra;
pub mod models;
pub mod pool;
pub mod query;
pub mod robot;
pub mod routes;
pub mod stripe;
pub mod wallet;
pub mod web;
+31 -35
View File
@@ -1,23 +1,27 @@
mod api;
mod billing;
mod bitcoin;
mod command;
mod db;
mod env;
mod infra;
mod models;
mod pool;
mod query;
mod robot;
mod routes;
mod stripe;
mod wallet;
mod web;
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
use crate::api::Api;
use crate::billing::Billing;
use crate::command::Command;
use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot;
use crate::stripe::Stripe;
#[tokio::main]
async fn main() -> Result<()> {
@@ -28,47 +32,39 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let pool = pool::create_pool().await?;
env::init();
db::init().await?;
let robot = Robot::new().await?;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone());
let api = Api::new(query, command, billing.clone());
let stripe = Stripe::new();
let billing = Billing::new(robot.clone());
let api = Api::new(billing.clone(), stripe, robot);
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2892);
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.collect();
let cors = if origins.is_empty() {
CorsLayer::permissive()
} else {
let parsed = origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect::<Vec<_>>();
CorsLayer::new().allow_origin(AllowOrigin::list(parsed))
};
let parsed = env::get()
.server_allow_origins
.iter()
.filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
.collect::<Vec<_>>();
let cors = CorsLayer::new()
.allow_origin(AllowOrigin::list(parsed))
.allow_methods(Any)
.allow_headers(Any);
let app = api.router().layer(cors);
tokio::spawn(async move {
infra.start().await;
tokio::spawn(async {
infra::start().await;
});
tokio::spawn(async move {
billing.start().await;
});
let listener = tokio::net::TcpListener::bind(format!("{host}:{port}")).await?;
let url = format!("127.0.0.1:{}", env::get().server_port);
let listener = tokio::net::TcpListener::bind(url).await?;
axum::serve(listener, app).await?;
Ok(())
}
+159 -16
View File
@@ -1,17 +1,26 @@
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
pub const RELAY_STATUS_ACTIVE: &str = "active";
pub const RELAY_STATUS_INACTIVE: &str = "inactive";
pub const RELAY_STATUS_DELINQUENT: &str = "delinquent";
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Activity {
pub id: String,
pub tenant: String,
pub created_at: i64,
pub activity_type: String,
pub resource_type: String,
pub resource_id: String,
/// Per-resource_type snapshot of a resource's state captured on each activity,
/// stored as JSON in `activity.snapshot`. Tagged on `resource_type` so the JSON
/// is self-describing and the variant matches the activity row's column. Add a
/// variant per resource type that needs state preserved on the activity log.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "resource_type", rename_all = "snake_case")]
pub enum Snapshot {
Relay { plan: String, status: String },
}
impl Snapshot {
pub fn resource_type(&self) -> &'static str {
match self {
Self::Relay { .. } => "relay",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -22,28 +31,53 @@ pub struct Plan {
pub members: Option<i64>,
pub blossom: bool,
pub livekit: bool,
pub stripe_price_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
#[derive(Debug, Default, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Tenant {
pub pubkey: String,
pub nwc_url: String,
/// Last NWC auto-payment error, or `None` when the wallet last paid (or has
/// never been tried). Surfaced in the UI to warn the user; it never blocks a
/// retry — the next reconcile attempts payment again regardless. Also cleared
/// when the tenant updates their NWC credentials.
pub nwc_error: Option<String>,
/// Last Stripe auto-payment error, with the same semantics as `nwc_error`.
pub stripe_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
pub stripe_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
/// The tenant's saved Stripe payment method, or `None` if they have not set
/// up a card yet. Set when the tenant adds a card via the Stripe portal.
pub stripe_payment_method_id: Option<String>,
/// `period_start` of the most recent period this tenant was renewed for, or
/// `None` if never renewed. The per-period renewal idempotency marker.
pub renewed_at: Option<i64>,
/// When the tenant was churned because an invoice went unpaid past the grace
/// period; its relays are delinquent while this is set. Cleared when billing
/// is re-activated (the tenant has new billable activity), at which point the
/// then-open invoices are voided rather than collected. `None` in good standing.
pub churned_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Activity {
pub id: String,
pub tenant_pubkey: String,
pub created_at: i64,
pub activity_type: String,
pub resource_type: String,
pub resource_id: String,
pub billed_at: Option<i64>,
pub snapshot: Json<Snapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Relay {
pub id: String,
pub tenant: String,
pub schema: String,
pub tenant_pubkey: String,
pub subdomain: String,
pub plan: String,
pub stripe_subscription_item_id: Option<String>,
pub plan_id: String,
pub status: String,
pub sync_error: String,
pub info_name: String,
@@ -58,3 +92,112 @@ pub struct Relay {
pub push_enabled: i64,
pub synced: i64,
}
impl Default for Relay {
fn default() -> Self {
Self {
id: String::new(),
tenant_pubkey: String::new(),
subdomain: String::new(),
plan_id: String::new(),
status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(),
info_name: String::new(),
info_icon: String::new(),
info_description: String::new(),
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
blossom_enabled: 0,
livekit_enabled: 0,
push_enabled: 1,
synced: 0,
}
}
}
/// A tenant's bill for one period. Its lifecycle is recorded as timestamps
/// rather than a status column: open while both `paid_at` and `voided_at` are
/// null, paid once `paid_at` is set, and void once `voided_at` is set (e.g. a
/// balance forgiven when the tenant churns).
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Invoice {
pub id: String,
pub tenant_pubkey: String,
/// The total owed, fixed when the invoice is cut from its outstanding line
/// items, so collection never has to re-sum them.
pub amount: i64,
pub period_start: i64,
pub period_end: i64,
pub created_at: i64,
pub paid_at: Option<i64>,
pub voided_at: Option<i64>,
/// When the manual-payment reminder DM was sent for this invoice, or `None` if
/// it hasn't been sent in order to avoid duplicate reminders for the same invoice.
pub notified_at: Option<i64>,
/// How the invoice was paid — `nwc`, `stripe`, or `oob` (out-of-band
/// Lightning) — set when it is marked paid; `None` while open or void.
pub method: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct InvoiceItem {
pub id: String,
/// `None` while outstanding; set once the item is claimed onto an invoice.
pub invoice_id: Option<String>,
/// `None` for renewal items, which have no source activity.
pub activity_id: Option<String>,
pub tenant_pubkey: String,
pub relay_id: String,
pub plan_id: String,
pub amount: i64,
pub description: String,
pub created_at: i64,
/// Set when the item is forgiven — the tenant churned or reactivated — so it
/// is never billed or carried into a later invoice; `None` while live. Applies
/// whether or not the item has been claimed onto an invoice.
pub voided_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Bolt11 {
pub id: String,
pub invoice_id: String,
pub lnbc: String,
pub msats: i64,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
/// A hosted Stripe Checkout session opened to pay an invoice on-session (so a 3D
/// Secure challenge can be cleared), shaped like [`Bolt11`]: created pending and
/// stamped `settled_at` once paid. `id` is our uuid; `session_id` is the Stripe
/// Checkout Session (`cs_…`), used to reconcile and expire it; `url` is the
/// hosted page we redirect the tenant to.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Checkout {
pub id: String,
pub invoice_id: String,
pub session_id: String,
pub url: String,
pub created_at: i64,
pub expires_at: i64,
pub settled_at: Option<i64>,
}
/// A write-ahead record of an off-session card charge: inserted before the Stripe
/// call and stamped `settled_at` once the charge confirms and the invoice is paid.
/// `payment_method_id` is the method it charges, so a retry after a lost settle
/// re-confirms the same (idempotent) PaymentIntent rather than charging a second
/// one; `payment_intent_id` is the Stripe `pi_…` that settled it.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Intent {
pub id: String,
pub invoice_id: String,
pub payment_method_id: String,
pub payment_intent_id: Option<String>,
pub created_at: i64,
pub settled_at: Option<i64>,
}
-50
View File
@@ -1,50 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn create_pool() -> Result<SqlitePool> {
let raw_database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
let database_url = normalize_sqlite_url(&raw_database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+274 -156
View File
@@ -1,170 +1,288 @@
use anyhow::Result;
use sqlx::SqlitePool;
use anyhow::{Result, anyhow};
use crate::models::{Activity, Plan, Relay, Tenant};
use crate::db::pool;
use crate::models::{Activity, Bolt11, Checkout, Invoice, InvoiceItem, Plan, Relay, Tenant};
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
}
impl Query {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn select_relay(tail: &str) -> String {
format!("SELECT * FROM relay {tail}")
}
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
ORDER BY pubkey",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}")
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
WHERE pubkey = ?",
)
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// --- Plans ---
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
stripe_price_id: None,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
stripe_price_id: Some(std::env::var("STRIPE_PRICE_BASIC").unwrap_or_default()),
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
stripe_price_id: Some(std::env::var("STRIPE_PRICE_GROWTH").unwrap_or_default()),
},
]
}
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
},
]
}
pub fn get_plan(plan_id: &str) -> Option<Plan> {
Self::list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn get_plan(plan_id: &str) -> Result<Plan> {
list_plans()
.into_iter()
.find(|p| p.id == plan_id)
.ok_or_else(|| anyhow!("plan not found: {plan_id}"))
}
pub fn is_paid_plan(plan_id: &str) -> bool {
Self::get_plan(plan_id)
.map(|p| p.id != "free")
.unwrap_or(false)
}
// --- Tenants ---
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
ORDER BY id",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE tenant = ?
ORDER BY id",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
Ok(
sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.fetch_optional(pool())
.await?,
)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE id = ?",
)
// --- Relays ---
pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
.fetch_all(pool())
.await?,
)
}
pub async fn list_relays_for_tenant(tenant_pubkey: &str) -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant_pubkey = ?"))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?,
)
}
pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn get_tenant_by_stripe_customer_id(
&self,
stripe_customer_id: &str,
) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at
FROM tenant
WHERE stripe_customer_id = ?",
)
.bind(stripe_customer_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let plans = sqlx::query_scalar::<_, String>(
"SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE resource_type = 'relay' AND resource_id = ?
ORDER BY created_at DESC, id DESC",
)
.bind(relay_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
.fetch_optional(pool())
.await?)
}
/// The relay's plan immediately before `before`, read from the most recent
/// relay-activity snapshot with `created_at < before`. Billing uses this as
/// the `old` side of a plan-change delta.
pub async fn get_relay_plan_before(relay_id: &str, before: i64) -> Result<Option<String>> {
Ok(sqlx::query_scalar::<_, String>(
"SELECT json_extract(snapshot, '$.plan') FROM activity
WHERE resource_id = ?
AND resource_type = 'relay'
AND created_at < ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(relay_id)
.bind(before)
.fetch_optional(pool())
.await?)
}
// --- Invoices ---
pub async fn get_invoice(invoice_id: &str) -> Result<Option<Invoice>> {
Ok(
sqlx::query_as::<_, Invoice>("SELECT * FROM invoice WHERE id = ?")
.bind(invoice_id)
.fetch_optional(pool())
.await?,
)
}
pub async fn list_invoices() -> Result<Vec<Invoice>> {
Ok(
sqlx::query_as::<_, Invoice>("SELECT * FROM invoice ORDER BY created_at DESC")
.fetch_all(pool())
.await?,
)
}
pub async fn list_invoices_for_tenant(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice WHERE tenant_pubkey = ? ORDER BY created_at DESC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// The line items claimed onto an invoice, oldest first. Used to render an
/// invoice's contents (and its downloadable copy) from what was actually billed.
pub async fn list_invoice_items_for_invoice(invoice_id: &str) -> Result<Vec<InvoiceItem>> {
Ok(sqlx::query_as::<_, InvoiceItem>(
"SELECT * FROM invoice_item WHERE invoice_id = ? ORDER BY created_at ASC",
)
.bind(invoice_id)
.fetch_all(pool())
.await?)
}
/// A tenant's outstanding line items — created but not yet claimed onto an
/// invoice — oldest first. These are exactly what `create_invoice` would bill,
/// and what a draft invoice presents before the balance is cut.
pub async fn list_unbilled_invoice_items(tenant_pubkey: &str) -> Result<Vec<InvoiceItem>> {
Ok(sqlx::query_as::<_, InvoiceItem>(
"SELECT * FROM invoice_item
WHERE tenant_pubkey = ? AND invoice_id IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// A tenant's open invoices — neither paid nor voided — oldest first. Dunning
/// retries each and treats the oldest one's `created_at` as the grace-period start.
pub async fn list_open_invoices(tenant_pubkey: &str) -> Result<Vec<Invoice>> {
Ok(sqlx::query_as::<_, Invoice>(
"SELECT * FROM invoice
WHERE tenant_pubkey = ? AND paid_at IS NULL AND voided_at IS NULL
ORDER BY created_at ASC",
)
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
// --- Bolt11 ---
pub async fn get_bolt11_for_invoice(invoice_id: &str) -> Result<Option<Bolt11>> {
Ok(sqlx::query_as::<_, Bolt11>(
"SELECT * FROM bolt11 WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
// --- Checkouts ---
/// The most recent Checkout session for an invoice, regardless of `settled_at`,
/// so a session can still be expired on Stripe after we've locally marked it
/// settled. Mirrors [`get_bolt11_for_invoice`]; callers gate on `settled_at`.
pub async fn get_checkout_for_invoice(invoice_id: &str) -> Result<Option<Checkout>> {
Ok(sqlx::query_as::<_, Checkout>(
"SELECT * FROM checkout WHERE invoice_id = ? ORDER BY created_at DESC LIMIT 1",
)
.bind(invoice_id)
.fetch_optional(pool())
.await?)
}
/// Every still-pending (unsettled) Checkout session for an invoice — the ones to
/// expire on Stripe once the invoice has been paid another way.
pub async fn list_pending_checkouts_for_invoice(invoice_id: &str) -> Result<Vec<Checkout>> {
Ok(sqlx::query_as::<_, Checkout>(
"SELECT * FROM checkout WHERE invoice_id = ? AND settled_at IS NULL ORDER BY created_at DESC",
)
.bind(invoice_id)
.fetch_all(pool())
.await?)
}
// --- Activity ---
/// Billable activity for a tenant not yet folded into an invoice. The
/// activity-type filter and the `billed_at IS NULL` guard live here so the
/// caller reconciles off a precise marker rather than a timestamp watermark.
/// Ordered oldest-first so line items and proration apply in event order.
pub async fn list_billable_activity(tenant_pubkey: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE tenant_pubkey = ?
AND billed_at IS NULL
AND activity_type IN (
'create_relay', 'update_relay', 'activate_relay', 'deactivate_relay',
'unmark_relay_delinquent'
)
ORDER BY created_at ASC",
))
.bind(tenant_pubkey)
.fetch_all(pool())
.await?)
}
/// The relay's most recent activity strictly before `before`, or `None` if it
/// had no activity yet — i.e. the relay didn't exist at that point. Billing
/// reads its snapshot to recover the relay's state as of a period boundary.
/// Strict `<` so a relay created exactly at the boundary isn't counted active
/// there (its own creation charge covers that period).
pub async fn get_latest_relay_activity_before(
relay_id: &str,
before: i64,
) -> Result<Option<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ?
AND resource_type = 'relay'
AND created_at < ?
ORDER BY created_at DESC
LIMIT 1",
))
.bind(relay_id)
.bind(before)
.fetch_optional(pool())
.await?)
}
pub async fn list_activity_for_resource(resource_id: &str) -> Result<Vec<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? ORDER BY created_at DESC",
))
.bind(resource_id)
.fetch_all(pool())
.await?)
}
pub async fn get_latest_activity_for_resource_and_type(
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
Ok(sqlx::query_as::<_, Activity>(&select_activity(
"WHERE resource_id = ? AND activity_type = ? ORDER BY created_at DESC LIMIT 1",
))
.bind(resource_id)
.bind(activity_type)
.fetch_optional(pool())
.await?)
}
+61 -126
View File
@@ -5,15 +5,13 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::env;
/// The service's Nostr identity: it publishes the robot's profile and relay
/// lists and sends encrypted direct messages to tenants, caching recipients'
/// relay lists between sends.
#[derive(Clone)]
pub struct Robot {
secret: String,
name: String,
description: String,
picture: String,
outbox_client: Client,
indexer_client: Client,
messaging_client: Client,
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
}
@@ -25,90 +23,67 @@ struct CacheEntry {
}
impl Robot {
/// Build the robot and publish its Nostr identity (profile and relay lists).
pub async fn new() -> Result<Self> {
let secret = std::env::var("ROBOT_SECRET").unwrap_or_default();
if secret.trim().is_empty() {
return Err(anyhow!("ROBOT_SECRET is required"));
}
let name = std::env::var("ROBOT_NAME").unwrap_or_default();
let description = std::env::var("ROBOT_DESCRIPTION").unwrap_or_default();
let picture = std::env::var("ROBOT_PICTURE").unwrap_or_default();
let outbox_relays = split_relays("ROBOT_OUTBOX_RELAYS");
let indexer_relays = split_relays("ROBOT_INDEXER_RELAYS");
let messaging_relays = split_relays("ROBOT_MESSAGING_RELAYS");
if outbox_relays.is_empty() {
return Err(anyhow!("ROBOT_OUTBOX_RELAYS is required"));
}
if indexer_relays.is_empty() {
return Err(anyhow!("ROBOT_INDEXER_RELAYS is required"));
}
if messaging_relays.is_empty() {
return Err(anyhow!("ROBOT_MESSAGING_RELAYS is required"));
}
let outbox_client = client_with_relays(&secret, &outbox_relays).await?;
let indexer_client = client_with_relays(&secret, &indexer_relays).await?;
let messaging_client = client_with_relays(&secret, &messaging_relays).await?;
let robot = Self {
secret,
name,
description,
picture,
outbox_client,
indexer_client,
messaging_client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
};
robot
.publish_identity(&outbox_relays, &messaging_relays)
.await?;
robot.publish_identity().await?;
Ok(robot)
}
async fn publish_identity(
&self,
outbox_relays: &[String],
messaging_relays: &[String],
) -> Result<()> {
async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(env::get().keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn publish_identity(&self) -> Result<()> {
let mut metadata = Metadata::new();
if !self.name.is_empty() {
metadata = metadata.name(&self.name);
if !env::get().robot_name.is_empty() {
metadata = metadata.name(&env::get().robot_name);
}
if !self.description.is_empty() {
metadata = metadata.about(&self.description);
if !env::get().robot_description.is_empty() {
metadata = metadata.about(&env::get().robot_description);
}
if !self.picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.picture)?);
if !env::get().robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
}
self.outbox_client
let outbox_client = self.make_client(&env::get().robot_outbox_relays).await?;
let indexer_client = self.make_client(&env::get().robot_indexer_relays).await?;
outbox_client
.send_event_builder(EventBuilder::metadata(&metadata))
.await?;
let outbox_tags = outbox_relays
let outbox_tags = env::get()
.robot_outbox_relays
.iter()
.map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.outbox_client
outbox_client
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = messaging_relays
let messaging_tags = env::get()
.robot_messaging_relays
.iter()
.map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
self.indexer_client
indexer_client
.send_event_builder(EventBuilder::new(Kind::Custom(10050), "").tags(messaging_tags))
.await?;
Ok(())
}
/// Send an encrypted direct message to a recipient over their messaging relays.
pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()> {
let outbox = self.fetch_outbox_relays(recipient).await?;
if outbox.is_empty() {
@@ -123,11 +98,7 @@ impl Robot {
}
let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.messaging_client.clone();
for relay in dm_relays {
let _ = client.add_relay(relay).await;
}
client.connect().await;
let client = self.make_client(&dm_relays).await?;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
@@ -141,10 +112,8 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let events = self
.indexer_client
.fetch_events(filter, Duration::from_secs(5))
.await?;
let client = self.make_client(&env::get().robot_indexer_relays).await?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
let mut relays = Vec::new();
if let Some(event) = events.into_iter().max_by_key(|e| e.created_at) {
@@ -160,6 +129,29 @@ impl Robot {
Ok(relays)
}
/// The recipient's display name from their Nostr profile, if they have one.
pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String> {
let pubkey = PublicKey::parse(pubkey).ok()?;
let filter = Filter::new().author(pubkey).kind(Kind::Metadata).limit(1);
let client = self
.make_client(&env::get().robot_indexer_relays)
.await
.ok()?;
let events = client
.fetch_events(filter, Duration::from_secs(5))
.await
.ok()?;
let event = events.into_iter().max_by_key(|e| e.created_at)?;
let content: serde_json::Value = serde_json::from_str(&event.content).ok()?;
let name = content
.get("display_name")
.or_else(|| content.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
Some(name)
}
async fn fetch_messaging_relays_from_outbox(
&self,
recipient: &str,
@@ -170,13 +162,7 @@ impl Robot {
}
let pubkey = PublicKey::parse(recipient)?;
let keys = Keys::parse(&self.secret)?;
let client = Client::new(keys);
for relay in outbox_relays {
client.add_relay(relay).await?;
}
client.connect().await;
let client = self.make_client(outbox_relays).await?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10050));
let events = client.fetch_events(filter, Duration::from_secs(5)).await?;
@@ -195,37 +181,6 @@ impl Robot {
}
}
fn split_relays(key: &str) -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|v| normalize_relay_url(v.trim()))
.filter(|v| !v.is_empty())
.collect()
}
fn normalize_relay_url(url: &str) -> String {
if url.is_empty() {
return String::new();
}
if url.starts_with("ws://") || url.starts_with("wss://") {
url.to_string()
} else {
format!("wss://{url}")
}
}
async fn client_with_relays(secret: &str, relays: &[String]) -> Result<Client> {
let keys = Keys::parse(secret)?;
let client = Client::new(keys);
for relay in relays {
client.add_relay(relay).await?;
}
client.connect().await;
Ok(client)
}
async fn get_cached(
cache: &std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
key: &str,
@@ -254,23 +209,3 @@ async fn set_cached(
},
);
}
#[cfg(test)]
impl Robot {
pub fn test_stub() -> Self {
let keys = Keys::generate();
let client = Client::new(keys);
Self {
secret: String::new(),
name: String::new(),
description: String::new(),
picture: String::new(),
outbox_client: client.clone(),
indexer_client: client.clone(),
messaging_client: client,
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
}
}
}
+21
View File
@@ -0,0 +1,21 @@
use std::sync::Arc;
use axum::extract::State;
use serde::Serialize;
use crate::api::{Api, AuthedPubkey};
use crate::web::{ApiResult, ok};
#[derive(Serialize)]
struct IdentityResponse {
pubkey: String,
is_admin: bool,
}
pub async fn get_identity(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult {
let is_admin = api.is_admin(&pubkey);
ok(IdentityResponse { pubkey, is_admin })
}
+140
View File
@@ -0,0 +1,140 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::{Api, AuthedPubkey};
use crate::query;
use crate::web::{ApiResult, internal, not_found, ok};
pub async fn list_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
ok(query::list_invoices().await.map_err(internal)?)
}
/// Read a single invoice
pub async fn get_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
ok(invoice)
}
/// Reconcile and collect an open invoice
pub async fn reconcile_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
// Nothing to collect on an already-resolved invoice.
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
return ok(invoice);
}
let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?;
api.billing
.ensure_bolt11_for_invoice(&invoice)
.await
.map_err(internal)?;
api.billing
.reconcile_payments(&tenant, &invoice, true, false)
.await
.map_err(internal)?;
// Re-read so the caller sees the possibly now-paid invoice.
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
ok(invoice)
}
/// Idempotently create a payable Lightning invoice (bolt11)
pub async fn ensure_invoice_bolt11(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(invoice_id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let bolt11 = api
.billing
.ensure_bolt11_for_invoice(&invoice)
.await
.map_err(internal)?;
ok(bolt11)
}
/// Open a hosted Stripe Checkout session to pay a single open invoice by card,
/// returning the URL to redirect the tenant to. Unlike the off-session card
/// charge, Checkout can satisfy a 3D Secure authentication challenge; the
/// resulting payment is reconciled by `reconcile_invoice` (or the dunning poll).
pub async fn ensure_invoice_checkout(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let tenant = api.get_tenant_or_404(&invoice.tenant_pubkey).await?;
let checkout = api
.billing
.ensure_checkout_for_invoice(&tenant, &invoice)
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": checkout.url }))
}
/// The line items billed on an invoice
pub async fn list_invoice_items(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(invoice_id): Path<String>,
) -> ApiResult {
let invoice = query::get_invoice(&invoice_id)
.await
.map_err(internal)?
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let items = query::list_invoice_items_for_invoice(&invoice_id)
.await
.map_err(internal)?;
ok(items)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod tenants;
+16
View File
@@ -0,0 +1,16 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use crate::api::Api;
use crate::query;
use crate::web::{ApiResult, not_found, ok};
pub async fn list_plans(State(_api): State<Arc<Api>>) -> ApiResult {
ok(query::list_plans())
}
pub async fn get_plan(State(_api): State<Arc<Api>>, Path(id): Path<String>) -> ApiResult {
let plan = query::get_plan(&id).map_err(|_| not_found("plan not found"))?;
ok(plan)
}
+316
View File
@@ -0,0 +1,316 @@
use std::sync::{Arc, LazyLock};
use anyhow::Result;
use axum::{
Json,
extract::{Path, State},
};
use regex::Regex;
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::web::{
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
unprocessable,
};
use crate::{command, infra, query};
pub async fn list_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let relays = query::list_relays().await.map_err(internal)?;
ok(relays)
}
pub async fn get_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
ok(relay)
}
pub async fn list_relay_activity(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let activity = query::list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
}
pub async fn list_relay_members(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let members = fetch_relay_members(&relay).await.map_err(internal)?;
ok(serde_json::json!({ "members": members }))
}
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant_pubkey: String,
pub subdomain: String,
pub plan_id: String,
pub info_name: String,
pub info_icon: String,
pub info_description: String,
pub policy_public_join: i64,
pub policy_strip_signatures: i64,
pub groups_enabled: i64,
pub management_enabled: i64,
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
}
pub async fn create_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Json(payload): Json<CreateRelayRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &payload.tenant_pubkey)?;
let relay_id = format!(
"{}_{}",
payload.subdomain.replace('-', "_"),
&uuid::Uuid::new_v4().simple().to_string()[..8]
);
let relay = Relay {
id: relay_id.clone(),
tenant_pubkey: payload.tenant_pubkey,
subdomain: payload.subdomain,
plan_id: payload.plan_id,
info_name: payload.info_name,
info_icon: payload.info_icon,
info_description: payload.info_description,
policy_public_join: payload.policy_public_join,
policy_strip_signatures: payload.policy_strip_signatures,
groups_enabled: payload.groups_enabled,
management_enabled: payload.management_enabled,
blossom_enabled: payload.blossom_enabled,
livekit_enabled: payload.livekit_enabled,
push_enabled: payload.push_enabled,
..Default::default()
};
let relay = prepare_relay(relay)?;
command::create_relay(&relay)
.await
.map_err(map_relay_write_error)?;
created(relay)
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan_id: Option<String>,
pub info_name: Option<String>,
pub info_icon: Option<String>,
pub info_description: Option<String>,
pub policy_public_join: Option<i64>,
pub policy_strip_signatures: Option<i64>,
pub groups_enabled: Option<i64>,
pub management_enabled: Option<i64>,
pub blossom_enabled: Option<i64>,
pub livekit_enabled: Option<i64>,
pub push_enabled: Option<i64>,
}
pub async fn update_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let current_plan = relay.plan_id.clone();
let requested_plan = payload.plan_id.clone();
if let Some(v) = payload.subdomain {
relay.subdomain = v;
}
if let Some(v) = requested_plan.clone() {
relay.plan_id = v;
}
if let Some(v) = payload.info_name {
relay.info_name = v;
}
if let Some(v) = payload.info_icon {
relay.info_icon = v;
}
if let Some(v) = payload.info_description {
relay.info_description = v;
}
if let Some(v) = payload.policy_public_join {
relay.policy_public_join = v;
}
if let Some(v) = payload.policy_strip_signatures {
relay.policy_strip_signatures = v;
}
if let Some(v) = payload.groups_enabled {
relay.groups_enabled = v;
}
if let Some(v) = payload.management_enabled {
relay.management_enabled = v;
}
if let Some(v) = payload.blossom_enabled {
relay.blossom_enabled = v;
}
if let Some(v) = payload.livekit_enabled {
relay.livekit_enabled = v;
}
if let Some(v) = payload.push_enabled {
relay.push_enabled = v;
}
let relay = prepare_relay(relay)?;
let plan_changed = requested_plan
.as_deref()
.is_some_and(|requested| requested != current_plan);
if plan_changed {
let selected_plan = query::get_plan(&relay.plan_id).map_err(internal)?;
if let Some(limit) = selected_plan.members {
let current_members = fetch_relay_members(&relay).await.map_err(internal)?.len() as i64;
if current_members > limit {
let message = format!(
"relay has {current_members} members, which exceeds the {} plan limit of {limit}",
selected_plan.name.to_lowercase()
);
return Err(unprocessable("member-limit-exceeded", &message));
}
}
}
command::update_relay(&relay)
.await
.map_err(map_relay_write_error)?;
ok(relay)
}
pub async fn deactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_INACTIVE {
return Err(bad_request(
"relay-is-inactive",
"relay is already inactive",
));
}
command::deactivate_relay(&relay).await.map_err(internal)?;
ok(())
}
pub async fn reactivate_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
if relay.status == RELAY_STATUS_DELINQUENT {
return Err(bad_request("relay-is-delinquent", "relay is delinquent"));
}
if relay.status == RELAY_STATUS_ACTIVE {
return Err(bad_request("relay-is-active", "relay is already active"));
}
command::activate_relay(&relay).await.map_err(internal)?;
ok(())
}
// --- helpers ----------------------------------------------------------------
async fn fetch_relay_members(relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
infra::list_relay_members(&relay.id).await
}
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
static SUBDOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$").unwrap());
/// Validate and normalize a relay before persistence: enforce the subdomain
/// format and reserved names, require an existing plan that permits any enabled
/// premium features, and coerce the boolean columns to 0/1.
fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
if !SUBDOMAIN_RE.is_match(&relay.subdomain)
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str())
{
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = query::get_plan(&relay.plan_id)
.map_err(|_| unprocessable("invalid-plan", "plan not found"))?;
if (!plan.blossom && relay.blossom_enabled == 1)
|| (!plan.livekit && relay.livekit_enabled == 1)
{
return Err(unprocessable(
"premium-feature",
"feature requires a paid plan",
));
}
relay.policy_public_join = parse_bool_default(relay.policy_public_join, 0);
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
relay.blossom_enabled = parse_bool_default(relay.blossom_enabled, 0);
relay.livekit_enabled = parse_bool_default(relay.livekit_enabled, 0);
relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay)
}
/// Translate a duplicate-subdomain write into a 422; anything else is a 500.
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
unprocessable("subdomain-exists", "subdomain already exists")
} else {
internal(e)
}
}
+280
View File
@@ -0,0 +1,280 @@
use std::sync::Arc;
use axum::{
Json,
extract::{Path, Query, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::billing::BillingPeriod;
use crate::models::{Invoice, Tenant};
use crate::web::{ApiResult, internal, map_unique_error, ok};
use crate::{command, env, query};
#[derive(Serialize)]
pub struct TenantResponse {
pub pubkey: String,
pub nwc_is_set: bool,
pub nwc_error: Option<String>,
pub stripe_error: Option<String>,
pub created_at: i64,
pub billing_anchor: Option<i64>,
pub stripe_customer_id: String,
pub stripe_payment_method_id: Option<String>,
/// Set when billing has churned the tenant; the UI uses it to warn that the
/// account is delinquent until billing is re-activated.
pub churned_at: Option<i64>,
}
impl From<Tenant> for TenantResponse {
fn from(t: Tenant) -> Self {
TenantResponse {
nwc_is_set: !t.nwc_url.is_empty(),
pubkey: t.pubkey,
nwc_error: t.nwc_error,
stripe_error: t.stripe_error,
created_at: t.created_at,
billing_anchor: t.billing_anchor,
stripe_customer_id: t.stripe_customer_id,
stripe_payment_method_id: t.stripe_payment_method_id,
churned_at: t.churned_at,
}
}
}
pub async fn list_tenants(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let tenants = query::list_tenants().await.map_err(internal)?;
ok(tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>())
}
/// Fetch a tenant by pubkey.
pub async fn get_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
/// Create the tenant row for the calling pubkey and provision its Stripe
/// customer. Idempotent: an existing tenant (including one created by a
/// concurrent unique-constraint race) is returned as-is.
pub async fn create_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(pubkey): AuthedPubkey,
) -> ApiResult {
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
return ok(TenantResponse::from(t));
}
let display_name = api
.robot
.fetch_nostr_name(&pubkey)
.await
.unwrap_or(pubkey.chars().take(8).collect());
let stripe_customer_id = api
.stripe
.create_customer(&pubkey, &display_name)
.await
.map_err(internal)?;
let tenant = Tenant {
pubkey: pubkey.clone(),
created_at: Utc::now().timestamp(),
stripe_customer_id,
..Default::default()
};
match command::create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match query::get_tenant(&pubkey).await {
Ok(Some(t)) => ok(TenantResponse::from(t)),
Ok(None) => Err(internal("tenant row missing after unique-constraint race")),
Err(e) => Err(internal(e)),
}
}
Err(e) => Err(internal(e)),
}
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn update_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Json(payload): Json<UpdateTenantRequest>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = api.get_tenant_or_404(&pubkey).await?;
// Encrypt tenant's nwc_url at rest
if let Some(nwc_url) = payload.nwc_url {
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
}
}
command::update_tenant(&tenant).await.map_err(internal)?;
ok(TenantResponse::from(tenant))
}
/// List a tenant's relays.
pub async fn list_tenant_relays(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = query::list_relays_for_tenant(&pubkey)
.await
.map_err(internal)?;
ok(relays)
}
/// List a tenant's invoices.
pub async fn list_tenant_invoices(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let invoices = query::list_invoices_for_tenant(&pubkey)
.await
.map_err(internal)?;
ok(invoices)
}
/// Reconcile a tenant's subscription
pub async fn reconcile_tenant(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
api.billing
.sync_stripe_customer(&tenant)
.await
.map_err(internal)?;
api.billing
.reconcile_subscription(&tenant, false)
.await
.map_err(internal)?;
// Re-read so the response reflects the synced method and any billing anchor.
let tenant = api.get_tenant_or_404(&pubkey).await?;
ok(TenantResponse::from(tenant))
}
/// Return the tenant's draft invoice: a synthetic, unsaved `Invoice` summing the
/// outstanding line items for the current period. It mirrors what `create_invoice`
/// would bill once the balance turns positive.
pub async fn get_draft_invoice(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let draft = match BillingPeriod::current(&tenant) {
Some(period) => {
let items = query::list_unbilled_invoice_items(&pubkey)
.await
.map_err(internal)?;
if items.is_empty() {
None
} else {
Some(Invoice {
id: "draft".to_string(),
amount: items.iter().map(|item| item.amount).sum(),
tenant_pubkey: tenant.pubkey,
period_start: period.start,
period_end: period.end,
created_at: Utc::now().timestamp(),
paid_at: None,
voided_at: None,
notified_at: None,
method: None,
})
}
}
None => None,
};
ok(draft)
}
/// The outstanding line items behind a tenant's draft invoice — the current
/// period's not-yet-billed charges. Mirrors `list_invoice_items` for a real
/// invoice (the draft's sentinel id can't be looked up there) so the UI can
/// itemize the draft in the same PDF.
pub async fn list_draft_invoice_items(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let items = query::list_unbilled_invoice_items(&pubkey)
.await
.map_err(internal)?;
ok(items)
}
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
/// Create a Stripe billing-portal session for the tenant to manage their saved
/// payment methods, returning the portal URL.
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
Query(params): Query<StripeSessionParams>,
) -> ApiResult {
api.require_tenant(&auth, &pubkey)?;
let tenant = api.get_tenant_or_404(&pubkey).await?;
let url = api
.stripe
.create_portal_session(&tenant.stripe_customer_id, params.return_url.as_deref())
.await
.map_err(internal)?;
ok(serde_json::json!({ "url": url }))
}
+299
View File
@@ -0,0 +1,299 @@
//! A thin async wrapper around the subset of the Stripe REST API this service uses.
//!
//! Nothing here knows about relays, tenants, or our database — it just speaks HTTP
//! to Stripe and hands back `serde_json::Value` (or small typed results). The
//! domain logic lives in [`crate::billing`].
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::env;
const STRIPE_API: &str = "https://api.stripe.com/v1";
#[derive(Clone)]
pub struct Stripe {
http: reqwest::Client,
}
impl Default for Stripe {
fn default() -> Self {
Self::new()
}
}
impl Stripe {
pub fn new() -> Self {
Self {
http: reqwest::Client::new(),
}
}
// --- Request helpers ---
fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.get(format!("{STRIPE_API}{path}"))
.bearer_auth(&env::get().stripe_secret_key)
}
fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.post(format!("{STRIPE_API}{path}"))
.bearer_auth(&env::get().stripe_secret_key)
}
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(env::get().stripe_secret_key.as_bytes())
.expect("HMAC accepts any key length");
for (i, part) in parts.iter().enumerate() {
if i > 0 {
mac.update(b":");
}
mac.update(part.as_bytes());
}
hex::encode(mac.finalize().into_bytes())
}
// --- Customers ---
/// Create a Stripe customer for a tenant and return its id. Idempotent on
/// `tenant_pubkey` so retrying a tenant's creation reuses the same customer.
pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String> {
let body = self
.post("/customers")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_customer", tenant_pubkey]),
)
.form(&[("name", name), ("metadata[tenant_pubkey]", tenant_pubkey)])
.send_json()
.await?;
let customer_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing customer id"))?;
Ok(customer_id.to_string())
}
// --- Payment methods ---
/// Return the id of the customer's first saved payment method, or `None` if
/// they have none. The returned `pm_…` id can be charged off-session via
/// [`Self::create_payment_intent`]. We don't track a Stripe "default" payment
/// method, so the first one Stripe lists is the one we'll charge.
pub async fn get_saved_payment_method(&self, customer_id: &str) -> Result<Option<String>> {
let body = self
.get("/payment_methods")
.query(&[("customer", customer_id), ("type", "card")])
.send_json()
.await?;
Ok(body["data"]
.as_array()
.and_then(|methods| methods.first())
.and_then(|method| method["id"].as_str())
.map(str::to_string))
}
// --- Intents ---
/// Create and immediately confirm an off-session PaymentIntent charging a
/// saved payment method. `amount` is in the currency's minor units (cents for
/// `usd`). Returns the PaymentIntent id on success.
///
/// A decline or an issuer authentication demand (`authentication_required`,
/// which we can't satisfy off-session) comes back from Stripe as an HTTP
/// error, so the caller naturally falls through to another payment method.
/// The charge is made idempotent on `invoice_id` and `payment_method_id`,
/// so a retried collection against the same method reuses the same charge
/// instead of billing twice, while a fall-back to a different method issues
/// a distinct charge instead of colliding on the original key.
pub async fn create_payment_intent(
&self,
customer_id: &str,
payment_method_id: &str,
invoice_id: &str,
amount: i64,
currency: &str,
) -> Result<String> {
let amount = amount.to_string();
let body = self
.post("/payment_intents")
.header(
"Idempotency-Key",
self.idempotency_key(&["payment_intent", invoice_id, payment_method_id]),
)
.form(&[
("amount", amount.as_str()),
("currency", currency),
("customer", customer_id),
("payment_method", payment_method_id),
("metadata[invoice_id]", invoice_id),
("off_session", "true"),
("confirm", "true"),
])
.send_json()
.await?;
// A successful off-session charge settles synchronously. Anything
// else (e.g. `requires_action`) can't be completed without the customer,
// so treat it as a failure and let the caller fall back.
let status = body["status"].as_str().unwrap_or_default();
if status != "succeeded" {
return Err(anyhow!("payment intent not succeeded (status: {status})"));
}
body["id"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing payment intent id"))
}
// --- Checkout ---
/// Open a hosted Stripe Checkout session that charges `amount` (in the
/// currency's minor units) for a single invoice on-session, so the customer
/// can satisfy a 3D Secure authentication that an off-session saved-card
/// charge can't. Returns the session id, its hosted URL, and its expiry. The
/// session and the PaymentIntent it creates both carry `invoice_id` in
/// metadata so the charge is traceable back to our ledger.
pub async fn create_checkout_session(
&self,
customer_id: &str,
invoice_id: &str,
amount: i64,
currency: &str,
success_url: &str,
cancel_url: &str,
) -> Result<(String, String, i64)> {
let amount = amount.to_string();
let body = self
.post("/checkout/sessions")
.form(&[
("mode", "payment"),
("customer", customer_id),
("success_url", success_url),
("cancel_url", cancel_url),
("line_items[0][quantity]", "1"),
("line_items[0][price_data][currency]", currency),
("line_items[0][price_data][unit_amount]", amount.as_str()),
(
"line_items[0][price_data][product_data][name]",
"Relay subscription",
),
("payment_intent_data[metadata][invoice_id]", invoice_id),
("metadata[invoice_id]", invoice_id),
])
.send_json()
.await?;
let session_id = body["id"]
.as_str()
.ok_or_else(|| anyhow!("missing checkout session id"))?;
let url = body["url"]
.as_str()
.ok_or_else(|| anyhow!("missing checkout session url"))?;
let expires_at = body["expires_at"]
.as_i64()
.ok_or_else(|| anyhow!("missing checkout session expiry"))?;
Ok((session_id.to_string(), url.to_string(), expires_at))
}
/// Whether a Checkout session has been paid. Used to reconcile an invoice
/// once the customer returns from (or later completes) the hosted page.
pub async fn is_checkout_paid(&self, session_id: &str) -> Result<bool> {
let body = self
.get(&format!("/checkout/sessions/{session_id}"))
.send_json()
.await?;
Ok(body["payment_status"].as_str() == Some("paid"))
}
/// Expire a Checkout session so it can no longer be completed. Used to close
/// out a still-open session once its invoice has been paid another way,
/// preventing a double charge. Errors if the session isn't open (already
/// completed or expired), which the caller treats as best-effort.
pub async fn expire_checkout_session(&self, session_id: &str) -> Result<()> {
self.post(&format!("/checkout/sessions/{session_id}/expire"))
.send_ok()
.await?;
Ok(())
}
// --- Portal ---
/// Open a Stripe billing-portal session for the customer, returning the URL
/// where they can manage their saved payment methods.
pub async fn create_portal_session(
&self,
customer_id: &str,
return_url: Option<&str>,
) -> Result<String> {
let mut params = vec![("customer", customer_id.to_string())];
if let Some(url) = return_url {
params.push(("return_url", url.to_string()));
}
let body = self
.post("/billing_portal/sessions")
.form(&params)
.send_json()
.await?;
body["url"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url"))
}
}
// Stripe request util
trait StripeRequest {
async fn send_ok(self) -> Result<reqwest::Response>;
async fn send_json(self) -> Result<serde_json::Value>;
}
impl StripeRequest for reqwest::RequestBuilder {
async fn send_ok(self) -> Result<reqwest::Response> {
error_for_status(self.send().await?).await
}
async fn send_json(self) -> Result<serde_json::Value> {
Ok(self.send_ok().await?.json().await?)
}
}
/// Give callers an actionable message instead of a bare "400 Bad Request"
async fn error_for_status(resp: reqwest::Response) -> Result<reqwest::Response> {
let status = resp.status();
if !status.is_client_error() && !status.is_server_error() {
return Ok(resp);
}
let url = resp.url().clone();
let body = resp.text().await.unwrap_or_default();
let detail = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|json| {
let error = &json["error"];
let message = error["message"].as_str()?.to_string();
let mut detail = message;
if let Some(code) = error["type"].as_str().or_else(|| error["code"].as_str()) {
detail.push_str(&format!(" [{code}]"));
}
if let Some(param) = error["param"].as_str() {
detail.push_str(&format!(" (param: {param})"));
}
Some(detail)
})
.unwrap_or_else(|| {
if body.trim().is_empty() {
"<empty response body>".to_string()
} else {
body
}
});
Err(anyhow!(
"Stripe API request to {url} failed with status {status}: {detail}"
))
}
+62
View File
@@ -0,0 +1,62 @@
use anyhow::{Result, anyhow};
use nwc::prelude::{
LookupInvoiceRequest, MakeInvoiceRequest, NWC, NostrWalletConnectURI, PayInvoiceRequest,
TransactionState,
};
/// A Nostr Wallet Connect wallet, used both as the service's receiving wallet
/// and as a tenant's paying wallet. Each call spins up and shuts down its own
/// short-lived NWC client; nothing is pooled across calls.
#[derive(Clone)]
pub struct Wallet {
url: NostrWalletConnectURI,
}
impl Wallet {
pub fn from_url(url: &str) -> Result<Self> {
Ok(Self {
url: url.parse::<NostrWalletConnectURI>()?,
})
}
pub async fn make_invoice(
&self,
amount_msats: u64,
description: &str,
expiry_secs: u64,
) -> Result<String> {
let nwc = NWC::new(self.url.clone());
let result = nwc
.make_invoice(MakeInvoiceRequest {
amount: amount_msats,
description: Some(description.to_string()),
description_hash: None,
expiry: Some(expiry_secs),
})
.await;
nwc.shutdown().await;
Ok(result
.map_err(|e| anyhow!("failed to create invoice: {e}"))?
.invoice)
}
pub async fn pay_invoice(&self, bolt11: String) -> Result<()> {
let nwc = NWC::new(self.url.clone());
let result = nwc.pay_invoice(PayInvoiceRequest::new(bolt11)).await;
nwc.shutdown().await;
result.map(|_| ()).map_err(|e| anyhow!("{e}"))
}
pub async fn is_settled(&self, bolt11: &str) -> Result<bool> {
let nwc = NWC::new(self.url.clone());
let result = nwc
.lookup_invoice(LookupInvoiceRequest {
payment_hash: None,
invoice: Some(bolt11.to_string()),
})
.await;
nwc.shutdown().await;
let response = result.map_err(|e| anyhow!("failed to lookup invoice: {e}"))?;
Ok(response.state == Some(TransactionState::Settled) || response.settled_at.is_some())
}
}
+129
View File
@@ -0,0 +1,129 @@
//! General-purpose HTTP helpers shared across route handlers.
//!
//! Success builders (`res`, `ok`, `created`) return [`ApiResult`] so they
//! can sit at the end of a handler without an `Ok(..)` wrap. Error builders
//! (`err`, `not_found`, `forbidden`, …) return [`ApiError`] so they compose
//! with `.map_err(...)` and with explicit `Err(...)` returns.
use std::fmt::Display;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
pub struct ApiError(pub Box<Response>);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
*self.0
}
}
impl From<Response> for ApiError {
fn from(r: Response) -> Self {
Self(Box::new(r))
}
}
pub type ApiResult = Result<Response, ApiError>;
#[derive(Serialize)]
pub struct DataResponse<T: Serialize> {
pub data: T,
pub code: &'static str,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
}
// --- success builders (return ApiResult) ------------------------------------
pub fn res<T: Serialize>(status: StatusCode, data: T) -> ApiResult {
Ok((status, Json(DataResponse { data, code: "ok" })).into_response())
}
pub fn ok<T: Serialize>(data: T) -> ApiResult {
res(StatusCode::OK, data)
}
pub fn created<T: Serialize>(data: T) -> ApiResult {
res(StatusCode::CREATED, data)
}
// --- error builders (return ApiError) ---------------------------------------
pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
(
status,
Json(ErrorResponse {
error: message.to_string(),
code: code.to_string(),
}),
)
.into_response()
.into()
}
pub fn unauthorized(reason: impl Display) -> ApiError {
err(
StatusCode::UNAUTHORIZED,
"unauthorized",
&reason.to_string(),
)
}
pub fn forbidden(message: &str) -> ApiError {
err(StatusCode::FORBIDDEN, "forbidden", message)
}
pub fn not_found(message: &str) -> ApiError {
err(StatusCode::NOT_FOUND, "not-found", message)
}
pub fn bad_request(code: &str, message: &str) -> ApiError {
err(StatusCode::BAD_REQUEST, code, message)
}
pub fn unprocessable(code: &str, message: &str) -> ApiError {
err(StatusCode::UNPROCESSABLE_ENTITY, code, message)
}
pub fn internal(reason: impl Display) -> ApiError {
err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&reason.to_string(),
)
}
// --- misc utilities ---------------------------------------------------------
pub fn parse_bool_default(value: i64, default: i64) -> i64 {
if value == 0 || value == 1 {
value
} else {
default
}
}
/// Recognize sqlite UNIQUE constraint violations on known columns so the
/// caller can translate them into 422 responses instead of opaque 500s.
pub fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> {
let sqlx_err = err.downcast_ref::<sqlx::Error>()?;
let sqlx::Error::Database(db_err) = sqlx_err else {
return None;
};
if db_err.message().contains("pubkey") {
return Some("pubkey-exists");
}
if db_err.message().contains("subdomain") {
return Some("subdomain-exists");
}
None
}
-31
View File
@@ -1,31 +0,0 @@
use axum::{Json, Router, routing::get};
use backend::billing::{fetch_btc_spot_price_from_base, fiat_minor_to_msats_from_quote};
#[tokio::test]
async fn quote_endpoint_can_be_stubbed_deterministically() {
async fn spot() -> Json<serde_json::Value> {
Json(serde_json::json!({ "data": { "amount": "50000.00" } }))
}
let app = Router::new().route("/v2/prices/BTC-USD/spot", get(spot));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test server");
let addr = listener.local_addr().expect("get local addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve quote stub");
});
let client = reqwest::Client::new();
let base = format!("http://{addr}/v2/prices");
let btc_price = fetch_btc_spot_price_from_base(&client, &base, "USD")
.await
.expect("fetch stubbed quote");
assert_eq!(btc_price, 50_000.0);
let msats =
fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000);
}
+3
View File
@@ -1,5 +1,8 @@
# Backend API base URL
VITE_API_URL=http://127.0.0.1:2892
# Domain that relay subdomains live under (must match the backend's RELAY_DOMAIN)
VITE_RELAY_DOMAIN=spaces.coracle.social
# Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel
-21
View File
@@ -1,21 +0,0 @@
FROM node:20-slim
RUN npm install -g bun
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install
COPY . .
ARG VITE_API_URL
ARG VITE_PLATFORM_NAME
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_PLATFORM_NAME=$VITE_PLATFORM_NAME
RUN bun run build
EXPOSE 3000
CMD ["npx", "serve", "dist", "-s"]
+1
View File
@@ -33,6 +33,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default |
|---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
| `VITE_RELAY_DOMAIN` | Domain relay subdomains live under (match backend `RELAY_DOMAIN`) | `spaces.coracle.social` |
## Running
+21 -14
View File
@@ -14,7 +14,10 @@ import AdminTenantDetail from "@/pages/admin/AdminTenantDetail"
import AdminRelayList from "@/pages/admin/AdminRelayList"
import AdminRelayDetail from "@/pages/admin/AdminRelayDetail"
import AdminRelayEdit from "@/pages/admin/AdminRelayEdit"
import { identity } from "@/lib/state"
import AdminInvoiceList from "@/pages/admin/AdminInvoiceList"
import AdminInvoiceDetail from "@/pages/admin/AdminInvoiceDetail"
import { account, eventStore, identity, pool } from "@/lib/state"
import { NostrProvider } from "@/lib/nostr"
function Layout(props: { children?: any }) {
const location = useLocation()
@@ -58,18 +61,22 @@ export default function App() {
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity()))
return (
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/relays" component={requireTenant(RelayList)} />
<Route path="/relays/new" component={requireTenant(RelayNew)} />
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
<Route path="/account" component={requireTenant(Account)} />
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
</Router>
<NostrProvider value={{ account, eventStore, pool }}>
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/relays" component={requireTenant(RelayList)} />
<Route path="/relays/new" component={requireTenant(RelayNew)} />
<Route path="/relays/:id" component={requireTenant(RelayDetail)} />
<Route path="/relays/:id/edit" component={requireTenant(RelayEdit)} />
<Route path="/account" component={requireTenant(Account)} />
<Route path="/admin/tenants" component={requireAdmin(AdminTenantList)} />
<Route path="/admin/tenants/:id" component={requireAdmin(AdminTenantDetail)} />
<Route path="/admin/relays" component={requireAdmin(AdminRelayList)} />
<Route path="/admin/relays/:id" component={requireAdmin(AdminRelayDetail)} />
<Route path="/admin/relays/:id/edit" component={requireAdmin(AdminRelayEdit)} />
<Route path="/admin/invoices" component={requireAdmin(AdminInvoiceList)} />
<Route path="/admin/invoices/:id" component={requireAdmin(AdminInvoiceDetail)} />
</Router>
</NostrProvider>
)
}
@@ -0,0 +1,59 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { invoiceStatus, type Invoice } from "@/lib/api"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
import { shortenPubkey } from "@/lib/pubkey"
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
type AdminInvoiceListItemProps = {
invoice: Invoice
href: string
showTenant?: boolean
}
export default function AdminInvoiceListItem(props: AdminInvoiceListItemProps) {
// Resolve the owning tenant's profile from the event store. The list that
// passes `showTenant` is responsible for priming these profiles in one batch,
// so this subscription does not prime on its own.
const metadata = useProfileMetadata(() => (props.showTenant ? props.invoice.tenant_pubkey : undefined), { prime: false })
const status = () => invoiceStatus(props.invoice)
return (
<li>
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="font-medium text-gray-900">{formatUsd(props.invoice.amount)}</p>
<p class="text-xs text-gray-500">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</p>
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</div>
</Show>
</div>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
</span>
</div>
</A>
</li>
)
}
+13 -94
View File
@@ -1,18 +1,12 @@
import { A, useLocation } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import Fuse from "fuse.js"
import { primeProfiles, useProfilePicture, useTenant, useTenantRelays, type Relay } from "@/lib/hooks"
import { listTenantInvoices, type Invoice } from "@/lib/api"
import { account, eventStore, identity } from "@/lib/state"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
import { account, identity } from "@/lib/state"
import { fuzzySearch } from "@/lib/search"
import { RELAY_DOMAIN } from "@/lib/subdomain"
import serverIcon from "@/assets/server.svg"
import Modal from "@/components/Modal"
import PaymentDialog from "@/components/PaymentDialog"
type Profile = {
name?: string
display_name?: string
nip05?: string
}
import BillingPrompts from "@/components/BillingPrompts"
function shortenPubkey(pubkey?: string) {
if (!pubkey) return ""
@@ -35,74 +29,25 @@ function RelayIcon() {
export default function AppShell(props: { children?: any }) {
const location = useLocation()
const picture = useProfilePicture(() => account()?.pubkey)
const [tenant] = useTenant()
const metadata = useProfileMetadata(() => account()?.pubkey)
const [tenantRelays] = useTenantRelays()
const [profile, setProfile] = createSignal<Profile>({})
const [searchOpen, setSearchOpen] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("")
const [pastDueInvoice, setPastDueInvoice] = createSignal<Invoice | undefined>()
const [showPaymentDialog, setShowPaymentDialog] = createSignal(false)
createEffect(async () => {
const t = tenant()
if (!t?.past_due_at) {
setPastDueInvoice(undefined)
return
}
try {
const invoices = await listTenantInvoices(t.pubkey)
const openInvoice = invoices.find(inv => inv.status === "open")
setPastDueInvoice(openInvoice)
} catch {
// ignore
}
})
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => metadata()?.nip05)
const searchedRelays = createMemo<Relay[]>(() => {
const list = tenantRelays() ?? []
const query = searchQuery().trim()
if (!query) return list
const fuse = new Fuse(list, {
keys: ["info_name", "subdomain"],
threshold: 0.35,
ignoreLocation: true,
})
return fuse.search(query).map((result) => result.item)
})
createEffect(() => {
const pubkey = account()?.pubkey
if (!pubkey) {
setProfile({})
return
}
const profileSub = eventStore.profile(pubkey).subscribe((metadata) => {
setProfile({
name: metadata?.name,
display_name: metadata?.display_name,
nip05: metadata?.nip05,
})
})
const primeSub = primeProfiles([pubkey])
onCleanup(() => {
profileSub.unsubscribe()
primeSub.unsubscribe()
})
return fuzzySearch(list, ["info_name", "subdomain"], query)
})
const myResources = [{ href: "/relays", label: "My Relays" }]
const adminResources = [
{ href: "/admin/tenants", label: "Tenants" },
{ href: "/admin/relays", label: "Relays" },
{ href: "/admin/invoices", label: "Invoices" },
]
const navItemClass = (href: string) => {
@@ -158,36 +103,10 @@ export default function AppShell(props: { children?: any }) {
</aside>
<div class="md:pl-[260px] min-h-screen pb-20 md:pb-0">
<Show when={tenant()?.past_due_at}>
<div class="bg-red-600 text-white px-4 py-3 text-sm flex items-center justify-between">
<span>Your account has an overdue balance.</span>
<Show when={pastDueInvoice()}>
<button
type="button"
onClick={() => setShowPaymentDialog(true)}
class="font-medium underline hover:no-underline"
>
Pay now
</button>
</Show>
</div>
</Show>
<BillingPrompts variant="banner" />
<main>{props.children}</main>
</div>
<Show when={pastDueInvoice() && showPaymentDialog()}>
{(_) => {
const invoice = pastDueInvoice()!
return (
<PaymentDialog
invoice={invoice}
open={true}
onClose={() => setShowPaymentDialog(false)}
/>
)
}}
</Show>
<nav
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
onClick={() => {
@@ -269,7 +188,7 @@ export default function AppShell(props: { children?: any }) {
onClick={closeSearchModal}
>
<p class="text-sm font-medium text-gray-900">{relay.info_name || relay.subdomain}</p>
<p class="text-xs text-gray-500">{relay.subdomain}.spaces.coracle.social</p>
<p class="text-xs text-gray-500">{relay.subdomain}.{RELAY_DOMAIN}</p>
</A>
</li>
)}
+15 -2
View File
@@ -1,14 +1,27 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
type BackLinkProps = {
href: string
// Omit to pop the previous history entry instead of navigating to a fixed
// route — for pages reachable from several places, "back" should land the
// user wherever they came from.
href?: string
label: string
}
export default function BackLink(props: BackLinkProps) {
return (
<div class="flex items-center gap-2 mb-6">
<A href={props.href} class="text-gray-500 hover:text-gray-700"> {props.label}</A>
<Show
when={props.href}
fallback={
<button type="button" onClick={() => history.back()} class="text-gray-500 hover:text-gray-700">
{props.label}
</button>
}
>
{(href) => <A href={href()} class="text-gray-500 hover:text-gray-700"> {props.label}</A>}
</Show>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { createMemo, createResource, createSignal, Show } from "solid-js"
import { useSearchParams } from "@solidjs/router"
import PromptBanner, { type PromptBannerAction } from "@/components/PromptBanner"
import PaymentDialog from "@/components/PaymentDialog"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, type Invoice } from "@/lib/api"
import { activeBillingPrompt, useBillingStatus, type BillingPromptKind } from "@/lib/billing"
import { account, billingFlowActive } from "@/lib/state"
type BillingPromptsProps = {
// "banner" sits in the dashboard shell (mounted on every page except the
// billing page); "inline" is shown contextually on the billing page itself.
// Only one is ever mounted at a time, so each can own its own modals + deep link.
variant?: "banner" | "inline"
}
export default function BillingPrompts(props: BillingPromptsProps) {
const status = useBillingStatus()
const [dismissed, setDismissed] = createSignal<Set<BillingPromptKind>>(new Set())
const [payInvoice, setPayInvoice] = createSignal<Invoice | undefined>()
const [setupOpen, setSetupOpen] = createSignal(false)
const [setupTab, setSetupTab] = createSignal<"nwc" | "card">("nwc")
// Deep link: /...?invoice=<id> (e.g. from the billing DM) opens the payment
// dialog on whatever dashboard page the link lands on.
const [searchParams, setSearchParams] = useSearchParams()
const [deepLinked] = createResource(
() => searchParams.invoice as string | undefined,
(id) => getInvoice(id),
)
const prompt = createMemo(() =>
activeBillingPrompt(
{
tenant: status.tenant(),
openInvoice: status.openInvoice(),
hasPaidSubscription: status.hasPaidSubscription(),
},
{ suppressInline: billingFlowActive() },
),
)
const visiblePrompt = createMemo(() => {
const p = prompt()
if (!p || dismissed().has(p.kind)) return undefined
return p
})
function openSetup(tab: "nwc" | "card") {
setSetupTab(tab)
setSetupOpen(true)
}
const actions = createMemo<PromptBannerAction[]>(() => {
const p = visiblePrompt()
if (!p) return []
const open = status.openInvoice()
switch (p.kind) {
case "churned":
return open
? [
{ label: "Pay now", onClick: () => setPayInvoice(open) },
{ label: "Update payment method", onClick: () => openSetup("nwc") },
]
: [{ label: "Update payment method", onClick: () => openSetup("nwc") }]
case "pay_invoice":
return open ? [{ label: "Pay now", onClick: () => setPayInvoice(open) }] : []
case "update_method":
return [{ label: "Update payment method", onClick: () => openSetup(status.tenant()?.nwc_error ? "nwc" : "card") }]
case "setup_autopay":
return [{ label: "Set up autopay", onClick: () => openSetup("nwc") }]
}
return []
})
const dismissible = () => visiblePrompt()?.kind === "setup_autopay"
function dismiss() {
const p = visiblePrompt()
if (p) setDismissed((prev) => new Set([...prev, p.kind]))
}
function clearDeepLink() {
if (searchParams.invoice) setSearchParams({ invoice: undefined })
}
// After paying or saving a method, reconcile + sync + (auto)collect, then
// refresh — so a card added inside the dialog actually settles the invoice.
function refreshBilling() {
const pubkey = account()?.pubkey
if (pubkey) void status.autopay(pubkey)
}
const outerClass = () => (props.variant === "inline" ? "" : "mx-4 mt-4 md:mx-6")
return (
<>
<Show when={visiblePrompt()}>
{(p) => (
<PromptBanner
severity={p().severity}
message={p().message}
actions={actions()}
onDismiss={dismissible() ? dismiss : undefined}
class={outerClass()}
/>
)}
</Show>
{/* Pay an invoice — from a prompt action or a deep link. */}
<Show when={payInvoice() ?? deepLinked()}>
{(invoice) => (
<PaymentDialog
invoice={invoice()}
open={true}
onClose={() => {
const wasDeepLink = !payInvoice()
setPayInvoice(undefined)
if (wasDeepLink) clearDeepLink()
refreshBilling()
}}
/>
)}
</Show>
<PaymentSetup
open={setupOpen()}
initialTab={setupTab()}
onClose={() => {
setSetupOpen(false)
refreshBilling()
}}
/>
</>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { For, Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
type ConfirmDialogProps = {
open: boolean
title: string
description: string
/** Optional bullet points shown in a warning box below the description */
details?: string[]
confirmLabel: string
busyLabel?: string
busy?: boolean
tone?: "danger" | "primary"
onConfirm: () => void | Promise<void>
onClose: () => void
}
const TONE_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "bg-red-600 text-white hover:bg-red-700",
primary: "bg-blue-600 text-white hover:bg-blue-700",
}
const DETAIL_BOX_STYLES: Record<NonNullable<ConfirmDialogProps["tone"]>, string> = {
danger: "border-amber-200 bg-amber-50 text-amber-800",
primary: "border-blue-200 bg-blue-50 text-blue-800",
}
type ConfirmDialogSnapshot = {
title: string
description: string
details?: string[]
confirmLabel: string
busyLabel?: string
busy: boolean
tone: NonNullable<ConfirmDialogProps["tone"]>
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [snapshot, setSnapshot] = createSignal<ConfirmDialogSnapshot>({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
createEffect(() => {
if (!props.open) return
setSnapshot({
title: props.title,
description: props.description,
details: props.details ? [...props.details] : undefined,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
})
})
const content = () => props.open
? {
title: props.title,
description: props.description,
details: props.details,
confirmLabel: props.confirmLabel,
busyLabel: props.busyLabel,
busy: props.busy ?? false,
tone: props.tone ?? "primary",
}
: snapshot()
const tone = () => content().tone
const confirmText = () => content().busy ? (content().busyLabel ?? content().confirmLabel) : content().confirmLabel
function handleClose() {
if (props.busy) return
props.onClose()
}
function handleConfirm() {
if (props.busy) return
void props.onConfirm()
}
return (
<Modal
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{content().title}</h2>
</div>
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="space-y-3 text-left">
<p class="text-sm text-gray-600">{content().description}</p>
<Show when={content().details && content().details!.length > 0}>
<ul class={`w-full rounded-lg border px-4 py-3 space-y-1.5 ${DETAIL_BOX_STYLES[tone()]}`}>
<For each={content().details}>
{(item) => (
<li class="flex items-start gap-2 text-sm">
<span class="mt-0.5 shrink-0 select-none"></span>
<span>{item}</span>
</li>
)}
</For>
</ul>
</Show>
</div>
</div>
<div class="px-6 py-4 flex justify-end gap-3 border-t border-gray-100">
<button
type="button"
onClick={handleClose}
disabled={content().busy}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirm}
disabled={content().busy}
class={`rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${TONE_STYLES[tone()]}`}
>
{confirmText()}
</button>
</div>
</Modal>
)
}
@@ -0,0 +1,68 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { invoiceStatus, type Invoice } from "@/lib/api"
import Field from "@/components/Field"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
import { shortenPubkey } from "@/lib/pubkey"
const invoiceStatusStyles: Record<string, string> = {
open: "bg-yellow-50 text-yellow-700 border-yellow-200",
paid: "bg-green-50 text-green-700 border-green-200",
void: "bg-gray-100 text-gray-500 border-gray-200",
}
type InvoiceDetailCardProps = {
invoice: Invoice
}
export default function InvoiceDetailCard(props: InvoiceDetailCardProps) {
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey, like the admin list item.
const metadata = useProfileMetadata(() => props.invoice.tenant_pubkey, { prime: false })
const status = () => invoiceStatus(props.invoice)
return (
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-6">
<div class="flex items-center justify-between gap-3">
<p class="text-2xl font-bold text-gray-900">{formatUsd(props.invoice.amount)}</p>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${invoiceStatusStyles[status()] ?? "bg-gray-100 text-gray-500 border-gray-200"}`}>
{status()}
</span>
</div>
<hr class="border-gray-200" />
<dl class="grid gap-x-6 gap-y-4 sm:grid-cols-2">
<Field label="Billing period">
<span class="text-gray-900">{formatPeriod(props.invoice.period_start, props.invoice.period_end)}</span>
</Field>
<Field label="Created">
<span class="text-gray-900">{new Date(props.invoice.created_at * 1000).toLocaleDateString()}</span>
</Field>
<Show when={props.invoice.method}>
<Field label="Payment method">
<span class="uppercase text-gray-900">{props.invoice.method}</span>
</Field>
</Show>
<Field label="Tenant">
<A href={`/admin/tenants/${props.invoice.tenant_pubkey}`} class="group flex w-fit items-center gap-2 min-w-0">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.invoice.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="truncate text-blue-600 group-hover:underline">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.invoice.tenant_pubkey)}</span>
</A>
</Field>
</dl>
</div>
)
}
+124 -74
View File
@@ -1,17 +1,23 @@
import { createEffect, createSignal, Show } from "solid-js"
import { createEffect, createResource, createSignal, Show } from "solid-js"
import QRCode from "qrcode"
import Modal from "@/components/Modal"
import PaymentSetup from "@/components/PaymentSetup"
import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
import InvoiceItemsList from "@/components/payment/InvoiceItemsList"
import LightningPayBody from "@/components/payment/LightningPayBody"
import { setToastMessage } from "@/lib/state"
import { copyToClipboard } from "@/lib/clipboard"
import { useInvoiceCheckout } from "@/lib/usePaymentSetup"
import { ensureInvoiceBolt11, listInvoiceItems, reconcileInvoice, type Invoice } from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { billingTenant } from "@/lib/state"
import { formatUsd, formatPeriod } from "@/lib/format"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PayMethod = "lightning" | "card"
type PaymentInvoice = {
id: string
amount_due: number
}
type PaymentInvoice = Pick<Invoice, "id" | "amount"> &
Partial<Pick<Invoice, "period_start" | "period_end">>
type PaymentDialogProps = {
invoice: PaymentInvoice
@@ -25,9 +31,24 @@ export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [payMethod, setPayMethod] = createSignal<PayMethod>("lightning")
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
const [setupSaved, setSetupSaved] = createSignal(false)
const [items] = createResource(
() => (props.open ? props.invoice.id : undefined),
listInvoiceItems,
)
// Paying by card opens a Stripe Checkout session scoped to this invoice (which
// can clear a 3D Secure challenge the off-session charge can't), then returns
// here where the payment is reconciled. Distinct from PaymentSetup, which
// manages the recurring card on file via the billing portal.
const checkout = useInvoiceCheckout(() => props.invoice.id)
const hasAutopay = () => {
const t = billingTenant()
return t ? autopayConfigured(t) : false
}
async function loadBolt11() {
if (!props.invoice.id) return
@@ -37,9 +58,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
setQrDataUrl("")
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
const { lnbc } = await ensureInvoiceBolt11(props.invoice.id)
setBolt11(lnbc)
setQrDataUrl(await QRCode.toDataURL(lnbc, { width: 256, margin: 2 }))
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
@@ -52,40 +73,47 @@ export default function PaymentDialog(props: PaymentDialogProps) {
void loadBolt11()
})
// The checkout redirect lives in a shared hook, so surface its failures here
// by mirroring its error signal into the toast.
createEffect(() => {
const err = checkout.error()
if (err) setToastMessage(err)
})
function copyBolt11() {
void navigator.clipboard.writeText(bolt11())
void copyToClipboard(bolt11(), { successMessage: "Invoice copied" })
}
async function checkPayment() {
setPayStatus("loading")
setPayError("")
try {
const invoice = await getInvoice(props.invoice.id)
if (invoice.status === "paid") {
const invoice = await reconcileInvoice(props.invoice.id)
if (invoice.paid_at != null) {
setPayStatus("success")
tenantNeedsPaymentSetup().then(needs => setShowSetup(needs)).catch(() => {})
} else {
setPayStatus("error")
setPayError("Payment not yet confirmed. Please try again after sending.")
setToastMessage("Payment not yet confirmed. Please try again after sending.")
}
} catch (e) {
setPayStatus("error")
setPayError(e instanceof Error ? e.message : "Failed to check payment status")
setToastMessage(e instanceof Error ? e.message : "Failed to check payment status")
}
}
function handleClose() {
setPayStatus("idle")
setPayError("")
setBolt11Status("idle")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setShowSetup(false)
setPayMethod("lightning")
checkout.reset()
props.onClose()
}
const amountLabel = () => `$${(props.invoice.amount_due / 100).toFixed(2)}`
const amountLabel = () => formatUsd(props.invoice.amount)
const periodLabel = () => formatPeriod(props.invoice.period_start, props.invoice.period_end)
return (
<>
@@ -101,6 +129,9 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<div>
<h2 class="text-lg font-semibold text-gray-900">Pay Invoice</h2>
<p class="text-2xl font-bold text-gray-900 mt-1">{amountLabel()}</p>
<Show when={periodLabel()}>
<p class="text-xs text-gray-500 mt-0.5">Billing period {periodLabel()}</p>
</Show>
</div>
<button
type="button"
@@ -120,47 +151,64 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<Show
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-3">
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
<div class="w-full space-y-4">
{/* What's being paid for — the invoice's actual line items */}
<Show when={(items() ?? []).length > 0}>
<InvoiceItemsList items={items() ?? []} />
</Show>
<Show when={bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
{/* Method switcher */}
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm transition-colors ${payMethod() === "lightning" ? "bg-gray-900 text-white" : "text-gray-700 hover:bg-gray-50"}`}
onClick={() => setPayMethod("lightning")}
>
Lightning
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm transition-colors ${payMethod() === "card" ? "bg-gray-900 text-white" : "text-gray-700 hover:bg-gray-50"}`}
onClick={() => setPayMethod("card")}
>
Card
</button>
</div>
{/* Lightning: pay this invoice via a bolt11 QR */}
<Show when={payMethod() === "lightning"}>
<LightningPayBody
bolt11Status={bolt11Status}
bolt11={bolt11}
qrDataUrl={qrDataUrl}
bolt11Error={bolt11Error}
onRetry={() => void loadBolt11()}
onCopy={copyBolt11}
/>
</Show>
{/* Card: redirect to a Stripe Checkout session for this invoice */}
<Show when={payMethod() === "card"}>
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">
Pay this invoice on Stripe's secure checkout. You'll be redirected and brought back here once it's done.
</p>
<button
type="button"
onClick={() => void loadBolt11()}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
onClick={checkout.openCheckout}
disabled={checkout.redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Retry
{checkout.redirecting() ? "Redirecting..." : `Pay ${amountLabel()} by card`}
</button>
</div>
</Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
</Show>
</div>
}
>
@@ -172,11 +220,11 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</div>
<p class="text-sm font-medium text-gray-900">Payment confirmed!</p>
<p class="text-xs text-gray-500">Thank you. Your account is up to date.</p>
<Show when={showSetup()}>
<Show when={!hasAutopay()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
class="mt-2 inline-flex items-center justify-center rounded-lg border border-blue-200 bg-blue-50 px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Set up automatic payments
</button>
@@ -185,13 +233,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
</Show>
</div>
{/* Error */}
<Show when={payError()}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{payError()}</p>
</div>
</Show>
{/* Footer */}
<div class="px-6 py-4 flex justify-between gap-3 border-t border-gray-100">
<Show
@@ -215,20 +256,29 @@ export default function PaymentDialog(props: PaymentDialogProps) {
>
Pay Later
</button>
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
</button>
<Show when={payMethod() === "lightning"}>
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
</button>
</Show>
</Show>
</div>
</Modal>
<PaymentSetup
open={showPaymentSetup()}
onClose={() => setShowPaymentSetup(false)}
onClose={() => {
setShowPaymentSetup(false)
if (setupSaved()) {
setSetupSaved(false)
props.onClose()
}
}}
onSaved={() => setSetupSaved(true)}
/>
</>
)
+30 -144
View File
@@ -1,84 +1,48 @@
import { createSignal, Show } from "solid-js"
import Modal from "@/components/Modal"
import { updateActiveTenant } from "@/lib/hooks"
import { createPortalSession } from "@/lib/api"
import { account } from "@/lib/state"
import { createEffect, createSignal, Show } from "solid-js"
import { PaymentSetupShell, PaymentSetupBody, SetupFooter, NwcSetupBody, CardSetupBody } from "@/components/PaymentSetupShell"
import { useCardPortal, useNwcSetup } from "@/lib/usePaymentSetup"
type Tab = "nwc" | "card"
type PaymentSetupProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// Which method to show first. Lightning/NWC is the default; pass "card" to land
// a tenant on the card tab (e.g. when their card is the method that failed).
initialTab?: Tab
}
export default function PaymentSetup(props: PaymentSetupProps) {
const [tab, setTab] = createSignal<Tab>("nwc")
const [nwcUrl, setNwcUrl] = createSignal("")
const [saving, setSaving] = createSignal(false)
const [saved, setSaved] = createSignal(false)
const [error, setError] = createSignal("")
const [redirecting, setRedirecting] = createSignal(false)
const [tab, setTab] = createSignal<Tab>(props.initialTab ?? "nwc")
async function saveNwc() {
const url = nwcUrl().trim()
if (!url) return
setSaving(true)
setError("")
try {
await updateActiveTenant({ nwc_url: url })
setSaved(true)
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save wallet connection")
} finally {
setSaving(false)
}
}
// Reset to the requested tab each time the dialog opens.
createEffect(() => {
if (props.open) setTab(props.initialTab ?? "nwc")
})
async function openPortal() {
setRedirecting(true)
setError("")
try {
const { url } = await createPortalSession(account()!.pubkey)
window.location.href = url
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to open billing portal")
setRedirecting(false)
}
}
const nwc = useNwcSetup(() => props.onSaved?.())
const card = useCardPortal()
// Surface only the active tab's error so a stale failure on one method doesn't
// bleed into the other.
const error = () => (tab() === "nwc" ? nwc.error() : card.error())
function handleClose() {
setNwcUrl("")
setSaved(false)
setError("")
nwc.reset()
card.reset()
props.onClose()
}
return (
<Modal
<PaymentSetupShell
open={props.open}
onClose={handleClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
title="Set Up Payments"
description="Choose how you'd like to pay once invoices are issued for your relay."
error={error()}
footer={<SetupFooter saved={nwc.saved()} cancelLabel="Set up later" onClose={handleClose} />}
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">Set Up Payments</h2>
<p class="text-sm text-gray-500 mt-1">Choose how you'd like to pay for your relay.</p>
</div>
<button
type="button"
onClick={handleClose}
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 pt-4">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">Pay with</p>
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
@@ -99,92 +63,14 @@ export default function PaymentSetup(props: PaymentSetupProps) {
</div>
</div>
<div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">
<PaymentSetupBody>
<Show when={tab() === "nwc"}>
<Show
when={!saved()}
fallback={
<div class="text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p class="text-sm font-medium text-gray-900">Wallet connected!</p>
<p class="text-xs text-gray-500 mt-1">Automatic payments are now enabled.</p>
</div>
}
>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Nostr Wallet Connect URL</label>
<input
type="text"
value={nwcUrl()}
onInput={(e) => setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={saveNwc}
disabled={saving() || !nwcUrl().trim()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving() ? "Saving..." : "Save"}
</button>
</div>
</Show>
<NwcSetupBody nwc={nwc} />
</Show>
<Show when={tab() === "card"}>
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">Add a payment card via Stripe to enable automatic billing.</p>
<button
type="button"
onClick={openPortal}
disabled={redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{redirecting() ? "Redirecting..." : "Add a payment card"}
</button>
</div>
<CardSetupBody card={card} />
</Show>
</div>
<Show when={error()}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{error()}</p>
</div>
</Show>
<div class="px-6 py-4 border-t border-gray-100">
<Show when={saved()}>
<div class="flex justify-end">
<button
type="button"
onClick={handleClose}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
Done
</button>
</div>
</Show>
<Show when={!saved()}>
<button
type="button"
onClick={handleClose}
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
Set up later
</button>
</Show>
</div>
</Modal>
</PaymentSetupBody>
</PaymentSetupShell>
)
}
@@ -0,0 +1,42 @@
import { PaymentSetupShell, PaymentSetupBody, SetupFooter, CardSetupBody } from "@/components/PaymentSetupShell"
import { useCardPortal } from "@/lib/usePaymentSetup"
type PaymentSetupCardProps = {
open: boolean
onClose: () => void
// The tenant already has a card on file, so the copy frames this as managing
// the existing one rather than adding a first card.
isUpdate?: boolean
}
// Focused card dialog. PaymentSetup offers both methods behind tabs for the
// general setup flow; here the entry point is explicitly "manage your card", so
// there's no method switcher — adding/updating a card is a redirect to the
// Stripe billing portal, which returns to wherever it was opened from.
export default function PaymentSetupCard(props: PaymentSetupCardProps) {
const card = useCardPortal()
function handleClose() {
card.reset()
props.onClose()
}
return (
<PaymentSetupShell
open={props.open}
onClose={handleClose}
title={props.isUpdate ? "Manage Card" : "Add a Card"}
description={
props.isUpdate
? "Manage your saved card in the Stripe billing portal."
: "Add a card via the Stripe billing portal to pay invoices automatically."
}
error={card.error()}
footer={<SetupFooter cancelLabel="Cancel" onClose={handleClose} />}
>
<PaymentSetupBody>
<CardSetupBody card={card} isUpdate={props.isUpdate} />
</PaymentSetupBody>
</PaymentSetupShell>
)
}
@@ -0,0 +1,43 @@
import { PaymentSetupShell, PaymentSetupBody, SetupFooter, NwcSetupBody } from "@/components/PaymentSetupShell"
import { useNwcSetup } from "@/lib/usePaymentSetup"
type PaymentSetupNWCProps = {
open: boolean
onClose: () => void
onSaved?: () => void
// The tenant already has a wallet connected, so the copy frames this as
// replacing it (the stored URL is write-only and never sent back).
isUpdate?: boolean
}
// Focused Lightning/NWC connect dialog. PaymentSetup offers both methods behind
// tabs for the general setup flow; here the entry point is explicitly "connect a
// Lightning wallet", so there's no method switcher — the card path lives on its
// own row that redirects to Stripe.
export default function PaymentSetupNWC(props: PaymentSetupNWCProps) {
const nwc = useNwcSetup(() => props.onSaved?.())
function handleClose() {
nwc.reset()
props.onClose()
}
return (
<PaymentSetupShell
open={props.open}
onClose={handleClose}
title={props.isUpdate ? "Update Lightning Wallet" : "Connect Lightning Wallet"}
description={
props.isUpdate
? "Paste a new Nostr Wallet Connect URL to replace your connected wallet."
: "Paste your Nostr Wallet Connect URL to pay invoices automatically over Lightning."
}
error={nwc.error()}
footer={<SetupFooter saved={nwc.saved()} cancelLabel="Cancel" onClose={handleClose} />}
>
<PaymentSetupBody>
<NwcSetupBody nwc={nwc} />
</PaymentSetupBody>
</PaymentSetupShell>
)
}
@@ -0,0 +1,159 @@
import { Show, type JSX } from "solid-js"
import Modal from "@/components/Modal"
import type { CardPortal, NwcSetup } from "@/lib/usePaymentSetup"
type PaymentSetupShellProps = {
open: boolean
onClose: () => void
title: string
description: string
error?: string
footer: JSX.Element
children: JSX.Element
}
// Shared chrome for the payment-setup dialogs: the modal frame, the
// title/description header with a close button, the error line, and the footer
// container. Each caller supplies the body (children) and footer buttons.
export function PaymentSetupShell(props: PaymentSetupShellProps) {
return (
<Modal
open={props.open}
onClose={props.onClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
panelClass="w-full max-w-md rounded-2xl bg-white shadow-xl overflow-hidden"
>
<div class="px-6 pt-6 pb-4 border-b border-gray-100">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-gray-900">{props.title}</h2>
<p class="text-sm text-gray-500 mt-1">{props.description}</p>
</div>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-700 rounded p-1 hover:bg-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
{props.children}
<Show when={props.error}>
<div class="px-6 pb-2">
<p class="text-xs text-red-600">{props.error}</p>
</div>
</Show>
<div class="px-6 py-4 border-t border-gray-100">{props.footer}</div>
</Modal>
)
}
// The fixed-height content region between the header and footer.
export function PaymentSetupBody(props: { children: JSX.Element }) {
return <div class="px-6 py-4 min-h-[180px] flex flex-col justify-center">{props.children}</div>
}
// Footer for every payment-setup dialog: a "Done" confirm once an action has
// succeeded, otherwise a secondary dismiss button. Card setup never "saves" (it
// redirects away), so it always shows the dismiss button.
export function SetupFooter(props: { saved?: boolean; cancelLabel: string; onClose: () => void }) {
return (
<Show
when={props.saved}
fallback={
<button
type="button"
onClick={props.onClose}
class="py-2 px-4 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors"
>
{props.cancelLabel}
</button>
}
>
<div class="flex justify-end">
<button
type="button"
onClick={props.onClose}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
>
Done
</button>
</div>
</Show>
)
}
// Lightning/NWC body: the URL input + save, or the success state once saved.
export function NwcSetupBody(props: { nwc: NwcSetup }) {
const nwc = props.nwc
return (
<Show
when={!nwc.saved()}
fallback={
<div class="text-center">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg class="w-6 h-6 text-green-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<p class="text-sm font-medium text-gray-900">Wallet connected!</p>
<p class="text-xs text-gray-500 mt-1">Automatic payments are now enabled.</p>
</div>
}
>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Nostr Wallet Connect URL</label>
<input
type="text"
value={nwc.nwcUrl()}
onInput={(e) => nwc.setNwcUrl(e.currentTarget.value)}
placeholder="nostr+walletconnect://..."
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={nwc.save}
disabled={nwc.saving() || !nwc.nwcUrl().trim()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{nwc.saving() ? "Saving..." : "Save"}
</button>
</div>
</Show>
)
}
// Card body: an explanation plus the button that redirects to the Stripe portal.
// `isUpdate` adjusts the copy for tenants who already have a card on file.
export function CardSetupBody(props: { card: CardPortal; isUpdate?: boolean }) {
return (
<div class="text-center space-y-4">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<svg class="w-6 h-6 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</div>
<p class="text-sm text-gray-600">
{props.isUpdate
? "Update or remove your card in the Stripe billing portal. We'll retry any due invoice after you're done."
: "Add a payment card via Stripe to enable automatic billing. If an invoice is currently due, we will retry collection after card setup."}
</p>
<button
type="button"
onClick={props.card.openPortal}
disabled={props.card.redirecting()}
class="w-full py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{props.card.redirecting() ? "Redirecting..." : props.isUpdate ? "Manage card" : "Add a payment card"}
</button>
</div>
)
}
+4 -4
View File
@@ -32,9 +32,9 @@ function memberLabel(members: number | null) {
type PricingTableProps = {
selectable?: boolean
selectedPlan?: PlanId
onSelect?: (plan: PlanId) => void
onCta?: (plan: PlanId) => void
selectedPlanId?: PlanId
onSelect?: (planId: PlanId) => void
onCta?: (planId: PlanId) => void
}
export default function PricingTable(props: PricingTableProps) {
@@ -43,7 +43,7 @@ export default function PricingTable(props: PricingTableProps) {
<For each={plans()}>
{(plan) => {
const isPopular = plan.id === "basic"
const isSelected = () => props.selectable && props.selectedPlan === plan.id
const isSelected = () => props.selectable && props.selectedPlanId === plan.id
const card = (
<>
+61
View File
@@ -0,0 +1,61 @@
import { For, Show } from "solid-js"
export type PromptBannerAction = {
label: string
onClick: () => void
}
type Severity = "error" | "warn" | "info"
type PromptBannerProps = {
severity: Severity
message: string
actions?: PromptBannerAction[]
onDismiss?: () => void
class?: string
}
const containerStyles: Record<Severity, string> = {
error: "border-red-200 bg-red-50 text-red-800",
warn: "border-amber-200 bg-amber-50 text-amber-800",
info: "border-blue-200 bg-blue-50 text-blue-800",
}
const actionStyles: Record<Severity, string> = {
error: "text-red-800 hover:text-red-900",
warn: "text-amber-800 hover:text-amber-900",
info: "text-blue-800 hover:text-blue-900",
}
export default function PromptBanner(props: PromptBannerProps) {
return (
<div class={`rounded-lg border p-4 flex items-start justify-between gap-4 ${containerStyles[props.severity]} ${props.class ?? ""}`.trim()}>
<p class="text-sm min-w-0">{props.message}</p>
<div class="flex items-center gap-3 shrink-0">
<For each={props.actions ?? []}>
{(action) => (
<button
type="button"
onClick={action.onClick}
class={`text-sm font-medium underline hover:no-underline whitespace-nowrap ${actionStyles[props.severity]}`}
>
{action.label}
</button>
)}
</For>
<Show when={props.onDismiss}>
<button
type="button"
onClick={() => props.onDismiss?.()}
aria-label="Dismiss"
class={`shrink-0 opacity-70 hover:opacity-100 ${actionStyles[props.severity]}`}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</Show>
</div>
</div>
)
}
+107 -209
View File
@@ -1,29 +1,17 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Show, createSignal } from "solid-js"
import type { Relay, PlanId } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import ConfirmDialog from "@/components/ConfirmDialog"
import Field from "@/components/Field"
import PricingTable from "@/components/PricingTable"
import ToggleButton from "@/components/ToggleButton"
import ToggleField from "@/components/ToggleField"
import { setToastMessage } from "@/components/Toast"
import RelayCardHeader from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import { flagToBool } from "@/lib/relayFlags"
import { plans } from "@/lib/state"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
}
function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
function DetailSection(props: { title: string; children: any }) {
return (
<div>
@@ -51,8 +39,8 @@ type RelayDetailCardProps = {
currentMembers?: number
showTenant?: boolean
editHref?: string
onDeactivate?: () => void
onReactivate?: () => void
onDeactivate?: () => void | Promise<void>
onReactivate?: () => void | Promise<void>
deactivating?: boolean
reactivating?: boolean
onTogglePublicJoin?: () => void
@@ -62,166 +50,104 @@ type RelayDetailCardProps = {
onToggleMediaStorage?: () => void
onToggleLivekitSupport?: () => void
onTogglePushNotifications?: () => void
onUpdatePlan?: (plan: PlanId) => Promise<void>
onUpdatePlan?: (planId: PlanId) => Promise<void>
enforcePlanLimits?: boolean
showPlanActions?: boolean
}
export default function RelayDetailCard(props: RelayDetailCardProps) {
const r = () => props.relay
const flag = (value: number, fallback: boolean) => {
if (value === 0) return false
if (value === 1) return true
return fallback
}
const [menuOpen, setMenuOpen] = createSignal(false)
const [plan, setPlan] = createSignal<PlanId>(props.relay.plan)
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
let menuContainerRef: HTMLDivElement | undefined
// Resolve the owning tenant's profile so the Tenant field can show a name and
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
// This subscription stays in the parent so the header doesn't double-subscribe.
const tenantProfile = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined))
const memberLimitLabel = () => {
const p = plans().find(p => p.id === r().plan)
const p = plans().find(p => p.id === r().plan_id)
if (!p) return "?"
return p.members === null ? "∞" : String(p.members)
}
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan === "free"
const planLimited = () => (props.enforcePlanLimits ?? true) && r().plan_id === "free"
const showPlanActions = () => props.showPlanActions ?? true
const actionBusy = () => pendingAction() === "deactivate" ? !!props.deactivating : pendingAction() === "reactivate" ? !!props.reactivating : false
const relayLabel = () => r().info_name || r().subdomain
const confirmTitle = () => pendingAction() === "deactivate" ? "Deactivate relay?" : "Reactivate relay?"
const confirmDescription = () => pendingAction() === "deactivate"
? `${relayLabel()} will be taken offline immediately.`
: `${relayLabel()} will come back online and start accepting connections.`
const confirmDetails = () => pendingAction() === "deactivate"
? [
"All client connections will be dropped immediately.",
"Members will be unable to read from or publish to the relay.",
"Scheduled and automated tasks (billing, syncing) will be paused.",
"All relay data, settings, and members are preserved, nothing is deleted.",
"You can reactivate at any time from this page.",
]
: undefined
const confirmLabel = () => pendingAction() === "deactivate" ? "Yes, deactivate" : "Yes, reactivate"
const confirmBusyLabel = () => pendingAction() === "deactivate" ? "Deactivating..." : "Reactivating..."
const confirmTone = () => pendingAction() === "deactivate" ? "danger" : "primary"
async function changePlan(plan: PlanId) {
setPlan(plan)
async function changePlanId(planId: PlanId) {
setPlanId(planId)
try {
await props.onUpdatePlan?.(plan)
setToastMessage(`Plan updated to ${plan}`, "success")
await props.onUpdatePlan?.(planId)
setToastMessage(`Plan updated to ${planId}`, "success")
} catch {
// error is handled by the caller
}
}
createEffect(() => {
if (!menuOpen()) return
function openActionDialog(action: "deactivate" | "reactivate") {
setPendingAction(action)
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (target && !menuContainerRef?.contains(target)) {
setMenuOpen(false)
}
function closeActionDialog() {
if (actionBusy()) return
setPendingAction(null)
}
async function confirmAction() {
const action = pendingAction()
if (!action) return
if (action === "deactivate") {
await props.onDeactivate?.()
} else {
await props.onReactivate?.()
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
})
})
setPendingAction(null)
}
return (
<div class="space-y-6">
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.spaces.coracle.social`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.spaces.coracle.social
</a>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<div class="relative flex-shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
aria-label="Open relay actions"
onClick={() => setMenuOpen((open) => !open)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
</button>
<div
class="absolute right-0 mt-2 w-44 rounded-lg border border-gray-200 bg-white shadow-lg z-10 py-1 origin-top-right transition-all duration-150 ease-out"
classList={{
"opacity-100 scale-100": menuOpen(),
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={r().status === "active" && props.onDeactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onDeactivate?.()
}}
disabled={props.deactivating}
>
{props.deactivating ? "Deactivating..." : "Deactivate"}
</button>
</Show>
<Show when={r().status === "inactive" && props.onReactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onReactivate?.()
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<RelayCardHeader
relay={r}
showTenant={props.showTenant}
tenantProfile={tenantProfile}
editHref={props.editHref}
deactivating={props.deactivating}
reactivating={props.reactivating}
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
/>
<hr class="border-gray-200" />
<DetailSection title="Policy">
<ToggleField label="Public join">
<ToggleButton
enabled={flag(r().policy_public_join, false)}
enabled={flagToBool(r().policy_public_join, false)}
onToggle={props.onTogglePublicJoin}
/>
</ToggleField>
<ToggleField label="Strip signatures">
<ToggleButton
enabled={flag(r().policy_strip_signatures, false)}
enabled={flagToBool(r().policy_strip_signatures, false)}
onToggle={props.onToggleStripSignatures}
/>
</ToggleField>
@@ -232,79 +158,43 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<DetailSection title="Features">
<ToggleField label="Rooms">
<ToggleButton
enabled={flag(r().groups_enabled, true)}
enabled={flagToBool(r().groups_enabled, true)}
onToggle={props.onToggleGroups}
/>
</ToggleField>
<ToggleField label="Management API">
<ToggleButton
enabled={flag(r().management_enabled, true)}
enabled={flagToBool(r().management_enabled, true)}
onToggle={props.onToggleManagement}
/>
</ToggleField>
<ToggleField label="Push notifications">
<ToggleButton
enabled={flag(r().push_enabled, true)}
enabled={flagToBool(r().push_enabled, true)}
onToggle={props.onTogglePushNotifications}
/>
</ToggleField>
<ToggleField label="Media storage">
<Show
when={!planLimited()}
fallback={
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().blossom_enabled, false)} onToggle={props.onToggleMediaStorage} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={flag(r().blossom_enabled, true)}
onToggle={props.onToggleMediaStorage}
/>
</Show>
<PlanGatedToggle
enabled={flagToBool(r().blossom_enabled, true)}
fallbackEnabled={flagToBool(r().blossom_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleMediaStorage}
/>
</ToggleField>
<ToggleField label="LiveKit support">
<Show
when={!planLimited()}
fallback={
<Show when={showPlanActions()} fallback={<ToggleButton enabled={flag(r().livekit_enabled, false)} onToggle={props.onToggleLivekitSupport} />}>
<Show
when={props.onUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={flag(r().livekit_enabled, true)}
onToggle={props.onToggleLivekitSupport}
/>
</Show>
<PlanGatedToggle
enabled={flagToBool(r().livekit_enabled, true)}
fallbackEnabled={flagToBool(r().livekit_enabled, false)}
planLimited={planLimited()}
showPlanActions={showPlanActions()}
canUpdatePlan={!!props.onUpdatePlan}
editHref={props.editHref}
onToggle={props.onToggleLivekitSupport}
/>
</ToggleField>
</DetailSection>
@@ -317,11 +207,6 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
<Field label="Member limit">
<span class="text-gray-900">{memberLimitLabel()}</span>
</Field>
<Show when={props.showTenant}>
<Field label="Tenant">
<span class="font-mono text-xs break-all">{r().tenant}</span>
</Field>
</Show>
</MembershipSection>
<Show when={showPlanActions()}>
@@ -332,20 +217,33 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
when={props.onUpdatePlan}
fallback={
<Field label="Current plan">
<span class="capitalize text-gray-900">{r().plan}</span>
<span class="capitalize text-gray-900">{r().plan_id}</span>
</Field>
}
>
<div class="lg:col-span-2 space-y-4">
<PricingTable
selectable
selectedPlan={plan()}
onSelect={changePlan}
selectedPlanId={planId()}
onSelect={changePlanId}
/>
</div>
</Show>
</DetailSection>
</Show>
<ConfirmDialog
open={pendingAction() !== null}
title={confirmTitle()}
description={confirmDescription()}
details={confirmDetails()}
confirmLabel={confirmLabel()}
busyLabel={confirmBusyLabel()}
busy={actionBusy()}
tone={confirmTone()}
onConfirm={confirmAction}
onClose={closeActionDialog}
/>
</div>
)
}
+43 -30
View File
@@ -1,22 +1,27 @@
import { createEffect, createMemo, createSignal, For } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import type { Relay } from "@/lib/hooks"
import { slugify } from "@/lib/slugify"
import { setToastMessage } from "@/components/Toast"
import { RELAY_DOMAIN, validateSubdomainLabel } from "@/lib/subdomain"
import { setToastMessage } from "@/lib/state"
import { plans } from "@/lib/state"
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan">
export type RelayFormValues = Pick<Relay, "info_name" | "subdomain" | "info_icon" | "info_description" | "plan_id">
type RelayFormProps = {
initialValues?: Partial<RelayFormValues>
syncSubdomainWithName?: boolean
// The plan can't be changed by editing a relay (only at creation or via the
// detail card), so the edit form hides the selector by passing false here.
showPlanSelector?: boolean
onSubmit: (values: RelayFormValues) => Promise<void> | void
submitLabel: string
submittingLabel: string
}
export default function RelayForm(props: RelayFormProps) {
const defaultPlanId = createMemo(() => props.initialValues?.plan ?? plans()[0]?.id ?? "free")
const [plan, setPlan] = createSignal(defaultPlanId())
const showPlanSelector = () => props.showPlanSelector ?? true
const defaultPlanId = createMemo(() => props.initialValues?.plan_id ?? plans()[0]?.id ?? "free")
const [planId, setPlanId] = createSignal(defaultPlanId())
const [name, setName] = createSignal(props.initialValues?.info_name ?? "")
const [subdomain, setSubdomain] = createSignal(props.initialValues?.subdomain ?? "")
const [icon, setIcon] = createSignal(props.initialValues?.info_icon ?? "")
@@ -26,17 +31,23 @@ export default function RelayForm(props: RelayFormProps) {
async function handleSubmit(e: Event) {
e.preventDefault()
if (!plan()) {
if (showPlanSelector() && !planId()) {
setToastMessage("Please select a plan")
return
}
const subdomainError = validateSubdomainLabel(subdomain())
if (subdomainError) {
setToastMessage(subdomainError)
return
}
setToastMessage("")
setSubmitting(true)
try {
await props.onSubmit({
plan: plan(),
plan_id: planId(),
info_name: name(),
subdomain: subdomain(),
info_icon: icon(),
@@ -49,7 +60,7 @@ export default function RelayForm(props: RelayFormProps) {
}
}
createEffect(() => setPlan(defaultPlanId()))
createEffect(() => setPlanId(defaultPlanId()))
createEffect(() => {
if (props.syncSubdomainWithName) {
@@ -77,7 +88,7 @@ export default function RelayForm(props: RelayFormProps) {
required
class="flex-1 px-3 py-2"
/>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">.spaces.coracle.social</span>
<span class="px-3 py-2 bg-gray-50 text-gray-500 text-sm border-l border-gray-300">.{RELAY_DOMAIN}</span>
</div>
</div>
<div>
@@ -98,28 +109,30 @@ export default function RelayForm(props: RelayFormProps) {
class="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
<For each={plans()}>
{(p) => (
<button
type="button"
onClick={() => setPlan(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${plan() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
</div>
</button>
)}
</For>
<Show when={showPlanSelector()}>
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">Plan</label>
<div class="grid grid-cols-3 gap-3">
<For each={plans()}>
{(p) => (
<button
type="button"
onClick={() => setPlanId(p.id)}
class={`border-2 rounded-xl p-4 text-left transition-colors ${planId() === p.id ? "border-blue-600 bg-blue-50" : "border-gray-200 hover:border-gray-300"}`}
>
<div class="font-bold text-gray-900">{p.name}</div>
<div class="text-sm text-gray-500 mt-1">
{p.amount === 0 ? "Free" : `$${p.amount / 100}/mo`}
</div>
<div class="text-xs text-gray-500 mt-2">
{p.members === null ? "Unlimited members" : `Up to ${p.members} members`}
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
<button
type="submit"
disabled={submitting()}
+27 -6
View File
@@ -1,6 +1,10 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import { useProfileMetadata } from "@/lib/hooks"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
type RelayListItemProps = {
relay: Relay
@@ -9,16 +13,33 @@ type RelayListItemProps = {
}
export default function RelayListItem(props: RelayListItemProps) {
// Resolve the owning tenant's profile from the event store. The list that
// passes `showTenant` is responsible for priming these profiles in one batch,
// so this subscription does not prime on its own.
const metadata = useProfileMetadata(() => (props.showTenant ? props.relay.tenant_pubkey : undefined), { prime: false })
return (
<li>
<A href={props.href} class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<div>
<div class="min-w-0">
<p class="font-medium text-gray-900">{props.relay.info_name || props.relay.subdomain}</p>
<p class="text-xs text-gray-500">{props.relay.subdomain}.spaces.coracle.social</p>
{props.showTenant && (
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)}
<p class="text-xs text-gray-500">{props.relay.subdomain}.{RELAY_DOMAIN}</p>
<Show when={props.showTenant}>
<div class="mt-1.5 flex items-center gap-2">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-5 w-5 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || props.relay.tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-5 w-5 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-xs text-gray-500 truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(props.relay.tenant_pubkey)}</span>
</div>
</Show>
</div>
<Show
when={props.relay.sync_error}
@@ -28,7 +49,7 @@ export default function RelayListItem(props: RelayListItemProps) {
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
Failed to sync
</span>
</Show>
</div>
+1 -10
View File
@@ -1,14 +1,5 @@
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
type ToastVariant = "error" | "success"
const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
import { toastMessage, toastVariant, setToastMessage } from "@/lib/state"
export default function Toast() {
const [visible, setVisible] = createSignal(false)
@@ -0,0 +1,64 @@
import { Show } from "solid-js"
import { formatUsd } from "@/lib/format"
// Presentational invoice/draft row for the Payment History list. Status label,
// style, and period label are computed by the parent (Account.tsx) and passed in;
// PDF/pay actions are surfaced as callbacks. Props are reactive only when read
// lazily, so access props.* inside JSX, never destructure at the top.
type InvoiceListItemProps = {
amount: number
statusLabel: string
statusStyle: string
periodLabel: string
method?: string
isDraft?: boolean
isOpen?: boolean
onPay?: () => void
onPrintPdf: () => void
printing: boolean
}
export default function InvoiceListItem(props: InvoiceListItemProps) {
return (
<li class={`rounded-lg border p-4 text-sm ${props.isDraft ? "border-dashed border-gray-300" : "border-gray-200"}`}>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{formatUsd(props.amount)}</span>
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${props.statusStyle}`}>
{props.statusLabel}
</span>
<Show when={props.method}>
<span class="text-xs text-gray-500">· paid via {props.method}</span>
</Show>
</div>
<Show when={props.periodLabel}>
<p class="text-xs text-gray-500 mt-0.5">{props.periodLabel}</p>
</Show>
<Show when={props.isDraft}>
<p class="text-xs text-gray-400 mt-1">Charges accruing this period. You'll be invoiced once a balance is due.</p>
</Show>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Show when={props.isOpen}>
<button
type="button"
onClick={() => props.onPay?.()}
class="py-1 px-3 bg-blue-600 text-white text-xs font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Pay now
</button>
</Show>
<button
type="button"
onClick={props.onPrintPdf}
disabled={props.printing}
class="text-xs font-medium text-gray-500 hover:text-gray-800 underline disabled:opacity-50"
>
PDF
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,53 @@
import { Show } from "solid-js"
import type { PaymentMethodState } from "@/lib/paymentMethod"
// Style/label lookups for a payment method's state, co-located with the row that
// consumes them so any future remodel of PaymentMethodState is a single-file
// change.
const methodStatusStyles: Record<PaymentMethodState["kind"], string> = {
not_set_up: "bg-gray-100 text-gray-500 border-gray-200",
ok: "bg-green-50 text-green-700 border-green-200",
error: "bg-red-50 text-red-700 border-red-200",
}
const methodStatusLabels: Record<PaymentMethodState["kind"], string> = {
not_set_up: "not set up",
ok: "ok",
error: "error",
}
// Presentational payment-method list row (title, optional error line, status
// badge, Set up/Update CTA). Props are reactive only when read lazily, so access
// props.* inside JSX, never destructure at the top.
type PaymentMethodRowProps = {
title: string
state: PaymentMethodState
onAction: () => void
}
export default function PaymentMethodRow(props: PaymentMethodRowProps) {
return (
<li class="rounded-lg border border-gray-200 p-4">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900">{props.title}</p>
<Show when={props.state.kind === "error"}>
<p class="text-xs text-red-600 mt-0.5 break-words">{(props.state as { message: string }).message}</p>
</Show>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${methodStatusStyles[props.state.kind]}`}>
{methodStatusLabels[props.state.kind]}
</span>
<button
type="button"
onClick={props.onAction}
class="text-sm font-medium text-blue-600 hover:text-blue-700 disabled:opacity-50"
>
{props.state.kind === "not_set_up" ? "Set up" : "Update"}
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,94 @@
import { Show } from "solid-js"
type KeyTab = "plaintext" | "encrypted"
// Presentational key-login panel. The actual login (loginWithKeyMaterial) stays
// in Login.tsx and is invoked via onSubmit; preventDefault is handled here so the
// parent only supplies the async login. Props are reactive only when read lazily,
// so access props.* inside JSX, never destructure signal-bearing props at the top.
type LoginKeyScreenProps = {
keyTab: () => KeyTab
setKeyTab: (tab: KeyTab) => void
nsecValue: () => string
setNsecValue: (value: string) => void
ncryptsecValue: () => string
setNcryptsecValue: (value: string) => void
password: () => string
setPassword: (value: string) => void
loading: () => boolean
onBack: () => void
onSubmit: () => void
}
export default function LoginKeyScreen(props: LoginKeyScreenProps) {
return (
<>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={props.onBack}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with key</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.keyTab() === "plaintext" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setKeyTab("plaintext")}
>
Plaintext
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.keyTab() === "encrypted" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setKeyTab("encrypted")}
>
Encrypted
</button>
</div>
<form
class="space-y-3"
onSubmit={(e) => {
e.preventDefault()
props.onSubmit()
}}
>
<Show when={props.keyTab() === "plaintext"}>
<input
value={props.nsecValue()}
onInput={(e) => props.setNsecValue(e.currentTarget.value)}
placeholder="nsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<Show when={props.keyTab() === "encrypted"}>
<input
value={props.ncryptsecValue()}
onInput={(e) => props.setNcryptsecValue(e.currentTarget.value)}
placeholder="ncryptsec1..."
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
<input
type="password"
value={props.password()}
onInput={(e) => props.setPassword(e.currentTarget.value)}
placeholder="Password"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</Show>
<button
type="submit"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={props.loading()}
>
Log in
</button>
</form>
</div>
</>
)
}
@@ -0,0 +1,108 @@
import { Show } from "solid-js"
type SignerTab = "qr" | "paste"
// Presentational NIP-46 signer panel. No signers are created here; QR/URI are
// generated in Login.tsx and passed down, and actions are surfaced as callbacks.
// Props are reactive only when read lazily, so access props.* inside JSX, never
// destructure signal-bearing props at the top.
type LoginSignerScreenProps = {
signerTab: () => SignerTab
setSignerTab: (tab: SignerTab) => void
qrDataUrl: () => string
nostrConnectUri: () => string
bunkerUrl: () => string
setBunkerUrl: (value: string) => void
loading: () => boolean
onBack: () => void
onCopyUri: () => void
onScan: () => void
onConnectBunker: () => void
}
export default function LoginSignerScreen(props: LoginSignerScreenProps) {
return (
<>
<button
type="button"
class="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
onClick={props.onBack}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back
</button>
<h2 class="mt-3 text-lg font-semibold text-gray-900">Log in with signer</h2>
<div class="mt-4 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.signerTab() === "qr" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setSignerTab("qr")}
>
Use QR Code
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.signerTab() === "paste" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setSignerTab("paste")}
>
Paste Link
</button>
</div>
<Show when={props.signerTab() === "qr"}>
<Show when={props.qrDataUrl()} fallback={
<div class="flex items-center justify-center py-8 text-sm text-gray-400">
{props.loading() ? "Generating..." : "Loading QR code..."}
</div>
}>
<img src={props.qrDataUrl()} alt="Nostrconnect QR code" class="mx-auto rounded-lg" />
</Show>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={props.nostrConnectUri()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onCopyUri}
title="Copy link"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
</Show>
<Show when={props.signerTab() === "paste"}>
<div class="flex rounded-lg border border-gray-300">
<input
value={props.bunkerUrl()}
onInput={(e) => props.setBunkerUrl(e.currentTarget.value)}
placeholder="bunker://..."
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-sm bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onScan}
title="Scan QR code"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</button>
</div>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={props.loading() || !props.bunkerUrl().trim()}
onClick={props.onConnectBunker}
>
Connect to Signer
</button>
</Show>
</div>
</>
)
}
@@ -0,0 +1,84 @@
import { Show } from "solid-js"
import type { Tab } from "@/lib/loginInput"
// Presentational tab-selection panel for the login screen. All login logic stays
// in Login.tsx; this only renders tabs and surfaces continue callbacks. Props are
// reactive only when read lazily, so access props.* inside JSX, never destructure
// signal-bearing props at the top.
type LoginTabsScreenProps = {
tab: () => Tab
setTab: (tab: Tab) => void
loading: () => boolean
hasExtension: boolean
onContinueExtension: () => void
onContinueSigner: () => void
onContinueKey: () => void
}
export default function LoginTabsScreen(props: LoginTabsScreenProps) {
return (
<>
<h2 class="text-lg font-semibold text-gray-900">Log in / Sign up</h2>
<p class="mt-2 text-xs text-gray-500">
Use any Nostr signer method. New users are automatically onboarded.
</p>
<div class="mt-6 space-y-4">
<div class="flex gap-2 border border-gray-200 rounded-lg p-1">
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "nip07" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("nip07")}
disabled={!props.hasExtension}
>
Extension
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "nip46" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("nip46")}
>
Signer
</button>
<button
type="button"
class={`flex-1 rounded-md px-3 py-2 text-sm ${props.tab() === "key" ? "bg-gray-900 text-white" : "text-gray-700"}`}
onClick={() => props.setTab("key")}
>
Key
</button>
</div>
<Show when={props.tab() === "nip07"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium disabled:opacity-50"
disabled={!props.hasExtension || props.loading()}
onClick={props.onContinueExtension}
>
{props.loading() ? "Connecting..." : <>Continue with extension <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg></>}
</button>
</Show>
<Show when={props.tab() === "nip46"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={props.onContinueSigner}
>
Continue with signer <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
<Show when={props.tab() === "key"}>
<button
type="button"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium"
onClick={props.onContinueKey}
>
Continue with key <svg class="inline-block w-4 h-4 ml-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</Show>
</div>
</>
)
}
@@ -0,0 +1,29 @@
import { Show } from "solid-js"
// Presentational scanner overlay chrome. The <video> ref is owned by the parent
// via the videoRef setter so the QrScanner instance and its lifecycle stay in
// Login.tsx (openScanner waits a microtask for this element to mount). Props are
// reactive only when read lazily, so access props.* inside JSX.
type QrScannerOverlayProps = {
open: boolean
onClose: () => void
videoRef: (el: HTMLVideoElement) => void
}
export default function QrScannerOverlay(props: QrScannerOverlayProps) {
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={props.onClose}>
<div class="relative w-full max-w-sm rounded-2xl bg-white p-4 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900">Scan QR Code</h3>
<button type="button" class="text-gray-400 hover:text-gray-700" onClick={props.onClose}>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<video ref={props.videoRef} class="w-full rounded-lg" />
</div>
</div>
</Show>
)
}
@@ -0,0 +1,36 @@
import { For, Show, createMemo } from "solid-js"
import type { InvoiceItem } from "@/lib/api"
import { formatUsd } from "@/lib/format"
const MAX_VISIBLE_ITEMS = 8
// Presentational "On this invoice" line-items block. The createResource that
// fetches items stays in PaymentDialog; this only renders the parsed list. Props
// are reactive only when read lazily, so access props.* inside JSX/derivations.
type InvoiceItemsListProps = {
items: InvoiceItem[]
}
export default function InvoiceItemsList(props: InvoiceItemsListProps) {
const visibleItems = createMemo(() => props.items.slice(0, MAX_VISIBLE_ITEMS))
return (
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">On this invoice</p>
<ul class="space-y-1.5">
<For each={visibleItems()}>
{(item) => (
<li class="flex items-center justify-between gap-3 text-sm">
<span class="truncate text-gray-900">{item.description}</span>
<span class="flex-shrink-0 text-xs text-gray-500">{formatUsd(item.amount)}</span>
</li>
)}
</For>
<Show when={props.items.length > MAX_VISIBLE_ITEMS}>
<li class="text-xs text-gray-500">
+ {props.items.length - MAX_VISIBLE_ITEMS} more
</li>
</Show>
</ul>
</div>
)
}
@@ -0,0 +1,66 @@
import { Show } from "solid-js"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
// Presentational lightning payment body: loading/error/ready states, the bolt11
// QR + input, and copy. All fetching/QR generation stays in PaymentDialog and is
// surfaced via accessors/callbacks. Props are reactive only when read lazily, so
// access props.* inside JSX, never destructure signal-bearing props at the top.
type LightningPayBodyProps = {
bolt11Status: () => Bolt11Status
bolt11: () => string
qrDataUrl: () => string
bolt11Error: () => string
onRetry: () => void
onCopy: () => void
}
export default function LightningPayBody(props: LightningPayBodyProps) {
return (
<>
<Show when={props.bolt11Status() === "idle" || props.bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
</Show>
<Show when={props.bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{props.bolt11Error()}</p>
<button
type="button"
onClick={props.onRetry}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Retry
</button>
</div>
</Show>
<Show when={props.bolt11Status() === "ready"}>
<img src={props.qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={props.bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={props.bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={props.onCopy}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
<p class="text-xs text-gray-500 text-center">
Scan this QR code with a Bitcoin Lightning wallet to pay.
</p>
</Show>
</>
)
}
@@ -0,0 +1,48 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import ToggleButton from "@/components/ToggleButton"
// Pure presentational toggle that gates a feature behind plan limits. Props are
// reactive only when read lazily, so access props.* inside JSX/derivations and
// never destructure at the top.
type PlanGatedToggleProps = {
enabled: boolean
fallbackEnabled: boolean
planLimited: boolean
showPlanActions: boolean
canUpdatePlan: boolean
editHref?: string
onToggle?: () => void
}
export default function PlanGatedToggle(props: PlanGatedToggleProps) {
return (
<Show
when={!props.planLimited}
fallback={
<Show when={props.showPlanActions} fallback={<ToggleButton enabled={props.fallbackEnabled} onToggle={props.onToggle} />}>
<Show
when={props.canUpdatePlan}
fallback={
<A
href={props.editHref ?? "#"}
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
>
Upgrade Plan
</A>
}
>
<span class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700">
Update Plan
</span>
</Show>
</Show>
}
>
<ToggleButton
enabled={props.enabled}
onToggle={props.onToggle}
/>
</Show>
)
}
@@ -0,0 +1,177 @@
import { A } from "@solidjs/router"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import { getProfilePicture, type ProfileContent } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import menuDotsIcon from "@/assets/menu-dots-2.svg"
import { shortenPubkey } from "@/lib/pubkey"
import { RELAY_DOMAIN } from "@/lib/subdomain"
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-50 text-green-700 border-green-200",
inactive: "bg-gray-100 text-gray-500 border-gray-200",
}
export function StatusBadge(props: { status: string }) {
const styles = () => STATUS_STYLES[props.status] ?? "bg-gray-100 text-gray-500 border-gray-200"
const label = () => props.status.replace(/_/g, " ")
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{label()}
</span>
)
}
// Presentational header for RelayDetailCard: icon/title/status, the WSS link, the
// optional tenant profile link, the sync_error banner, and the actions dropdown.
// The dropdown owns its own open/close UI state and document listeners; all relay
// mutations are surfaced as callbacks. Props are reactive only when read lazily,
// so access props.* (and accessor props like props.relay()) inside JSX, never
// destructure at the top.
type RelayCardHeaderProps = {
relay: () => Relay
showTenant?: boolean
tenantProfile: () => ProfileContent | undefined
editHref?: string
deactivating?: boolean
reactivating?: boolean
onRequestDeactivate?: () => void
onRequestReactivate?: () => void
}
export default function RelayCardHeader(props: RelayCardHeaderProps) {
const r = () => props.relay()
const metadata = () => props.tenantProfile()
const [menuOpen, setMenuOpen] = createSignal(false)
let menuContainerRef: HTMLDivElement | undefined
createEffect(() => {
if (!menuOpen()) return
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (target && !menuContainerRef?.contains(target)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
onCleanup(() => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
})
})
return (
<>
{/* Header */}
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 min-w-0">
<Show when={r().info_icon}>
<img src={r().info_icon} alt="" class="w-14 h-14 rounded-xl object-cover shrink-0 border border-gray-200" />
</Show>
<div class="min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-gray-900">{r().info_name || r().subdomain}</h1>
<StatusBadge status={r().status} />
</div>
<a
href={`wss://${r().subdomain}.${RELAY_DOMAIN}`}
class="text-sm text-blue-600 hover:underline break-all"
>
wss://{r().subdomain}.{RELAY_DOMAIN}
</a>
<Show when={props.showTenant}>
<A href={`/admin/tenants/${r().tenant_pubkey}`} class="group mt-1.5 flex w-fit items-center gap-2 min-w-0">
<Show
when={getProfilePicture(metadata())}
fallback={
<div class="h-6 w-6 flex-shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-[10px] text-gray-500">
{((metadata()?.name || metadata()?.display_name) || r().tenant_pubkey).slice(0, 1).toUpperCase()}
</div>
}
>
<img src={getProfilePicture(metadata())} alt="" class="h-6 w-6 flex-shrink-0 rounded-full object-cover" />
</Show>
<span class="text-sm text-blue-600 group-hover:underline truncate">{(metadata()?.name || metadata()?.display_name) || shortenPubkey(r().tenant_pubkey)}</span>
</A>
</Show>
<Show when={r().info_description.trim()}>
<p class="mt-2 text-sm text-gray-600">{r().info_description}</p>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onRequestDeactivate || props.onRequestReactivate)}>
<div class="relative shrink-0" ref={menuContainerRef}>
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 hover:bg-gray-50"
aria-label="Open relay actions"
onClick={() => setMenuOpen((open) => !open)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
</button>
<div
class="absolute right-0 mt-2 w-44 rounded-lg border border-gray-200 bg-white shadow-lg z-10 py-1 origin-top-right transition-all duration-150 ease-out"
classList={{
"opacity-100 scale-100": menuOpen(),
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
}}
>
<A
href={props.editHref!}
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => setMenuOpen(false)}
>
Edit Details
</A>
<Show when={r().status === "active" && props.onRequestDeactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onRequestDeactivate?.()
}}
disabled={props.deactivating}
>
{props.deactivating ? "Deactivating..." : "Deactivate"}
</button>
</Show>
<Show when={r().status === "inactive" && props.onRequestReactivate}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 disabled:opacity-50"
onClick={() => {
setMenuOpen(false)
props.onRequestReactivate?.()
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
</div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
</>
)
}
+1
View File
@@ -22,6 +22,7 @@ declare global {
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_RELAY_DOMAIN: string
readonly VITE_PLATFORM_NAME?: string
}
+3
View File
@@ -23,4 +23,7 @@
--color-blue-500: #c18254;
--color-blue-600: #c18254;
--color-blue-700: #a66d46;
--color-blue-800: #8a5a39;
--color-blue-900: #6f4730;
--color-blue-950: #45291a;
}
+122 -22
View File
@@ -35,7 +35,6 @@ export type Plan = {
id: string
name: string
amount: number
stripe_price_id: string | null
members: number | null
blossom: boolean
livekit: boolean
@@ -45,13 +44,11 @@ export type PlanId = string
export type Relay = {
id: string
tenant: string
schema: string
tenant_pubkey: string
subdomain: string
plan: PlanId
plan_id: PlanId
status: string
sync_error: string
stripe_subscription_item_id: string | null
synced: number
info_name: string
info_icon: string
@@ -66,9 +63,9 @@ export type Relay = {
}
export type CreateRelayInput = {
tenant?: string
tenant_pubkey?: string
subdomain: string
plan: string
plan_id: string
info_name?: string
info_icon?: string
info_description?: string
@@ -83,7 +80,7 @@ export type CreateRelayInput = {
export type UpdateRelayInput = {
subdomain?: string
plan?: string
plan_id?: string
info_name?: string
info_icon?: string
info_description?: string
@@ -98,27 +95,79 @@ export type UpdateRelayInput = {
export type Tenant = {
pubkey: string
nwc_url: string
nwc_is_set: boolean
created_at: number
billing_anchor: number | null
stripe_customer_id: string
stripe_subscription_id: string | null
past_due_at: number | null
stripe_payment_method_id: string | null
nwc_error: string | null
stripe_error: string | null
churned_at: number | null
}
// Internal aliases derived from the wire shapes below — pure naming, no payload
// change. InvoiceMethod is the non-null members of Invoice.method; InvoiceStatus
// is the lifecycle status derived from the paid_at/voided_at timestamps.
export type InvoiceMethod = "nwc" | "stripe" | "oob"
export type InvoiceStatus = "open" | "paid" | "void"
export type Invoice = {
id: string
status: string
amount_due: number
currency: string
hosted_invoice_url: string
tenant_pubkey: string
amount: number
period_start: number
period_end: number
created_at: number
paid_at: number | null
voided_at: number | null
method: InvoiceMethod | null
}
export type InvoiceItem = {
id: string
invoice_id: string | null
activity_id: string | null
tenant_pubkey: string
relay_id: string
plan_id: string
amount: number
description: string
created_at: number
voided_at: number | null
}
export type Bolt11 = {
id: string
invoice_id: string
lnbc: string
msats: number
created_at: number
expires_at: number
settled_at: number | null
}
// The backend models an invoice's lifecycle as timestamps rather than a status
// field, so derive the display status from them: paid once paid_at is set, void
// once voided_at is set, otherwise still open.
export function invoiceStatus(invoice: Pick<Invoice, "paid_at" | "voided_at">): InvoiceStatus {
if (invoice.paid_at != null) return "paid"
if (invoice.voided_at != null) return "void"
return "open"
}
// The single invoice autopay collects and the dashboard surfaces as "Pay now":
// the OLDEST open invoice with a positive balance, matching the backend's
// dunning order so the UI pays the same one collection targets. undefined when
// nothing is due. Canonical pick shared by useBillingStatus and autopayBilling.
export function selectPayableInvoice(invoices: Invoice[]): Invoice | undefined {
return invoices
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
.sort((a, b) => a.created_at - b.created_at)[0]
}
export type Activity = {
id: string
tenant: string
tenant_pubkey: string
created_at: number
activity_type: string
resource_type: string
@@ -145,8 +194,6 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235,
content: "",
created_at: Math.floor(now / 1000),
// Intentional session-style auth: sign the API base URL once, then reuse
// the header briefly to avoid prompting the signer on every request.
tags: [["u", API_URL]],
})
@@ -229,6 +276,26 @@ export function listTenantInvoices(pubkey: string) {
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
}
// Reconcile a tenant's billing: sync the Stripe payment method (picking up a
// card added via the portal), fold billable activity into invoice items, renew
// the current period, and cut an invoice for any outstanding balance. Does not
// attempt payment. Returns the reconciled tenant.
export function reconcileTenant(pubkey: string) {
return callApi<undefined, Tenant>("POST", `/tenants/${pubkey}/reconcile`)
}
// The draft is a synthetic Invoice (id "draft", empty lifecycle fields) summing
// the current period's not-yet-billed items, or null when there's nothing due.
export function getDraftInvoice(pubkey: string) {
return callApi<undefined, Invoice | null>("GET", `/tenants/${pubkey}/invoices/draft`)
}
// The draft's line items, fetched by tenant since its sentinel id has no row in
// the per-invoice items endpoint. Lets the draft render an itemized PDF.
export function listDraftInvoiceItems(pubkey: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/tenants/${pubkey}/invoices/draft/items`)
}
export function updateTenant(pubkey: string, input: UpdateTenantInput) {
return callApi<UpdateTenantInput, Tenant>("PUT", `/tenants/${pubkey}`, input)
}
@@ -237,10 +304,18 @@ export function listRelays() {
return callApi<undefined, Relay[]>("GET", "/relays")
}
export function listInvoices() {
return callApi<undefined, Invoice[]>("GET", "/invoices")
}
export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`)
}
export function listRelayMembers(id: string) {
return callApi<undefined, { members: string[] }>("GET", `/relays/${id}/members`)
}
export function listRelayActivity(id: string) {
return callApi<undefined, { activity: Activity[] }>("GET", `/relays/${id}/activity`)
}
@@ -249,12 +324,37 @@ export function reactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/reactivate`)
}
export function createPortalSession(pubkey: string) {
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session`)
export function createPortalSession(pubkey: string, returnUrl?: string) {
const query = returnUrl ? `?return_url=${encodeURIComponent(returnUrl)}` : ""
return callApi<undefined, { url: string }>("GET", `/tenants/${pubkey}/stripe/session${query}`)
}
export function getInvoiceBolt11(invoiceId: string) {
return callApi<undefined, { bolt11: string }>("GET", `/invoices/${invoiceId}/bolt11`)
// Idempotently create a payable bolt11 for an invoice (reusing a valid existing
// one) and return it. No reconciliation — settlement is detected by
// reconcileInvoice. The lnbc string is the data the QR needs.
export function ensureInvoiceBolt11(invoiceId: string) {
return callApi<undefined, Bolt11>("POST", `/invoices/${invoiceId}/bolt11`)
}
// Open a hosted Stripe Checkout session to pay a single invoice by card,
// reusing a valid pending one. Unlike the off-session charge, Checkout can
// satisfy a 3D Secure challenge. Returns the URL to redirect to; the payment is
// reconciled by reconcileInvoice once the tenant returns (or by the poll). The
// return URL is fixed to the account page server-side.
export function createInvoiceCheckout(invoiceId: string) {
return callApi<undefined, { url: string }>("POST", `/invoices/${invoiceId}/checkout`)
}
// Reconcile and collect an open invoice: ensure a payable bolt11 exists, then
// run the payment cascade (NWC, then an out-of-band Lightning settle, then a
// saved card). Caller-initiated, so no dunning DM and no churn. Returns the
// refreshed invoice (paid_at set once collected).
export function reconcileInvoice(invoiceId: string) {
return callApi<undefined, Invoice>("POST", `/invoices/${invoiceId}/reconcile`)
}
export function listInvoiceItems(invoiceId: string) {
return callApi<undefined, InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
}
export function createRelay(input: CreateRelayInput) {
+140
View File
@@ -0,0 +1,140 @@
import { createMemo } from "solid-js"
import { indexBy } from "@welshman/lib"
import { invoiceStatus, selectPayableInvoice, type Invoice, type Tenant } from "@/lib/api"
import { autopayConfigured, cardState, nwcState } from "@/lib/paymentMethod"
import { autopayBilling, billingDraftInvoice, billingInvoices, billingRelays, billingTenant, plans, refetchBilling } from "@/lib/state"
export type BillingPromptKind = "churned" | "pay_invoice" | "update_method" | "setup_autopay"
export type BillingPrompt = {
kind: BillingPromptKind
severity: "error" | "warn" | "info"
message: string
}
export type BillingStatusSnapshot = {
tenant: Tenant | undefined
openInvoice: Invoice | undefined
hasPaidSubscription: boolean
}
// The single billing read shared by the dashboard shell and the billing page.
// `openInvoice` is the OLDEST open, positive invoice — matching the backend's
// dunning order so the UI pays the same one collection targets.
export function useBillingStatus() {
const tenant = () => billingTenant()
const invoices = () => billingInvoices() ?? []
// The current period's in-progress bill (outstanding items not yet cut into a
// real invoice), or undefined when nothing is due. Shown as a "draft" row.
const draftInvoice = () => billingDraftInvoice() ?? undefined
const openInvoices = createMemo(() =>
invoices().filter((inv) => invoiceStatus(inv) === "open" && inv.amount > 0),
)
// The autopay/dunning target — the same pick autopayBilling collects.
const openInvoice = () => selectPayableInvoice(invoices())
// Amount due: the total of all open invoices.
const balance = () => openInvoices().reduce((sum, inv) => sum + inv.amount, 0)
const hasPaidSubscription = createMemo(() => {
const planById = indexBy((p) => p.id, plans())
return (billingRelays() ?? []).some((relay) => {
const plan = planById.get(relay.plan_id)
return Boolean(plan && plan.amount > 0 && relay.status === "active")
})
})
const loading = () => billingTenant.loading || billingInvoices.loading || billingDraftInvoice.loading
return { tenant, invoices, draftInvoice, balance, openInvoice, hasPaidSubscription, loading, refetch: refetchBilling, autopay: autopayBilling }
}
// Pure priority selector: returns the single highest-priority billing prompt to
// surface, or null. Priority: churned > pay an open invoice > fix a failed method
// > set up autopay. `suppressInline` hides the prompts the create/upgrade inline
// flow already handles (pay_invoice, setup_autopay) while still surfacing churn
// and method errors.
export function activeBillingPrompt(
s: BillingStatusSnapshot,
opts?: { suppressInline?: boolean },
): BillingPrompt | null {
const tenant = s.tenant
if (!tenant) return null
if (tenant.churned_at) {
return {
kind: "churned",
severity: "error",
message:
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
}
}
const hasAutopay = autopayConfigured(tenant)
const nwc = nwcState(tenant)
const card = cardState(tenant)
const methodError = nwc.kind === "error" || card.kind === "error"
const suppressInline = opts?.suppressInline ?? false
// Any open invoice gets a "Pay now" surface, even with autopay configured:
// autopay may not have fired yet or may have failed without setting an error,
// and the user still needs a way to pay manually. Only the inline create/upgrade
// flow (suppressInline) handles its own invoice, so defer to it there.
if (s.openInvoice && !suppressInline) {
return {
kind: "pay_invoice",
severity: "warn",
message: "You have an unpaid invoice. Pay it now to keep your relays running.",
}
}
if (methodError) {
return {
kind: "update_method",
severity: "warn",
message: nwc.kind === "error"
? "Your Lightning wallet couldn't be charged. Update your payment method."
: "Your card couldn't be charged. Update your payment method.",
}
}
if (s.hasPaidSubscription && !hasAutopay && !s.openInvoice && !suppressInline) {
return {
kind: "setup_autopay",
severity: "info",
message: "Set up automatic payments so your subscription renews without interruption.",
}
}
return null
}
export type AccountStatus = "active" | "inactive" | "delinquent"
// Coarse account-health summary for the status badge. Pure function of the same
// snapshot `activeBillingPrompt` consumes, so the badge can never disagree with
// the prompt. Mutually exclusive and total:
// - delinquent: churned_at is set — the ONLY frontend-visible signal of a real
// suspension (churn_tenant is the single place relays are paused). An open
// invoice alone is NOT delinquency: the tenant has a 7-day grace window and
// autopay may simply not have fired yet. Matches the sole severity:"error"
// branch in activeBillingPrompt.
// - active: not churned AND there is paid business to keep running — an active
// paid relay, an open balance, or a configured payment method. A failed method
// (nwc_error/stripe_error) or an unpaid invoice within grace stays "active";
// the per-method rows and the inline prompt carry that detail.
// - inactive: not churned and nothing billable — no paid relay, no balance, no
// method. The brand-new or free-only tenant (typically billing_anchor == null).
export function accountStatus(s: BillingStatusSnapshot): AccountStatus {
const tenant = s.tenant
if (!tenant) return "inactive"
if (tenant.churned_at) return "delinquent"
const hasAutopay = autopayConfigured(tenant)
const hasOpenInvoice = Boolean(s.openInvoice)
if (s.hasPaidSubscription || hasOpenInvoice || hasAutopay) return "active"
return "inactive"
}
+16
View File
@@ -0,0 +1,16 @@
import { setToastMessage } from "@/lib/state"
export async function copyToClipboard(
text: string,
opts?: { successMessage?: string; errorMessage?: string },
): Promise<boolean> {
if (!text) return false
try {
await navigator.clipboard.writeText(text)
setToastMessage(opts?.successMessage ?? "Copied to clipboard", "success")
return true
} catch {
setToastMessage(opts?.errorMessage ?? "Couldn't copy to clipboard")
return false
}
}
+4
View File
@@ -0,0 +1,4 @@
export const formatUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
export const formatPeriod = (startSecs?: number, endSecs?: number) =>
(!startSecs || !endSecs) ? "" : `${new Date(startSecs * 1000).toLocaleDateString()} ${new Date(endSecs * 1000).toLocaleDateString()}`
+107 -34
View File
@@ -1,77 +1,129 @@
import { createEffect, createResource, createSignal, onCleanup } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { uniq } from "@welshman/lib"
import type { EventStore } from "applesauce-core"
import type { RelayPool } from "applesauce-relay"
import { getProfilePicture, type ProfileContent } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable"
import { Relay as NostrRelay, RelayManagement } from "applesauce-relay"
import { map, of } from "rxjs"
import {
createRelay,
deactivateRelay,
reactivateRelay,
getInvoice,
getRelay,
getTenant,
invoiceStatus,
listInvoices,
listRelayActivity,
listRelays,
listTenantInvoices,
listTenantRelays,
listTenants,
reconcileTenant,
updateRelay,
updateTenant,
type Activity,
type CreateRelayInput,
type Invoice,
type Relay,
type Tenant,
type UpdateRelayInput,
} from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
import { decidePostPaidFlow, type PaidFlowDecision } from "@/lib/relayPlanFlow"
import { account, eventStore, pool } from "@/lib/state"
import { useNostr } from "@/lib/nostr"
export function useProfilePicture(pubkey: () => string | undefined) {
const [picture, setPicture] = createSignal<string | undefined>()
// Subscribes to the raw ProfileContent for a single pubkey from the event store,
// optionally priming it over the network. Call sites project the fields they need
// (name/display_name/nip05/picture) from the returned ProfileContent. Pass
// { prime: false } when a parent list already batch-primes these profiles.
export function useProfileMetadata(pubkey: () => string | undefined, opts?: { prime?: boolean }) {
// Safe: hooks run inside a component/root reactive scope, so useNostr resolves.
const nostr = useNostr()
const prime = opts?.prime ?? true
const [metadata, setMetadata] = createSignal<ProfileContent | undefined>()
createEffect(() => {
const pk = pubkey()
if (!pk) {
setPicture(undefined)
setMetadata(undefined)
return
}
const profileSub = eventStore.profile(pk).subscribe((profile) => {
setPicture(getProfilePicture(profile))
})
const reqSub = primeProfiles([pk])
const profileSub = nostr.eventStore.profile(pk).subscribe(setMetadata)
const reqSub = prime ? primeProfiles([pk], nostr) : undefined
onCleanup(() => {
profileSub.unsubscribe()
reqSub.unsubscribe()
reqSub?.unsubscribe()
})
})
return picture
return metadata
}
export function primeProfiles(pubkeys: string[]) {
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
// Batch variant of useProfileMetadata: subscribes to a list of pubkeys, priming
// them all in one request, and returns a Record keyed by pubkey. Call sites
// project the fields they need from each ProfileContent.
export function useProfileMetadataMap(pubkeys: () => string[]) {
const nostr = useNostr()
const [metadata, setMetadata] = createSignal<Record<string, ProfileContent | undefined>>({})
createEffect(() => {
const list = pubkeys()
if (!list.length) return
const reqSub = primeProfiles(list, nostr)
const profileSubs = list.map((pubkey) =>
nostr.eventStore.profile(pubkey).subscribe((profile) => {
setMetadata((prev) => ({ ...prev, [pubkey]: profile }))
}),
)
onCleanup(() => {
reqSub.unsubscribe()
for (const sub of profileSubs) sub.unsubscribe()
})
})
return metadata
}
export function useProfilePicture(pubkey: () => string | undefined) {
const md = useProfileMetadata(pubkey)
return () => getProfilePicture(md())
}
// Accepts an optional context so callers inside a reactive scope can thread the
// injected eventStore/pool through; defaults to the module singletons because
// most callers run outside reactive scope (event handlers, plain effects) where
// useNostr() would be invalid.
export function primeProfiles(pubkeys: string[], ctx: { eventStore: EventStore; pool: RelayPool } = { eventStore, pool }) {
const { eventStore: store, pool: relayPool } = ctx
const uniquePubkeys = uniq(pubkeys.filter(Boolean))
if (uniquePubkeys.length === 0) {
return { unsubscribe() {} }
}
const seedRelays = Array.from(pool.relays.keys())
const seedRelays = Array.from(relayPool.relays.keys())
const mailboxSeedSub = seedRelays.length
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
eventStore.add(event)
? relayPool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
store.add(event)
})
: undefined
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
includeMailboxes(eventStore),
includeMailboxes(store),
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
map(createOutboxMap),
)
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") eventStore.add(message)
const profileSub = relayPool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") store.add(message)
})
return {
@@ -97,10 +149,16 @@ export const useAdminTenants = () => createResource(listTenants)
export const useAdminRelays = () => createResource(listRelays)
export const useAdminInvoices = () => createResource(listInvoices)
export const useInvoice = (id: () => string) => createResource(id, getInvoice)
export const useAdminTenant = (pubkey: () => string) => createResource(pubkey, getTenant)
export const useAdminTenantRelays = (pubkey: () => string) => createResource(pubkey, listTenantRelays)
export const useAdminTenantInvoices = (pubkey: () => string) => createResource(pubkey, listTenantInvoices)
export const createRelayForActiveTenant = (input: CreateRelayInput) => {
const defaults = {
info_name: "",
@@ -114,9 +172,9 @@ export const createRelayForActiveTenant = (input: CreateRelayInput) => {
}
const overrides = {
tenant: account()!.pubkey,
blossom_enabled: input.plan === "free" ? 0 : 1,
livekit_enabled: input.plan === "free" ? 0 : 1,
tenant_pubkey: account()!.pubkey,
blossom_enabled: input.plan_id === "free" ? 0 : 1,
livekit_enabled: input.plan_id === "free" ? 0 : 1,
}
return createRelay({...defaults, ...input, ...overrides})
@@ -126,7 +184,7 @@ export const updateActiveTenant = (input: { nwc_url?: string }) => updateTenant(
export const updateRelayById = (id: string, input: UpdateRelayInput) => updateRelay(id, input)
export const updateRelayPlanById = (id: string, plan: string) => updateRelay(id, { plan })
export const updateRelayPlanById = (id: string, plan_id: string) => updateRelay(id, { plan_id })
export const deactivateRelayById = (id: string) => deactivateRelay(id)
@@ -134,17 +192,32 @@ export const reactivateRelayById = (id: string) => reactivateRelay(id)
export async function tenantNeedsPaymentSetup(): Promise<boolean> {
const tenant = await getTenant(account()!.pubkey)
return !tenant.nwc_url && !tenant.stripe_subscription_id
return !autopayConfigured(tenant)
}
export async function getRelayMembers(url: string) {
const management = new RelayManagement(new NostrRelay(url), account()!.signer)
try {
return await management.listAllowedPubkeys()
} catch {
return []
}
export async function getLatestOpenInvoice(): Promise<Invoice | null> {
const invoices = await listTenantInvoices(account()!.pubkey)
const open = invoices
.filter(inv => invoiceStatus(inv) === "open" && inv.amount > 0)
.sort((a, b) => b.period_start - a.period_start)
return open[0] ?? null
}
export type { Activity, Relay, Tenant }
// Resolve what to do after a paid create/upgrade succeeds: a tenant that already
// has autopay configured just navigates, otherwise we surface the freshly
// materialized open invoice to pay directly (or fall back to payment setup when
// none is available). Shared by RelayNew, Home's signup-and-create path, and the
// plan-upgrade toggle so the post-paid ladder stays identical across all three.
export async function resolvePostPaidFlow(): Promise<PaidFlowDecision> {
const pubkey = account()!.pubkey
// The reads below are pure GETs now, so explicitly materialize the just-created
// invoice and pick up any portal-added card before deciding the post-paid ladder.
await reconcileTenant(pubkey)
const needsSetup = await tenantNeedsPaymentSetup()
const invoice = needsSetup ? await getLatestOpenInvoice() : null
return decidePostPaidFlow({ needsSetup, invoice })
}
export type { Activity, Invoice, Relay, Tenant }
export type { ProfileContent }
+56
View File
@@ -0,0 +1,56 @@
// Pure decision module for Login's input handling. No signers are constructed
// here (that's effectful/async and stays in the component) — only the input
// normalization, the key-material validation ladder, and the initial-tab choice.
// Keeping these pure makes them testable independently of the DOM/signer stack.
export type Tab = "nip07" | "nip46" | "key"
// Normalize a pasted signer link into a bunker:// URI. nostrconnect:// links are
// rewritten to the equivalent bunker:// form; anything else is passed through
// trimmed. Pure string transform.
export function normalizeBunkerUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ""
if (trimmed.startsWith("nostrconnect://")) {
const url = new URL(trimmed)
const remote = url.host || url.pathname.replace(/^\/+/, "")
const relays = url.searchParams.getAll("relay")
const secret = url.searchParams.get("secret")
const params = new URLSearchParams()
for (const relay of relays) params.append("relay", relay)
if (secret) params.set("secret", secret)
return `bunker://${remote}?${params.toString()}`
}
return trimmed
}
// Discriminated plan for key-material login, encoding the validation ladder:
// an ncryptsec requires a password; otherwise an nsec is required; otherwise it's
// an error. The component constructs the actual signer/account on the data
// branches and throws on the error branch.
export type KeyLoginPlan =
| { kind: "ncryptsec"; ncryptsec: string; password: string }
| { kind: "nsec"; key: string }
| { kind: "error"; message: string }
export function decideKeyLogin(input: { ncryptsec: string; nsec: string; password: string }): KeyLoginPlan {
const ncryptsec = input.ncryptsec.trim()
if (ncryptsec) {
if (!input.password.trim()) {
return { kind: "error", message: "Password is required for ncryptsec" }
}
return { kind: "ncryptsec", ncryptsec, password: input.password }
}
const key = input.nsec.trim()
if (!key) return { kind: "error", message: "Enter an nsec or ncryptsec key" }
return { kind: "nsec", key }
}
// Default login tab: prefer the extension when one is present, otherwise the
// remote signer tab.
export function initialLoginTab(hasExtension: boolean): Tab {
return hasExtension ? "nip07" : "nip46"
}
+35
View File
@@ -0,0 +1,35 @@
import { createComponent, createContext, useContext } from "solid-js"
import type { JSX } from "solid-js"
import type { IAccount } from "applesauce-accounts"
import type { EventStore } from "applesauce-core"
import type { RelayPool } from "applesauce-relay"
import { account, eventStore, pool } from "@/lib/state"
// A single lightweight DI seam for the app's nostr singletons. eventStore and
// pool are genuine app-lifetime singletons; account is a reactive signal. This
// context lets call sites reach them without importing the module globals
// directly, while falling back to the live singletons when no Provider is
// mounted — so production wiring stays byte-for-byte identical and the seam
// only matters for tests/alternate mounts.
export type NostrContext = {
account: () => IAccount | undefined
eventStore: EventStore
pool: RelayPool
}
const NostrContextImpl = createContext<NostrContext>()
export function useNostr(): NostrContext {
return useContext(NostrContextImpl) ?? { account, eventStore, pool }
}
// Authored without JSX so this stays a plain .ts module. createComponent renders
// the context Provider with the given value, wrapping the children.
export function NostrProvider(props: { value: NostrContext; children?: JSX.Element }): JSX.Element {
return createComponent(NostrContextImpl.Provider, {
value: props.value,
get children() {
return props.children
},
})
}
+43
View File
@@ -0,0 +1,43 @@
import type { InvoiceMethod, Tenant } from "@/lib/api"
// A discriminated view of a single payment method's state, derived AT the
// boundary from the raw tenant fields (nwc_is_set/stripe_payment_method_id and
// the matching *_error). This replaces the ad-hoc per-page MethodState objects
// and the scattered nwc/stripe boolean expressions so the surfaces can't drift.
// No new wire data is emitted: these read the same raw fields the API returns.
export type PaymentMethodState =
| { kind: "not_set_up" }
| { kind: "ok" }
| { kind: "error"; message: string }
export function nwcState(t: Pick<Tenant, "nwc_is_set" | "nwc_error">): PaymentMethodState {
if (!t.nwc_is_set) return { kind: "not_set_up" }
if (t.nwc_error) return { kind: "error", message: t.nwc_error }
return { kind: "ok" }
}
export function cardState(t: Pick<Tenant, "stripe_payment_method_id" | "stripe_error">): PaymentMethodState {
if (!t.stripe_payment_method_id) return { kind: "not_set_up" }
// Don't surface Stripe's raw decline/error text to the tenant (it can be noisy
// or sensitive); show a generic message. The detail stays on the tenant record
// for admins (see AdminTenantDetail).
if (t.stripe_error) return { kind: "error", message: "Payment failed" }
return { kind: "ok" }
}
// True when the tenant has any usable automatic payment method on file.
export function autopayConfigured(
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
): boolean {
return t.nwc_is_set || Boolean(t.stripe_payment_method_id)
}
const METHOD_LABELS: Record<InvoiceMethod, string> = {
nwc: "Lightning",
stripe: "Card",
oob: "Lightning",
}
export function methodLabel(m: InvoiceMethod): string {
return METHOD_LABELS[m]
}

Some files were not shown because too many files have changed in this diff Show More