Compare commits

...

67 Commits

Author SHA1 Message Date
Jon Staab 4a5bd4d563 Add contact option on main pricing page
Docker / build-and-push-image (push) Successful in 1m35s
2026-06-17 15:03:23 -07:00
Jon Staab 7750a5e552 Put host in the relay stuct
Docker / build-and-push-image (push) Successful in 1m39s
2026-06-16 09:04:17 -07:00
Jon Staab 326fe9d2e1 Add migration to fix syncing imported relays
Docker / build-and-push-image (push) Successful in 1m35s
2026-06-12 14:20:14 -07:00
Jon Staab 90f5a55269 Add custom domain support
Docker / build-and-push-image (push) Successful in 1m41s
2026-06-12 13:31:40 -07:00
Jon Staab bd3217f43d Show pubkey copy things
Docker / build-and-push-image (push) Successful in 35s
2026-06-12 13:01:10 -07:00
Jon Staab 5587d40688 Show plan on relay detail header
Docker / build-and-push-image (push) Successful in 34s
2026-06-11 17:50:55 -07:00
Jon Staab ada9e10570 Show plan on the relay list
Docker / build-and-push-image (push) Successful in 36s
2026-06-11 17:44:35 -07:00
Jon Staab 0fc7c706f0 Add build cache, remove arm build target
Docker / build-and-push-image (push) Successful in 5m26s
2026-06-11 16:47:06 -07:00
Jon Staab a34f5ec41d Split out migration
Docker / build-and-push-image (push) Successful in 1h8m33s
2026-06-11 16:23:08 -07:00
Jon Staab 4ebc9fe61b Add license
Docker / build-and-push-image (push) Successful in 1h6m53s
2026-06-08 10:47:43 -07:00
Jon Staab b5f3efc775 Fix a few bugs
Docker / build-and-push-image (push) Successful in 1h6m37s
2026-06-05 11:31:10 -07:00
Jon Staab 791a4fcb70 Fix relay link, add admin navigation item on mobile
Docker / build-and-push-image (push) Successful in 1h6m43s
2026-06-04 16:00:19 -07:00
Jon Staab 28d1d164f1 Bring back chachi
Docker / build-and-push-image (push) Successful in 1h6m45s
2026-06-03 20:50:20 -07:00
Jon Staab 1097a5eba3 Add link to home page at the top of the sidebar
Docker / build-and-push-image (push) Successful in 1h8m8s
2026-06-03 17:10:38 -07:00
Jon Staab e3083304d0 Disable logging when serving frontend
Docker / build-and-push-image (push) Has been cancelled
2026-06-03 17:07:53 -07:00
Jon Staab 451264106a Make logo customizable
Docker / build-and-push-image (push) Has been cancelled
2026-06-03 16:46:20 -07:00
Jon Staab 96e2fcda49 Use cargo chef to speed up builds 2026-06-03 16:27:56 -07:00
Jon Staab 73cad3a153 Tweak docs 2026-06-03 15:39:56 -07:00
Jon Staab 5f8b08e02c Clean up the dockerfile a bit
Docker / build-and-push-image (push) Successful in 52m10s
2026-06-03 15:32:48 -07:00
Jon Staab 43eaad1621 Differentiate checkout id/session id
Docker / build-and-push-image (push) Successful in 52m8s
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
Docker / build-and-push-image (push) Successful in 50m58s
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
Docker / build-and-push-image (push) Successful in 51m48s
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
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
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
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-06-01 13:31:00 -07:00
Jon Staab e4e0172972 Add agents stuff
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
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
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-05-25 16:15:00 -07:00
Jon Staab acf7ae8e0a Remove comments from env template
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 0s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 1s
2026-05-25 14:47:52 -07:00
Jon Staab ebada70c0d Fix cors
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 1s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
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
Docker / build-and-push-image (backend, backend, coracle/caravel-backend) (push) Failing after 1s
Docker / build-and-push-image (frontend, frontend, coracle/caravel-frontend) (push) Failing after 0s
2026-05-25 11:09:24 -07:00
Jon Staab 384ddbd439 Sync frontend and backend 2026-05-22 11:03:55 -07:00
135 changed files with 8494 additions and 4423 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
+6 -13
View File
@@ -6,6 +6,7 @@ on:
env:
REGISTRY: gitea.coracle.social
IMAGE: coracle/caravel
jobs:
build-and-push-image:
@@ -14,16 +15,6 @@ jobs:
contents: read
packages: write
strategy:
matrix:
include:
- component: frontend
image: coracle/caravel-frontend
context: frontend
- component: backend
image: coracle/caravel-backend
context: backend
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -39,7 +30,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ matrix.image }}
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
@@ -52,8 +43,10 @@ jobs:
id: push
uses: docker/build-push-action@v5
with:
context: ${{ matrix.context }}
context: .
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64 #,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true
+2
View File
@@ -4,5 +4,7 @@ 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.
+85
View File
@@ -0,0 +1,85 @@
# syntax=docker/dockerfile:1
# ---------- Build the Rust backend ----------
# cargo-chef caches the compiled dependency graph in its own layer, so a
# source-only change recompiles just our crate instead of every dependency from
# scratch. The recipe is derived solely from Cargo.toml/Cargo.lock, so the
# expensive `cook` layer is reused until the dependency set changes.
# https://github.com/LukeMathWalker/cargo-chef
FROM rust:1.94-bookworm AS chef
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install --locked cargo-chef
# Distill the dependency recipe. Cheap, and re-runs on any source change, but
# yields a stable recipe.json as long as dependencies are unchanged — which is
# what keeps the cook layer below cached.
FROM chef AS planner
COPY backend/Cargo.toml backend/Cargo.lock ./
COPY backend/src ./src
RUN cargo chef prepare --recipe-path recipe.json
# Build (and cache) dependencies from the recipe, then compile the application.
# Must share the chef stage's Rust version for the cached layer to apply.
FROM chef AS backend-build
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
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__ \
VITE_PLATFORM_LOGO=__VITE_PLATFORM_LOGO__
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
COPY --chmod=0755 entrypoint.sh /app/entrypoint.sh
RUN mkdir -p /app/data && chown -R node:node /app/dist /app/data
USER node:node
ENV SERVER_PORT=2892 \
DATABASE_URL=sqlite:///app/data/caravel.db
EXPOSE 2892 3000
CMD ["/app/entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jon Staab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+118 -1
View File
@@ -2,7 +2,124 @@
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 PLATFORM_LOGO=/caravel.png \
-e RELAY_DOMAIN=example.com \
-e APP_URL=https://example.com \
-e ZOOID_API_URL=http://zooid:3334 \
-e SERVER_URL=https://api.example.com \
-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 .
```
### Zooid and TLS
Zooid (the relay engine) should run on its own server with [Caddy](https://caddyserver.com/) as the TLS-terminating reverse proxy. Keeping Zooid separate lets it scale independently and makes custom relay domains work without touching the Caravel host.
**Why Caddy?** Caravel supports tenant-facing custom domains, which require per-domain TLS certificates that are provisioned automatically. Caddy's [on-demand TLS](https://caddyserver.com/docs/automatic-https#on-demand-tls) handles this: it calls a Caravel endpoint before issuing each certificate, so only known domains get one.
#### Zooid server setup
On the Zooid server, create a `Caddyfile`:
```
{
on_demand_tls {
ask http://<caravel-host>:2892/domains/check
interval 2m
burst 5
}
}
:443 {
tls {
on_demand
}
reverse_proxy localhost:3334
}
```
Replace `<caravel-host>` with the hostname or IP of the Caravel server. The `/domains/check` endpoint returns `200` for any subdomain of `RELAY_DOMAIN` and for any tenant custom domain that has been verified, and `404` otherwise — Caddy will only obtain a certificate if it gets a `200`.
Run Caddy and Zooid together, for example with Docker Compose:
```yaml
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: unless-stopped
zooid:
image: gitea.coracle.social/coracle/zooid
environment:
API_HOST: api.zooid.example.com
API_WHITELIST: <hex-pubkey-matching-ROBOT_SECRET>
volumes:
- zooid_data:/app/data
restart: unless-stopped
volumes:
caddy_data:
zooid_data:
```
Point your wildcard DNS record (`*.relay_domain`) at this server's IP. Custom domains are pointed there by tenants via a CNAME to their relay's canonical subdomain; Caravel verifies the CNAME in the background and notifies Zooid once confirmed.
Set `ZOOID_API_URL` in Caravel's environment to the same value as zooid's `API_HOST` value, prefixed with the protocol, e.g. `https://api.zooid.example.com`.
## Local Development
### Prerequisites
+11 -11
View File
@@ -1,18 +1,21 @@
# Server
SERVER_HOST=127.0.0.1
SERVER_URL=http://127.0.0.1:2892
SERVER_PORT=2892
SERVER_ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
SERVER_ADMIN_PUBKEYS= # Comma-separated hex pubkeys with admin access
SERVER_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
SERVER_ADMIN_PUBKEYS=
# 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_WALLET= # Nostr Wallet Connect URL for generating Lightning invoices
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
@@ -24,7 +27,7 @@ LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# Blossom S3 (optional; when region, bucket, access key, and secret are all set, relays with blossom enabled sync with adapter s3 and key_prefix = relay schema)
# Blossom S3
BLOSSOM_S3_ENDPOINT=
BLOSSOM_S3_REGION=
BLOSSOM_S3_BUCKET=
@@ -32,7 +35,4 @@ BLOSSOM_S3_ACCESS_KEY=
BLOSSOM_S3_SECRET_KEY=
# Billing
STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
STRIPE_PRICE_BASIC= # Stripe price ID (price_...) for the Basic plan; required for paid plans
STRIPE_PRICE_GROWTH= # Stripe price ID (price_...) for the Growth plan; required for paid plans
STRIPE_SECRET_KEY=
-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"]
+16 -17
View File
@@ -16,17 +16,16 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w
backend/
migrations/
0001_init.sql
spec/ # Module-by-module design notes
src/
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, stripe)
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
pool.rs # SQLite pool + migrations
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)
@@ -43,11 +42,12 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
| Variable | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain |
| `SERVER_PORT` | API bind port |
| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly |
| `SERVER_PORT` | API bind port; the server binds `0.0.0.0` (all interfaces) |
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
| `APP_URL` | Frontend base URL; used to build links in DMs and invoices |
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths resolve under the compile-time crate dir (`backend/` in dev, `/app` in the Docker image) |
**Robot identity**
@@ -72,7 +72,7 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
| `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 schema) when a relay enables blossom.
**Blossom S3** — sent to zooid as the S3 adapter config (with `key_prefix` = relay id) when a relay enables blossom.
| Variable | Description |
| ----------------------- | --------------------- |
@@ -84,18 +84,15 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
**Billing (Stripe)**
| Variable | Description |
| ----------------------- | ----------------------------------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers |
| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan |
| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan |
| 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
@@ -105,7 +102,6 @@ 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)
@@ -122,16 +118,19 @@ Public exceptions:
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `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 /tenants/:pubkey/stripe/session` — create Stripe customer portal session (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 `SERVER_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.
+104 -36
View File
@@ -1,28 +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,
plan_id TEXT NOT NULL,
status TEXT NOT NULL,
synced INTEGER NOT NULL DEFAULT 0,
sync_error TEXT NOT NULL DEFAULT '',
@@ -36,32 +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)
);
CREATE TABLE IF NOT EXISTS lightning_invoice (
stripe_invoice_id TEXT PRIMARY KEY,
tenant_pubkey TEXT NOT NULL,
bolt11 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'paid')),
paid_method TEXT CHECK (paid_method IN ('nwc', 'manual')),
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (tenant_pubkey) REFERENCES tenant(pubkey)
);
CREATE INDEX IF NOT EXISTS idx_tenant_stripe_customer_id
ON tenant (stripe_customer_id);
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 INDEX IF NOT EXISTS idx_relay_tenant_id
ON relay (tenant, id);
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 INDEX IF NOT EXISTS idx_relay_tenant_status_plan
ON relay (tenant, status, plan);
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 INDEX IF NOT EXISTS idx_activity_resource_type_resource_id_created_at_id
ON activity (resource_type, resource_id, created_at DESC, id DESC);
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 INDEX IF NOT EXISTS idx_lightning_invoice_tenant_pubkey
ON lightning_invoice (tenant_pubkey);
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;
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE relay ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_relay_created ON relay (created_at);
@@ -0,0 +1,2 @@
ALTER TABLE relay ADD COLUMN custom_domain TEXT NOT NULL DEFAULT '';
ALTER TABLE relay ADD COLUMN custom_domain_verified INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,29 @@
-- A manual migration created relays without recording the `complete_relay_sync`
-- activity that `complete_relay_sync` normally emits. Backfill one per synced relay
-- that lacks it, mirroring `insert_activity_tx`: a fresh v4 UUID id, the relay's tenant,
-- a NULL billed_at, and a snapshot of the relay's current plan and status. This
-- activity type is not billable (see `list_billable_activity`), so the NULL
-- billed_at bills no one.
INSERT INTO activity (id, tenant_pubkey, created_at, activity_type, resource_type, resource_id, snapshot)
SELECT
lower(
hex(randomblob(4)) || '-' ||
hex(randomblob(2)) || '-4' ||
substr(hex(randomblob(2)), 2) || '-' ||
substr('89ab', abs(random() % 4) + 1, 1) ||
substr(hex(randomblob(2)), 2) || '-' ||
hex(randomblob(6))
),
relay.tenant_pubkey,
CAST(strftime('%s', 'now') AS INTEGER),
'complete_relay_sync',
'relay',
relay.id,
json_object('resource_type', 'relay', 'plan', relay.plan_id, 'status', relay.status)
FROM relay
WHERE relay.synced = 1
AND NOT EXISTS (
SELECT 1 FROM activity
WHERE activity.resource_id = relay.id
AND activity.activity_type = 'complete_relay_sync'
);
-259
View File
@@ -1,259 +0,0 @@
# `pub struct Api`
Api owns the HTTP interface: the shared application state, the router, NIP-98 authentication, and the authorization helpers. The route handlers themselves live in `crate::routes` (`routes/identity.rs`, `plans.rs`, `tenants.rs`, `relays.rs`, `invoices.rs`, `stripe.rs`) and use the response helpers in `spec/web.md`.
Members:
- `env: Env` - configuration (see `spec/env.md`); supplies the NIP-98 host check, admin pubkeys, encryption, etc.
- `query: Query`
- `command: Command`
- `billing: Billing`
- `stripe: Stripe`
- `robot: Robot`
- `infra: Infra`
Notes:
- Authentication is done using NIP-98, comparing the event's `u` tag to `env.server_host`, not the incoming request URL.
- The shared `Api` is wrapped in an `Arc` and handed to every handler as `State<Arc<Api>>`.
- A handler that requires an authenticated caller takes an `AuthedPubkey` extractor; handlers that omit it are anonymous.
- Each handler is responsible for authorization using `require_admin` or `require_admin_or_tenant`.
- Successful responses are `{ data, code: "ok" }`; error responses are `{ error, code }`, both with an appropriate HTTP status (see `spec/web.md`).
## `pub fn new(query, command, billing, stripe, robot, infra, env: &Env) -> Self`
- Stores the services and a clone of `env`
## `pub fn router(self) -> Router`
- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes
## `pub fn is_admin(&self, pubkey: &str) -> bool`
- Whether `pubkey` is in `env.server_admin_pubkeys`
## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>`
- `Ok` if `authorized_pubkey` is an admin, otherwise a `403`
## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>`
- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403`
## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>`
- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error
## `pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>`
- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error
# Authentication
## `pub struct AuthedPubkey(pub String)`
An axum extractor (`FromRequestParts`) that authenticates a request via NIP-98 and yields the signer's pubkey. Adding it to a handler signature is what enforces "must be authenticated"; on failure the request is rejected with a `401`.
## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError>` / `fn decode_nip98_pubkey(&self, headers) -> Result<String>`
- Parses the `Authorization` header, which must use the `Nostr ` scheme followed by a base64-encoded NIP-98 event
- Decodes and parses the event, requires kind `27235` (`HttpAuth`), and verifies its signature
- Requires the event's `u` tag to contain `env.server_host` (skipped when `server_host` is empty)
- Intentionally does **not** enforce exact request URL/method/query matching, and does **not** validate the `payload` tag/hash, `created_at` freshness window, or a replay nonce/cache
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
- Returns the signer pubkey (hex) when all checks pass; any failure surfaces as a `401`
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Uses `nostr_sdk` functionality where possible.
# Routes
Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`.
--- Identity
## `get_identity` — `GET /identity`
- Authenticated (any signer)
- Side-effect-free: returns `{ pubkey, is_admin }`
- Clients must call `POST /tenants` before any tenant-scoped write
--- Plans
## `list_plans` — `GET /plans`
- No authentication required
- `data` is the list of plans from `query.list_plans`
## `get_plan` — `GET /plans/:id`
- No authentication required
- `data` is the plan matching `id`; `404` `not-found` if it doesn't exist
--- Tenants
## `list_tenants` — `GET /tenants`
- Admin only
- `data` is a list of `TenantResponse` (exposes `nwc_is_set: bool` instead of `nwc_url`)
## `create_tenant` — `POST /tenants`
- Authenticated (any signer); the target pubkey is the auth pubkey, no request body
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
- Otherwise resolve a display name via `robot.fetch_nostr_name` (falling back to the first 8 chars of the pubkey), create a Stripe customer via `stripe.create_customer`, and create the tenant. No subscription is created yet — that happens when the first paid relay is added.
- On a unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- Always returns `200`; `data` is a `TenantResponse`
## `get_tenant` — `GET /tenants/:pubkey`
- Admin or matching tenant
- `data` is a `TenantResponse`
## `update_tenant` — `PUT /tenants/:pubkey`
- Admin or matching tenant
- Accepts an optional `nwc_url`: an empty string clears it, otherwise it is encrypted at rest via `env.encrypt`
- Updates the tenant via `command.update_tenant`
- `data` is the updated `TenantResponse`
## `list_tenant_relays` — `GET /tenants/:pubkey/relays`
- Admin or matching tenant
- `data` is the tenant's relays from `query.list_relays_for_tenant`
--- Relays
## `list_relays` — `GET /relays`
- Admin only
- `data` is all relays from `query.list_relays`
## `get_relay` — `GET /relays/:id`
- `404` `not-found` if the relay doesn't exist; then admin or relay owner
- `data` is the relay
## `list_relay_members` — `GET /relays/:id/members`
- Admin or relay owner
- For unsynced relays (`synced = 0`), returns an empty member list without calling zooid
- For synced relays, proxies the member list from zooid via `infra.list_relay_members`
- `data` is `{ members }`
## `create_relay` — `POST /relays`
- Admin or the `tenant` pubkey in the request body
- Generates the relay `id`/`schema`, validates and normalizes the relay via `prepare_relay`, and creates it via `command.create_relay`
- Duplicate subdomain → `422` `subdomain-exists`
- `data` is the relay; HTTP `201`
## `update_relay` — `PUT /relays/:id`
- `404` if missing; then admin or relay owner
- Applies the provided optional fields, then validates/normalizes via `prepare_relay`
- If the plan changes to one with a finite member limit and the current member count exceeds it, return `422` `member-limit-exceeded`
- Updates via `command.update_relay`; duplicate subdomain → `422` `subdomain-exists`
- `data` is the relay
## `list_relay_activity` — `GET /relays/:id/activity`
- `404` if missing; then admin or relay owner
- `data` is `{ activity }` from `query.list_activity_for_resource`
## `deactivate_relay` — `POST /relays/:id/deactivate`
- `404` if missing; then admin or relay owner
- If status is `delinquent`, return `400` `relay-is-delinquent`; if already `inactive`, return `400` `relay-is-inactive`
- Otherwise `command.deactivate_relay`; `data` is empty
## `reactivate_relay` — `POST /relays/:id/reactivate`
- `404` if missing; then admin or relay owner
- If status is `delinquent`, return `400` `relay-is-delinquent` (a delinquent relay must be resolved through payment, not reactivated by the user); if already `active`, return `400` `relay-is-active`
- Otherwise `command.activate_relay`; `data` is empty
--- Invoices
## `list_tenant_invoices` — `GET /tenants/:pubkey/invoices`
- Admin or matching tenant
- Looks up the tenant, then lists invoices from Stripe by `stripe_customer_id`
- `data` is a list of `StripeInvoice` objects: `{ id, customer, status, amount_due, currency }`
## `get_invoice` — `GET /invoices/:id`
- Fetches the invoice from Stripe (`404` `not-found` if it doesn't exist)
- Looks up the tenant by the invoice's `customer` (`404` if none), then authorizes admin or matching tenant
- Runs `billing.reconcile_invoice` (marks it paid if its bolt11 already settled out of band)
- `data` is the (possibly refreshed) `StripeInvoice`
## `get_lightning_invoice` — `GET /invoices/:id/bolt11`
- Fetches the invoice from Stripe (`404` if it doesn't exist) and the tenant by `customer` (`404` if none), then authorizes admin or matching tenant
- Runs `billing.reconcile_invoice`, then `billing.ensure_lightning_invoice` to get or (re)issue the bolt11 for the invoice's `amount_due`/`currency`
- `data` is the `LightningInvoice` (including its `bolt11`)
--- Stripe portal
## `create_stripe_session` — `GET /tenants/:pubkey/stripe/session`
- Admin or matching tenant; accepts an optional `return_url` query parameter
- Looks up the tenant and creates a Stripe Customer Portal session for its `stripe_customer_id`
- `data` is `{ url }` — the portal session URL
--- Stripe webhook
## `stripe_webhook` — `POST /stripe/webhook`
- No NIP-98 authentication — verified via the `Stripe-Signature` header over the raw body
- Reads the raw body and signature, verifies/parses the event via `stripe.get_webhook_event`, and dispatches to the handlers below
- Returns `200` on success, `400` (`webhook-error`) on verification/parse failure
# Webhook event handlers
Implemented in `routes/stripe.rs`. They translate verified Stripe events into domain actions, looking the tenant up by `stripe_customer_id` and ignoring events whose customer doesn't map to a tenant. Unknown event types are ignored.
## `invoice.created`
Attempts to pay a new subscription invoice (Stripe is pay-in-advance, so this fires immediately when a paid relay is added or a plan is upgraded). Skips `amount_due` of 0. Ensures a `LightningInvoice` exists, then in priority order:
1. **NWC auto-pay**: if the tenant has a `nwc_url`, run `billing.pay_invoice_nwc`. On success, done. On failure, record the error via `command.set_tenant_nwc_error`, log it, summarize it for the eventual DM, and fall through.
2. **Card on file**: if `stripe.has_payment_method`, do nothing — Stripe charges automatically for this attempt.
3. **Manual payment**: send a DM via `robot.send_dm` telling the tenant payment is due (including the summarized NWC error, if any), with a link to the app for manual Lightning payment.
## `invoice.paid`
- If the tenant has `past_due_at` set, clear it (`command.clear_tenant_past_due`) and reactivate each `delinquent` relay on a paid plan via `command.activate_relay`
## `invoice.payment_failed`
- If the tenant doesn't already have `past_due_at` set, set it (`command.set_tenant_past_due`) and DM the tenant that payment failed and their relays may be deactivated if unresolved
## `invoice.overdue`
- Mark every `active` relay on a paid plan `delinquent` (`command.mark_relay_delinquent`) and DM the tenant that their paid relays were deactivated for non-payment
## `customer.subscription.updated`
- If the subscription status is `canceled` or `unpaid`, clear `stripe_subscription_id` (`command.clear_tenant_subscription`) and mark every `active` paid relay `delinquent`
## `customer.subscription.deleted`
- Clear `stripe_subscription_id` (`command.clear_tenant_subscription`)
## `payment_method.attached`
- Retry Stripe collection (`stripe.pay_invoice`) for every `open` invoice with `amount_due > 0`, so invoices that were due before the card was added are charged immediately
# Helpers
## `prepare_relay(api: &Api, relay: Relay) -> Result<Relay, ApiError>`
- Validates `subdomain` against the allowed pattern and a reserved list (`api`, `admin`, `internal`) → `422` `invalid-subdomain`
- Validates that `plan` matches a known plan → `422` `invalid-plan`
- If the relay enables `blossom`/`livekit` but the selected plan doesn't include it → `422` `premium-feature`
- Normalizes the boolean relay flags to sane defaults
# `TenantResponse`
The tenant shape returned by tenant endpoints. Same as `Tenant` but replaces `nwc_url` with `nwc_is_set: bool` (true when a `nwc_url` is stored) and never exposes the stored URL: `{ pubkey, nwc_is_set, nwc_error, created_at, stripe_customer_id, stripe_subscription_id, past_due_at }`.
-96
View File
@@ -1,96 +0,0 @@
# `pub struct Billing`
Billing encapsulates the domain logic for synchronizing a tenant's Stripe subscription with their relays and for collecting Stripe invoices over Lightning (NWC auto-pay and manual payment).
It owns the domain logic only: Stripe REST calls go through `Stripe` (see `spec/stripe.md`), NWC wallet operations through `Wallet` (see `spec/wallet.md`), and fiat → msats conversion through `bitcoin` (see `spec/bitcoin.md`). The Stripe webhook dispatch that calls into `Billing` lives in `spec/api.md`.
Members:
- `stripe: Stripe` - Stripe REST wrapper (see `spec/stripe.md`)
- `wallet: Wallet` - the system NWC wallet, used to issue and look up bolt11 invoices (see `spec/wallet.md`)
- `query: Query`
- `command: Command`
- `env: Env` - used to decrypt a tenant's stored `nwc_url`
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
- Builds `stripe` via `Stripe::new(env)` and `wallet` via `Wallet::from_url(&env.robot_wallet)`
- Panics if `ROBOT_WALLET` is not a valid NWC URL
## `pub async fn start(self)`
- Subscribes to `command.notify.subscribe()`
- Runs a full reconcile (`reconcile_subscriptions("startup")`) before entering the loop
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
- On `Lagged`, runs a full reconcile (`reconcile_subscriptions("lagged")`); on `Closed`, exits
## `async fn reconcile_subscriptions(&self, source: &str)`
- Calls `reconcile_subscription` for every tenant, logging (but not aborting on) per-tenant errors
## `async fn handle_activity(&self, activity: &Activity)`
- On `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, `fail_relay_sync`, or `complete_relay_sync`: resolve the tenant named by the activity (skip if it no longer exists) and call `reconcile_subscription`
## `async fn reconcile_subscription(&self, tenant: &Tenant)`
Reconciles a tenant's single Stripe subscription with the set of relays that should be billed. Only paid (non-free) relays interact with Stripe; free-only tenants have no subscription. Idempotent.
Stripe forbids two subscription items on the same subscription from sharing a price, so billing is modeled as **one subscription item per plan (price), with `quantity` equal to the number of the tenant's `active` relays on that plan**. The relay → item mapping is fully derivable from `relay.plan → plan.stripe_price_id` and the live subscription's items, so nothing is persisted on the relay.
Stripe uses **pay-in-advance** by default: when a subscription is first created, Stripe immediately generates an open invoice for the current period. The `invoice.created` webhook fires shortly after and the webhook handler attempts payment (see `spec/api.md`).
- Build the desired state via `get_quantity_by_price_id`.
- **No relays to bill** (desired state empty): `ensure_subscription_is_inactive` and return.
- Otherwise `ensure_subscription_is_active` to resolve/create the subscription, then `ensure_subscription_items` to sync its items.
## `async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>>`
- For each `active` relay whose plan has a `stripe_price_id`, count relays per price. Returns the price → quantity map.
## `async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>>`
- If the tenant has a `stripe_subscription_id`, fetch it from Stripe (`None` if Stripe 404s)
- If the fetched subscription's status is `canceled`/`incomplete_expired`, call `command.clear_tenant_subscription` and return `None`
## `async fn ensure_subscription_is_active(&self, tenant, quantity_by_price_id) -> Result<StripeSubscription>`
- Returns the existing subscription if there is one
- Otherwise creates a Stripe subscription with the desired items (Stripe rejects an itemless subscription) and saves the id via `command.set_tenant_subscription`
## `async fn ensure_subscription_is_inactive(&self, tenant: &Tenant)`
- If the tenant still has a live subscription, cancel it via Stripe and call `command.clear_tenant_subscription`
## `async fn ensure_subscription_items(&self, subscription, quantity_by_price_id)`
- For each desired `(price, quantity)`: update the matching item's quantity if it differs, otherwise create the item
- Delete any existing item whose price no longer appears in the desired state
## `pub async fn ensure_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, amount_due: i64, currency: &str) -> Result<LightningInvoice>`
- Returns the existing `lightning_invoice` row if it is already `paid`, or still `pending` and not expired
- Otherwise converts `amount_due` to msats via `bitcoin::fiat_to_msats`, issues a fresh bolt11 (1 hour expiry) on the system wallet, and upserts it via `command.insert_lightning_invoice` (re-reading the stored row if the upsert was a no-op because the invoice was already paid)
## `pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()>`
Pays a Lightning invoice from the tenant's own wallet.
- Decrypt the tenant's `nwc_url` (`env.decrypt`) and build a tenant `Wallet`
- Pay `invoice.bolt11` from the tenant wallet
- On success, `settle_invoice(..., "nwc")`
- On a pay error, the payment may still have landed before the response was lost: check `wallet.is_settled(invoice.bolt11)` on the system wallet and `settle_invoice(..., "nwc")` if it settled; otherwise return the pay error
## `pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice>`
Catches an out-of-band Lightning payment we never recorded (e.g. the user paid but the frontend failed to notify us). Meant to run before presenting a payable invoice so we never hand back one that's already been paid.
- If the invoice is not `open`, or has no `lightning_invoice` row, return it unchanged
- If its bolt11 has settled on the system wallet, `settle_invoice(..., "manual")` and return the re-fetched (now paid) Stripe invoice; fall back to the original snapshot if Stripe momentarily 404s
- On any settlement-lookup failure, log and return the invoice unchanged
## `async fn settle_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, method: &str)`
- `command.mark_lightning_invoice_paid(stripe_invoice_id, method)` (first-writer-wins)
- `stripe.pay_invoice_out_of_band(stripe_invoice_id)` (idempotent)
- `command.clear_tenant_nwc_error(tenant_pubkey)`
-11
View File
@@ -1,11 +0,0 @@
# `bitcoin` — fiat ↔ Bitcoin conversion
Free async helpers for pricing fiat amounts in Lightning units against a live BTC spot price. The NWC wallet lives in `spec/wallet.md`; billing orchestration lives in `spec/billing.md`.
## `pub async fn fiat_to_msats(amount_fiat_minor: i64, currency: &str) -> Result<u64>`
Converts a Stripe-style minor-unit fiat amount to millisatoshis using the live BTC spot price for `currency` and Stripe's per-currency decimal exponent (most currencies 2; `JPY`/`KRW`/… 0; `BHD`/`KWD`/… 3).
## `pub async fn get_bitcoin_price(currency: &str) -> Result<f64>`
Returns the current BTC spot price in `currency`, fetched from Coinbase's public spot-price endpoint.
-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:
- Write methods that mutate tenants/relays are atomic and accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`, run inside a single transaction via the `with_activity` helper.
- `insert_activity` builds and returns the `Activity` struct (resolving `tenant` from the resource — directly for `tenant` resources, by looking up `relay.tenant` for `relay` resources — and using `chrono::Utc::now()` for `created_at`).
- After each successful commit, the `Activity` is sent on the broadcast channel.
- The subscription/error/past-due setters and the lightning-invoice writes below intentionally do **not** log activity and write directly to the pool.
## `pub fn new(pool: SqlitePool) -> Self`
- Stores the pool and creates the broadcast channel
## `pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- Creates tenant (writes `pubkey`, `nwc_url`, `created_at`, `stripe_customer_id`), may throw sqlite uniqueness error on pubkey
- Logs activity as `(create_tenant, tenant, pubkey)`
## `pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates the tenant's `nwc_url`
- Logs activity as `(update_tenant, tenant, pubkey)`
## `pub async fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay with status `active` and `synced = 0`, may throw sqlite uniqueness error on subdomain
- Logs activity as `(create_relay, relay, id)`
## `pub async fn update_relay(&self, relay: &Relay) -> Result<()>`
- Updates relay (all mutable fields), resets `synced = 0` so it re-syncs to zooid; may throw sqlite uniqueness error on subdomain
- Logs activity as `(update_relay, relay, id)`
## `pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive` and `synced = 0`
- Logs activity as `(deactivate_relay, relay, id)`
- Used for user/admin-initiated deactivation only
## `pub async fn mark_relay_delinquent(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `delinquent` and `synced = 0`
- Logs activity as `(mark_relay_delinquent, 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 async fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `active` and `synced = 0`
- Logs activity as `(activate_relay, relay, id)`
## `pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()>`
- Sets `synced = 0` and `sync_error` on the relay
- Logs activity as `(fail_relay_sync, relay, id)`
## `pub async fn complete_relay_sync(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 1`, clears `sync_error`
- Logs activity as `(complete_relay_sync, relay, id)`
## `pub async fn set_tenant_subscription(&self, pubkey: &str, stripe_subscription_id: &str) -> Result<()>`
- Sets `stripe_subscription_id` on the tenant
- Does not log activity
## `pub async fn clear_tenant_subscription(&self, pubkey: &str) -> Result<()>`
- Sets `stripe_subscription_id = null` on the tenant
- Does not log activity
## `pub async fn set_tenant_nwc_error(&self, pubkey: &str, error: &str) -> Result<()>`
- Sets `nwc_error` on the tenant
- Does not log activity
## `pub async fn clear_tenant_nwc_error(&self, pubkey: &str) -> Result<()>`
- Sets `nwc_error = null` on the tenant
- Does not log activity
## `pub async fn set_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at` to the current timestamp
- Does not log activity
## `pub async fn clear_tenant_past_due(&self, pubkey: &str) -> Result<()>`
- Sets `past_due_at = null` on the tenant
- Does not log activity
## `pub async fn insert_lightning_invoice(&self, stripe_invoice_id: &str, tenant_pubkey: &str, bolt11: &str, expires_at: i64) -> Result<Option<LightningInvoice>>`
- Upserts the `pending` bolt11 row for a Stripe invoice, returning the resulting row
- On conflict the stored `bolt11`/`expires_at` are replaced (this is how an expired invoice is regenerated), **except** once the invoice is `paid`: the `status = 'pending'` guard makes the update a no-op and `None` is returned so the caller can fall back to reading the settled row
- Does not log activity
## `pub async fn mark_lightning_invoice_paid(&self, stripe_invoice_id: &str, method: &str) -> Result<()>`
- Marks a `pending` invoice `paid`, recording `paid_method = method` (`nwc` or `manual`)
- The `status = 'pending'` guard makes this idempotent and first-writer-wins: a later reconcile won't clobber the method recorded by whoever settled it first
- Does not log activity
-47
View File
@@ -1,47 +0,0 @@
# `pub struct Env`
Env is the application's configuration, loaded once at startup and cloned into every service that needs it (`Api`, `Query`, `Command`, `Billing`, `Infra`, `Robot`, `Stripe`). It is the single source of truth for environment-derived settings, and it also owns the robot nostr key used for signing, NIP-44 encryption, and NIP-98 auth.
Members (all populated from environment variables):
- `server_host: String` - from `SERVER_HOST`; also used for the NIP-98 `u` host check
- `server_port: u16` - from `SERVER_PORT`
- `server_admin_pubkeys: Vec<String>` - admin pubkeys from `SERVER_ADMIN_PUBKEYS`
- `server_allow_origins: Vec<String>` - CORS origins from `SERVER_ALLOW_ORIGINS`
- `database_url: String` - from `DATABASE_URL`
- `robot_name: String` - from `ROBOT_NAME`
- `robot_wallet: String` - the system NWC URL from `ROBOT_WALLET`, used to issue/look up bolt11 invoices
- `robot_picture: String` - from `ROBOT_PICTURE`
- `robot_description: String` - from `ROBOT_DESCRIPTION`
- `robot_outbox_relays: Vec<String>` - from `ROBOT_OUTBOX_RELAYS`
- `robot_indexer_relays: Vec<String>` - from `ROBOT_INDEXER_RELAYS`
- `robot_messaging_relays: Vec<String>` - from `ROBOT_MESSAGING_RELAYS`
- `blossom_s3_region` / `blossom_s3_bucket` / `blossom_s3_endpoint` / `blossom_s3_access_key` / `blossom_s3_secret_key: String` - from the matching `BLOSSOM_S3_*` vars
- `zooid_api_url: String` - from `ZOOID_API_URL`
- `relay_domain: String` - from `RELAY_DOMAIN`
- `livekit_url` / `livekit_api_key` / `livekit_api_secret: String` - from the matching `LIVEKIT_*` vars
- `stripe_secret_key: String` - from `STRIPE_SECRET_KEY`
- `stripe_webhook_secret: String` - from `STRIPE_WEBHOOK_SECRET`
- `stripe_price_basic: String` - Stripe price id for the Basic plan, from `STRIPE_PRICE_BASIC`
- `stripe_price_growth: String` - Stripe price id for the Growth plan, from `STRIPE_PRICE_GROWTH`
- `keys: Keys` - parsed from `ROBOT_SECRET`; used for nostr signing, NIP-44 encryption, and NIP-98 auth
## `pub fn load() -> Self`
- Reads every variable above and panics if any is missing or malformed.
- String vars must be present and non-blank (trimmed).
- The port must parse as a `u16`.
- CSV vars are split on commas, trimmed, and empties dropped; each must contain at least one entry.
- `keys` is parsed from `ROBOT_SECRET` and panics if it is not a valid nostr secret key.
## `pub fn encrypt(&self, plaintext: &str) -> Result<String>`
- NIP-44 (v2) encrypts `plaintext` to the robot's own key. Used to encrypt a tenant's `nwc_url` at rest.
## `pub fn decrypt(&self, ciphertext: &str) -> Result<String>`
- NIP-44 decrypts a value previously produced by `encrypt`.
## `pub async fn make_auth(&self, url: &str, method: HttpMethod) -> Result<String>`
- Builds a NIP-98 `Authorization` header value for an outgoing request to `url` with `method`, signed with `keys`. Used by `Infra` to authenticate requests to zooid.
-59
View File
@@ -1,59 +0,0 @@
# `pub struct Infra`
Infra is a background worker that listens for activity and synchronizes relay configuration to a remote zooid instance.
Members:
- `env: Env` - configuration; supplies `zooid_api_url`, `relay_domain`, the `BLOSSOM_S3_*` and `LIVEKIT_*` settings, and the robot key used to sign requests
- `query: Query`
- `command: Command`
## `pub fn new(query: Query, command: Command, env: &Env) -> Self`
- Stores `query`, `command`, and a clone of `env`
## `pub async fn start(self)`
- Subscribes to `command.notify`
- Runs `reconcile_relay_state("startup")` before entering the loop
- Loops on `rx.recv()`, calling `handle_activity` for each `Activity`
- On `Lagged`, runs `reconcile_relay_state("lagged")`; on `Closed`, exits
## `async fn handle_activity(&self, activity: &Activity)`
- Ignores anything that isn't a `relay` resource with activity type `create_relay`, `update_relay`, `activate_relay`, `deactivate_relay`, or `fail_relay_sync`
- For `fail_relay_sync`, schedules a delayed retry via `schedule_relay_sync_retry`
- Otherwise resolves the relay (skip if gone) and calls `sync_relay`
## `async fn reconcile_relay_state(&self, source: &str)`
- Lists relays still pending sync (`query.list_relays_pending_sync`)
- For each: `sync_relay` immediately if its `sync_error` is empty, otherwise `schedule_relay_sync_retry`
## `async fn schedule_relay_sync_retry(&self, relay_id: &str, source: &str)`
- Counts the relay's consecutive trailing `fail_relay_sync` activities to derive the attempt number
- Computes an exponential backoff (base 30s, doubling, capped at 15 minutes); gives up after `RELAY_SYNC_RETRY_MAX_ATTEMPTS` (6) to avoid infinite retry loops
- Spawns a task that sleeps for the delay, then re-reads the relay and `sync_relay`s it (no-op if the relay is gone)
## `async fn sync_relay(&self, relay: &Relay)`
- Calls `try_sync_relay`; on success `command.complete_relay_sync`, on failure `command.fail_relay_sync` with the error
## `async fn try_sync_relay(&self, relay: &Relay)`
- A relay is "new" only if it has never completed a sync (`synced != 1` and no `complete_relay_sync` activity exists). New relays are created with `POST /relay/:id`; existing relays are updated with `PATCH /relay/:id`.
- A freshly generated `secret` is included only for creation (`POST`), so updates don't rotate relay identity and we never store the secret.
- The body carries relay configuration: `host` (= `subdomain.relay_domain`), `schema`, `inactive` (true when status is `inactive` or `delinquent`), `info` (name/icon/description/pubkey), `policy`, `groups`, `management`, `blossom`, `livekit`, `push`, and hard-coded `roles`.
- When `blossom_enabled`, the blossom section uses `adapter: "s3"` with the `BLOSSOM_S3_*` settings and `s3.key_prefix` set to the relay's `schema`; otherwise it sends `{ "enabled": false }`.
- When `livekit_enabled`, the livekit section carries the `LIVEKIT_*` settings; otherwise `{ "enabled": false }`.
## `pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>>`
- `GET /relay/:id/members` from zooid; returns the `members` array
## `async fn request(&self, method, path, body)`
- Sends an authenticated request to the zooid API at `path` (relative to `env.zooid_api_url`), with a 5s timeout
- Authenticates each request with a NIP-98 header via `env.make_auth`
- Returns the response on 2xx; bails with the status and body text otherwise
-17
View File
@@ -1,17 +0,0 @@
# `async fn main() -> Result<()>`
- Loads `.env` (via `dotenvy`) and configures tracing/logging from the default env filter
- Calls `Env::load()` to read and validate all configuration
- Calls `create_pool(&env.database_url)` to get a `SqlitePool`
- Constructs the services, passing `&env` where needed:
- `Robot::new(&env).await` (publishes the robot's nostr identity)
- `Stripe::new(&env)`
- `Query::new(pool.clone(), &env)`
- `Command::new(pool)`
- `Billing::new(query.clone(), command.clone(), &env)`
- `Infra::new(query.clone(), command.clone(), &env)`
- `Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env)`
- Builds a CORS layer restricted to the parsed `env.server_allow_origins`
- Gets the axum router from `api.router()` and applies the CORS layer
- Spawns `infra.start()` and `billing.start()` as background tasks
- Binds a `TcpListener` on `env.server_host:env.server_port` and calls `axum::serve`
-104
View File
@@ -1,104 +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`
- `mark_relay_delinquent`
- `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's monthly cost in USD minor units (cents); e.g. `500` for $5/mo
- `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; stored encrypted at rest using NIP-44 with the robot key (`ROBOT_SECRET`, via `Env::encrypt`); never serialized to API responses — tenant API endpoints expose `nwc_is_set: bool` instead
- `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` - calculated based on `subdomain` (with `-` replaced by `_`) + `_` + 8 random hex chars
- `tenant` - the tenant's pubkey
- `schema` - the relay's db schema (read only, same as `id`)
- `subdomain` - the relay's subdomain
- `plan` - the relay's plan
- `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.
# LightningInvoice
Tracks the bolt11 invoice issued on the system wallet to collect a single Stripe invoice over Lightning (either NWC auto-pay or a manual payment from the app). One row per Stripe invoice.
- `stripe_invoice_id` - the Stripe invoice this bolt11 collects (primary key)
- `tenant_pubkey` - the owning tenant
- `bolt11` - the bolt11 invoice string issued on the system wallet
- `status` - one of `pending|paid`
- `paid_method` (nullable) - how it settled, one of `nwc|manual`; set when `status` becomes `paid`
- `expires_at` - unix timestamp after which the `pending` bolt11 is considered expired and may be regenerated
- `created_at` / `updated_at` - unix timestamps
-15
View File
@@ -1,15 +0,0 @@
# `pub async fn create_pool(database_url: &str) -> Result<SqlitePool>`
Creates and returns a sqlite connection pool.
Notes:
- Database table names are singular: `activity`, `tenant`, `relay`, `lightning_invoice`
Steps:
- Normalizes `database_url`: a relative `sqlite://` path is resolved under the crate manifest directory (`CARGO_MANIFEST_DIR`); absolute paths and `:memory:` are left as-is
- Ensures any directory referred to in the (normalized) URL exists
- Opens the pool with `create_if_missing` enabled
- Enables WAL journaling (`PRAGMA journal_mode = WAL`)
- Runs migrations found in the `migrations` directory
-69
View File
@@ -1,69 +0,0 @@
# `pub struct Query`
Query reads from the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
- `env: Env` - configuration; used to fill in plan `stripe_price_id`s from `STRIPE_PRICE_*`
## `pub fn new(pool: SqlitePool, env: &Env) -> Self`
- Stores the pool and a clone of `env`
## `pub fn list_plans(&self) -> Vec<Plan>`
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
- The `basic`/`growth` `stripe_price_id`s come from `env` (`stripe_price_basic` / `stripe_price_growth`); `free` has none
- This is the source of truth for plan metadata exposed via API
## `pub fn get_plan(&self, plan_id: &str) -> Option<Plan>`
- Returns the plan matching `plan_id`, if any
## `pub fn is_paid_plan(&self, plan_id: &str) -> bool`
- Returns whether `plan_id` is a known plan with `amount > 0`
## `pub async fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns all tenants
## `pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>>`
- Returns the matching tenant, or `None` if not found
## `pub async fn get_tenant_by_stripe_customer_id(&self, stripe_customer_id: &str) -> Result<Option<Tenant>>`
- Returns the tenant matching the given `stripe_customer_id`, or `None`
## `pub async fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays
## `pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>>`
- Returns all relays where `synced = 0` or `sync_error` is non-empty
- Used by `Infra` to reconcile relays that still need to be pushed to zooid
## `pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays belonging to the given tenant
## `pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>>`
- Returns the matching relay, or `None` if not found
## `pub async fn get_lightning_invoice(&self, stripe_invoice_id: &str) -> Result<Option<LightningInvoice>>`
- Returns the `lightning_invoice` row for the given Stripe invoice, or `None`
## `pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_id = resource_id`
- Ordered newest-first
## `pub async fn get_latest_activity_for_resource_and_type(&self, resource_id: &str, activity_type: &str) -> Result<Option<Activity>>`
- Returns the most recent activity for `resource_id` with the given `activity_type`, or `None`
- Used by `Infra` to decide whether a relay has ever completed a sync
-32
View File
@@ -1,32 +0,0 @@
# `pub struct Robot`
Robot is the nostr identity that acts on behalf of the application — it publishes the app's profile/relay lists and sends DMs to tenants. It signs with the robot key in `env.keys` and builds nostr clients on demand from the relay lists in `env`.
Members:
- `env: Env` - configuration; supplies the robot key and the outbox/indexer/messaging relay lists and profile metadata
- `outbox_cache` / `dm_cache` - per-recipient caches (5 minute TTL) of discovered outbox and messaging relays
## `pub async fn new(env: &Env) -> Result<Self>`
- Stores a clone of `env` and initializes the caches
- Calls `publish_identity`, which publishes a `kind 0` profile and a `kind 10002` relay list (the `ROBOT_OUTBOX_RELAYS`, as `r` tags) to the outbox relays, and a `kind 10050` DM relay list (the `ROBOT_MESSAGING_RELAYS`, as `relay` tags) via the indexer relays
## `pub async fn send_dm(&self, recipient: &str, message: &str) -> Result<()>`
- Resolves the recipient's outbox relays (`fetch_outbox_relays`), then their messaging relays from those outbox relays (`fetch_messaging_relays_from_outbox`)
- Sends a NIP-17 private message to the recipient via their messaging relays
- Errors if no outbox or messaging relays are found
## `pub async fn fetch_nostr_name(&self, pubkey: &str) -> Option<String>`
- Fetches the recipient's `kind 0` metadata from the indexer relays and returns its `display_name` (falling back to `name`), trimmed and non-empty
- Returns `None` on any failure — used to derive a Stripe customer display name
## `async fn fetch_outbox_relays(&self, recipient: &str) -> Result<Vec<String>>`
- Returns the `r` tags from the recipient's latest `kind 10002` event, fetched from the indexer relays; cached for 5 minutes
## `async fn fetch_messaging_relays_from_outbox(&self, recipient: &str, outbox_relays: &[String]) -> Result<Vec<String>>`
- Returns the `relay` tags from the recipient's latest `kind 10050` event, fetched from their outbox relays; cached for 5 minutes
-98
View File
@@ -1,98 +0,0 @@
# `pub struct Stripe`
A thin async wrapper around the subset of the Stripe REST API this service uses. It knows nothing about relays, tenants, or the database — it just speaks HTTP to Stripe and returns small typed results. The domain logic that drives it lives in `spec/billing.md`, and the webhook dispatch lives in `spec/api.md`.
Members:
- `env: Env` - configuration; supplies the Stripe secret key (bearer token + idempotency HMAC key) and the webhook signing secret
- `http: reqwest::Client`
All requests authenticate with `env.stripe_secret_key` via `Authorization: Bearer`. Write requests that must not be retried twice (customer/subscription/item creation, invoice payment) send a deterministic `Idempotency-Key` derived by HMAC-ing the operation name and its arguments with the secret key. Reconcile-to-desired-state writes (e.g. setting an item quantity, deleting/canceling) intentionally omit the idempotency key, since re-applying the same target is a no-op.
On any 4xx/5xx response, the wrapper reads the body and folds Stripe's JSON error payload (`error.message` / `error.type` / `error.code` / `error.param`) into the returned error so callers get an actionable message instead of a bare status line.
## `pub fn new(env: &Env) -> Self`
Constructs the client with a fresh `reqwest::Client`, holding a clone of `env`. This is what `Billing::new` and `main` call.
## `pub async fn create_customer(&self, tenant_pubkey: &str, name: &str) -> Result<String>`
- `POST /v1/customers` with `name` and `metadata[tenant_pubkey]`
- Idempotent on `tenant_pubkey`
- Returns the new customer id
## `pub async fn get_subscription(&self, subscription_id: &str) -> Result<Option<StripeSubscription>>`
- `GET /v1/subscriptions/:id`
- Returns `None` if Stripe responds `404` (so callers can recover from a stale subscription id), otherwise the parsed `StripeSubscription`
## `pub async fn create_subscription(&self, customer_id: &str, items: &BTreeMap<String, i64>) -> Result<StripeSubscription>`
- `POST /v1/subscriptions` with `collection_method: charge_automatically` and one `items[n][price]` / `items[n][quantity]` pair per entry
- Idempotent on the customer and the `(price, quantity)` set
- Returns the created `StripeSubscription` (including its items)
## `pub async fn create_subscription_item(&self, subscription_id: &str, price_id: &str, quantity: i64) -> Result<()>`
- `POST /v1/subscription_items`
- Idempotent on `(subscription_id, price_id)`
## `pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()>`
- `POST /v1/subscription_items/:id` with `quantity`
- No idempotency key (reconcile-to-target write)
## `pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()>`
- `DELETE /v1/subscription_items/:id`
## `pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()>`
- `DELETE /v1/subscriptions/:id`
## `pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>>`
- `GET /v1/invoices?customer=…`
- Returns the parsed `data` array
## `pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>>`
- `GET /v1/invoices/:id`
- Returns `None` if Stripe responds `404`, otherwise the parsed `StripeInvoice`
## `pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()>`
- `POST /v1/invoices/:id/pay` (retries collection using the customer's default payment method)
- Idempotent on `invoice_id`
## `pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()>`
- `POST /v1/invoices/:id/pay` with `paid_out_of_band: true` — used when payment was collected over Lightning rather than through Stripe
- Idempotent on `invoice_id` (under a distinct key from `pay_invoice`)
## `pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool>`
- `GET /v1/payment_methods?customer=…&type=card`
- Returns whether the customer has at least one card on file
## `pub async fn create_portal_session(&self, customer_id: &str, return_url: Option<&str>) -> Result<String>`
- `POST /v1/billing_portal/sessions` with `customer` and optional `return_url`
- Returns the Customer Portal session URL
## `pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent>`
Verifies the `Stripe-Signature` header against `env.stripe_webhook_secret` and parses the body.
- Parse `t=` (timestamp) and `v1=` (signature) from the header
- Compute `HMAC-SHA256(secret, "{t}.{payload}")`, hex-encode it, and compare to `v1=`; error on mismatch
- Error if the timestamp is more than 300 seconds from now
- Returns the deserialized `StripeWebhookEvent`
# Typed results
- `StripeWebhookEvent { event_type: String, data: StripeWebhookEventData }`, `StripeWebhookEventData { object: serde_json::Value }` — the verified, parsed webhook event (`event_type` deserializes from the JSON `type` field)
- `StripeSubscription { id, status, items: Vec<StripeSubscriptionItem> }` (`items` flattened from Stripe's `{ data: [...] }` list)
- `StripeSubscriptionItem { id, price: StripePrice, quantity }` (`quantity` defaults to 1 when absent)
- `StripePrice { id }`
- `StripeInvoice { id, customer, status, amount_due, currency }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
-23
View File
@@ -1,23 +0,0 @@
# `pub struct Wallet`
A handle to a single Nostr Wallet Connect (NWC) wallet. `Billing` holds one as its system wallet (receives — issues and looks up invoices); tenant wallets (pay invoices) are constructed ad-hoc from the decrypted `tenant.nwc_url` at the call site. Each operation opens a fresh NWC connection and shuts it down afterwards.
Member:
- `url: NostrWalletConnectURI` — the parsed `nostr+walletconnect://…` URI
## `pub fn from_url(url: &str) -> Result<Self>`
Parses an `nostr+walletconnect://` URI.
## `pub async fn make_invoice(&self, amount_msats: u64, description: &str, expiry_secs: u64) -> Result<String>`
Issues a bolt11 invoice for `amount_msats` with the given `description` and expiry, and returns the bolt11 string.
## `pub async fn pay_invoice(&self, bolt11: String) -> Result<()>`
Pays a bolt11 invoice.
## `pub async fn is_settled(&self, bolt11: &str) -> Result<bool>`
Returns whether a bolt11 invoice (previously issued by this wallet) has settled.
-39
View File
@@ -1,39 +0,0 @@
# `web` — HTTP response helpers
General-purpose helpers shared across the route handlers in `spec/api.md` (implemented under `src/routes/`). They standardize the success/error envelope and a couple of small utilities.
Successful responses are `{ data, code: "ok" }` with an appropriate HTTP status. Error responses are `{ error, code }` with an appropriate HTTP status, where `code` is a short machine-readable string (e.g. `subdomain-exists`) and `error` is a human-readable message.
## `pub struct ApiError(pub Box<Response>)`
A boxed `axum` `Response` that any handler can return as its error type. Implements `IntoResponse` and `From<Response>`, so the error builders below compose with `?`, `.map_err(...)`, and explicit `Err(...)`.
## `pub type ApiResult = Result<Response, ApiError>`
The return type of every route handler. Success builders return `ApiResult` so they sit at the end of a handler without an `Ok(..)` wrap; error builders return `ApiError`.
## Response bodies
- `DataResponse<T> { data: T, code: "ok" }` - the success envelope
- `ErrorResponse { error: String, code: String }` - the error envelope
## Success builders (return `ApiResult`)
- `res<T>(status, data)` - `{ data, code: "ok" }` with `status`
- `ok<T>(data)` - `res(200, data)`
- `created<T>(data)` - `res(201, data)`
## Error builders (return `ApiError`)
- `err(status, code, message)` - the base `{ error, code }` builder
- `unauthorized(reason)` - `401`, `code = "unauthorized"`
- `forbidden(message)` - `403`, `code = "forbidden"`
- `not_found(message)` - `404`, `code = "not-found"`
- `bad_request(code, message)` - `400` with the given `code`
- `unprocessable(code, message)` - `422` with the given `code`
- `internal(reason)` - `500`, `code = "internal"`
## Utilities
- `parse_bool_default(value: i64, default: i64) -> i64` - returns `value` if it is `0` or `1`, otherwise `default`. Used to normalize boolean-ish relay flags.
- `map_unique_error(err: &anyhow::Error) -> Option<&'static str>` - recognizes sqlite UNIQUE constraint violations so callers can translate them into `422`s instead of `500`s. Returns `pubkey-exists` or `subdomain-exists` when the violated column message matches, else `None`.
+46 -36
View File
@@ -18,8 +18,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow, ensure};
use axum::{
Router,
async_trait,
Router, async_trait,
extract::FromRequestParts,
http::{HeaderMap, request::Parts},
routing::{get, post},
@@ -28,55 +27,41 @@ use base64::Engine;
use nostr_sdk::{Event, JsonUtil, Kind};
use crate::billing::Billing;
use crate::command::Command;
use crate::env::Env;
use crate::infra::Infra;
use crate::env;
use crate::models::{Relay, Tenant};
use crate::query::Query;
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::routes::domains::check_domain;
use crate::routes::identity::get_identity;
use crate::routes::invoices::{get_invoice, get_lightning_invoice, list_tenant_invoices};
use crate::routes::invoices::{
ensure_invoice_bolt11, ensure_invoice_checkout, get_invoice, list_invoice_items, list_invoices,
reconcile_invoice,
};
use crate::routes::plans::{get_plan, list_plans};
use crate::routes::relays::{
create_relay, deactivate_relay, get_relay, list_relay_activity, list_relay_members,
list_relays, reactivate_relay, update_relay,
};
use crate::routes::stripe::{create_stripe_session, stripe_webhook};
use crate::routes::tenants::{
create_tenant, get_tenant, list_tenant_relays, list_tenants, update_tenant,
create_stripe_session, create_tenant, get_draft_invoice, get_tenant, list_draft_invoice_items,
list_tenant_invoices, list_tenant_relays, list_tenants, reconcile_tenant, update_tenant,
};
use crate::stripe::Stripe;
use crate::web::{ApiError, forbidden, internal, not_found, unauthorized};
#[derive(Clone)]
pub struct Api {
pub env: Env,
pub query: Query,
pub command: Command,
pub billing: Billing,
pub stripe: Stripe,
pub robot: Robot,
pub infra: Infra,
}
impl Api {
pub fn new(
query: Query,
command: Command,
billing: Billing,
stripe: Stripe,
robot: Robot,
infra: Infra,
env: &Env,
) -> Self {
pub fn new(billing: Billing, stripe: Stripe, robot: Robot) -> Self {
Self {
env: env.clone(),
query,
command,
billing,
stripe,
robot,
infra,
}
}
@@ -90,24 +75,37 @@ impl Api {
.route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route("/tenants/:pubkey/invoices/draft", get(get_draft_invoice))
.route(
"/tenants/:pubkey/invoices/draft/items",
get(list_draft_invoice_items),
)
.route("/tenants/:pubkey/reconcile", post(reconcile_tenant))
.route(
"/tenants/:pubkey/stripe/session",
get(create_stripe_session),
)
.route("/relays", get(list_relays).post(create_relay))
.route("/relays/:id", get(get_relay).put(update_relay))
.route("/relays/:id/members", get(list_relay_members))
.route("/relays/:id/activity", get(list_relay_activity))
.route("/relays/:id/deactivate", post(deactivate_relay))
.route("/relays/:id/reactivate", post(reactivate_relay))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
.route("/domains/check", get(check_domain))
.route("/invoices", get(list_invoices))
.route("/invoices/:id", get(get_invoice))
.route("/invoices/:id/bolt11", get(get_lightning_invoice))
.route("/tenants/:pubkey/stripe/session", get(create_stripe_session))
.route("/stripe/webhook", post(stripe_webhook))
.route("/invoices/:id/reconcile", post(reconcile_invoice))
.route("/invoices/:id/checkout", post(ensure_invoice_checkout))
.route("/invoices/:id/bolt11", post(ensure_invoice_bolt11))
.route("/invoices/:id/items", get(list_invoice_items))
.with_state(api)
}
// --- authorization helpers ----------------------------------------------
pub fn is_admin(&self, pubkey: &str) -> bool {
self.env.server_admin_pubkeys.iter().any(|a| a == pubkey)
env::get().server_admin_pubkeys.iter().any(|a| a == pubkey)
}
pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError> {
@@ -118,6 +116,18 @@ impl Api {
}
}
pub fn require_tenant(
&self,
authorized_pubkey: &str,
tenant_pubkey: &str,
) -> Result<(), ApiError> {
if authorized_pubkey == tenant_pubkey {
Ok(())
} else {
Err(forbidden("not authorized"))
}
}
pub fn require_admin_or_tenant(
&self,
authorized_pubkey: &str,
@@ -131,7 +141,7 @@ impl Api {
}
pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError> {
match self.query.get_tenant(pubkey).await {
match query::get_tenant(pubkey).await {
Ok(Some(t)) => Ok(t),
Ok(None) => Err(not_found("tenant not found")),
Err(e) => Err(internal(e)),
@@ -139,7 +149,7 @@ impl Api {
}
pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError> {
match self.query.get_relay(id).await {
match query::get_relay(id).await {
Ok(Some(r)) => Ok(r),
Ok(None) => Err(not_found("relay not found")),
Err(e) => Err(internal(e)),
@@ -178,7 +188,7 @@ impl Api {
ensure!(event.kind == Kind::HttpAuth, "invalid nip98 kind");
event.verify()?;
let got_u = event
let actual_u = event
.tags
.iter()
.filter_map(|tag| {
@@ -189,7 +199,7 @@ impl Api {
.ok_or_else(|| anyhow!("missing u tag"))?;
ensure!(
self.env.server_host.is_empty() || got_u.contains(&self.env.server_host),
actual_u == env::get().server_url,
"authorization host mismatch"
);
+620 -249
View File
@@ -1,82 +1,74 @@
use anyhow::{Result, anyhow};
use std::collections::BTreeMap;
use std::time::Duration;
use crate::bitcoin;
use crate::command::Command;
use crate::env::Env;
use crate::models::{Activity, LightningInvoice, Tenant, RELAY_STATUS_ACTIVE};
use crate::query::Query;
use crate::stripe::{Stripe, StripeInvoice, StripeSubscription};
use crate::command;
use crate::env;
use crate::models::{
Activity, Bolt11, Checkout, Invoice, InvoiceItem, RELAY_STATUS_ACTIVE, Snapshot, Tenant,
};
use crate::query;
use crate::robot::Robot;
use crate::stripe::Stripe;
use crate::wallet::Wallet;
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
const GRACE_PERIOD_SECS: i64 = 7 * 24 * 60 * 60;
/// Hold the manual-payment DM until an open invoice is at least this old. A freshly
/// issued invoice is surfaced to the tenant in-app first (e.g. right after they
/// create a relay), so we don't also nag by DM on the first dunning cycles.
const FRESH_INVOICE_DM_GRACE_SECS: i64 = 24 * 60 * 60;
const MANUAL_PAYMENT_DM_INTERVAL_SECS: i64 = 12 * 24 * 60 * 60;
const MANUAL_PAYMENT_DM: &str = "Payment is due for your relay subscription. Open the link below to review the invoice and pay by Lightning or card:";
const CHURN_DM: &str = "Your relay subscription is past due, so your relays have been paused. You can restore service any time by adding a payment method or paying an invoice from your dashboard:";
/// Owns subscription billing: it reconciles tenant activity into invoice items,
/// renews subscriptions each period, and collects payment (Lightning, then a
/// card on file, then a manual DM link).
#[derive(Clone)]
pub struct Billing {
stripe: Stripe,
wallet: Wallet,
query: Query,
command: Command,
env: Env,
robot: Robot,
}
impl Billing {
pub fn new(query: Query, command: Command, env: &Env) -> Self {
pub fn new(robot: Robot) -> Self {
Self {
stripe: Stripe::new(env),
wallet: Wallet::from_url(&env.robot_wallet).expect("invalid ROBOT_WALLET"),
query,
command,
env: env.clone(),
stripe: Stripe::new(),
wallet: Wallet::from_url(&env::get().robot_wallet).expect("invalid ROBOT_WALLET"),
robot,
}
}
// --- lifecycle methods ---
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
if let Err(error) = self.reconcile_subscriptions("startup").await {
tracing::error!(error = %error, "failed to reconcile relay billing state on startup");
}
let mut interval = tokio::time::interval(POLL_INTERVAL);
loop {
match rx.recv().await {
Ok(activity) => {
if let Err(e) = self.handle_activity(&activity).await {
tracing::error!(error = %e, "billing handle_activity failed");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "billing lagged");
interval.tick().await;
if let Err(error) = self.reconcile_subscriptions("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay billing state after lag");
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
if let Err(error) = self.reconcile_subscriptions().await {
tracing::error!(error = %error, "billing poll failed");
}
}
}
async fn reconcile_subscriptions(&self, source: &str) -> Result<()> {
let tenants = self.query.list_tenants().await?;
if tenants.is_empty() {
return Ok(());
}
async fn reconcile_subscriptions(&self) -> Result<()> {
let tenants = query::list_tenants().await?;
tracing::info!(
source,
tenant_count = tenants.len(),
"reconciling relay billing state"
"reconciling all subscriptions"
);
for tenant in tenants {
if let Err(error) = self.reconcile_subscription(&tenant).await {
if let Err(error) = self.reconcile_subscription(&tenant, true).await {
tracing::error!(
source,
tenant = %tenant.pubkey,
error = ?error,
"failed to reconcile relay billing state"
"failed to reconcile subscription"
);
}
}
@@ -84,266 +76,645 @@ impl Billing {
Ok(())
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_billing_sync = matches!(
activity.activity_type.as_str(),
"create_relay"
| "update_relay"
| "activate_relay"
| "deactivate_relay"
| "fail_relay_sync"
| "complete_relay_sync"
);
// --- Reconciliation of activity/renewals ---
if needs_billing_sync
&& let Some(tenant) = self.query.get_tenant(&activity.tenant).await?
{
self.reconcile_subscription(&tenant).await?;
/// Reconciles a tenant's billing: re-activates them if a churned tenant has
/// re-engaged, folds billable activity into line items (setting the billing
/// anchor based on the first one), renews the current period if due, and claims
/// outstanding items onto an invoice.
pub async fn reconcile_subscription(
&self,
tenant: &Tenant,
attempt_payment: bool,
) -> Result<()> {
let mut tenant = tenant.clone();
let mut activities = query::list_billable_activity(&tenant.pubkey).await?;
// A churned tenant with fresh billable activity is using the service
// again: re-activate billing (and restore their relays) before billing it.
// Reactivation records a billable activity for each restored relay; fold
// those into this pass so their prorated charges land on the same invoice.
if tenant.churned_at.is_some() && !activities.is_empty() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
activities.extend(command::reactivate_tenant(&tenant.pubkey, &relays).await?);
tenant.churned_at = None;
}
Ok(())
}
// Reconcile all activity, setting the tenant's billing anchor on the first
// activity if not already set.
for activity in activities {
if tenant.billing_anchor.is_none() {
tenant.billing_anchor = Some(activity.created_at);
command::set_tenant_billing_anchor(&tenant).await?;
}
// --- Subscriptions ---
self.reconcile_activity(&tenant, &activity).await?;
}
/// Reconciles a tenant's single Stripe subscription with the set of relays that
/// should be billed.
///
/// Stripe forbids two subscription items on the same subscription from sharing a
/// price, so billing is modeled as one subscription item per plan (price) with
/// `quantity` equal to the number of the tenant's `active` relays on that plan.
async fn reconcile_subscription(&self, tenant: &Tenant) -> Result<()> {
let quantity_by_price_id = self.get_quantity_by_price_id(tenant).await?;
// If the tenant has a billing anchor, renew the current period if due and
// claim any outstanding items onto an invoice.
if let Some(period) = BillingPeriod::current(&tenant) {
if tenant.renewed_at.is_none_or(|at| at < period.start) {
self.reconcile_renewal(&tenant, &period).await?;
}
// If we've got no subscription items, we can cancel and clear the tenant's subscription
if quantity_by_price_id.is_empty() {
self.ensure_subscription_is_inactive(tenant).await?;
command::create_invoice(&tenant, &period).await?;
}
// Fetch the tenant's open invoices once
let invoices = query::list_open_invoices(&tenant.pubkey).await?;
// If the tenant is past due, churn them
if self.maybe_churn_tenant(&tenant, &invoices).await? {
return Ok(());
}
let subscription = self
.ensure_subscription_is_active(tenant, &quantity_by_price_id)
.await?;
self.ensure_subscription_items(subscription, quantity_by_price_id).await
}
/// Gets a map of stripe_price_id -> quantity based on the tenant's current relays
async fn get_quantity_by_price_id(&self, tenant: &Tenant) -> Result<BTreeMap<String, i64>> {
let mut quantity_by_price_id = BTreeMap::new();
for relay in self.query.list_relays_for_tenant(&tenant.pubkey).await? {
if relay.status != RELAY_STATUS_ACTIVE {
continue;
}
let Some(price_id) = self.query.get_plan(&relay.plan).and_then(|p| p.stripe_price_id) else {
continue;
};
*quantity_by_price_id.entry(price_id).or_insert(0) += 1;
// If we're going to try to collect, make sure we have an updated payment method
if attempt_payment {
tenant.stripe_payment_method_id = self.sync_stripe_customer(&tenant).await?;
}
Ok(quantity_by_price_id)
// Reconcile out-of-band payments (and, when collecting, charge) on every
// open invoice. The out-of-band checks run even when attempt_payment is
// false, so a checkout or bolt11 paid out of band settles on any reconcile.
for invoice in &invoices {
self.reconcile_payments(&tenant, invoice, attempt_payment, true)
.await?;
}
Ok(())
}
/// Fetch the tenant's current subscription from Stripe, if it has one
async fn get_subscription(&self, tenant: &Tenant) -> Result<Option<StripeSubscription>> {
let subscription = match &tenant.stripe_subscription_id {
Some(id) => self.stripe.get_subscription(id).await?,
None => None,
/// Reconcile one activity into the ledger: build its line item (if any) and
/// persist it with the activity's billed marker. Activities that produce no
/// item (e.g. free-plan changes) are still marked billed so they aren't
/// re-scanned.
async fn reconcile_activity(&self, tenant: &Tenant, activity: &Activity) -> Result<()> {
let invoice_item = match activity.activity_type.as_str() {
"create_relay" => {
self.make_prorated_item(tenant, activity, 1, "New relay created")
.await?
}
"activate_relay" | "unmark_relay_delinquent" => {
self.make_prorated_item(tenant, activity, 1, "Relay reactivated")
.await?
}
"deactivate_relay" => {
self.make_prorated_item(tenant, activity, -1, "Relay deactivated")
.await?
}
"update_relay" => self.make_plan_change_item(tenant, activity).await?,
_ => None,
};
// If it's canceled, clear the subscription id and return nothing for simplicity
if subscription
.as_ref()
.is_some_and(|s| matches!(s.status.as_str(), "canceled" | "incomplete_expired"))
{
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
match invoice_item {
Some(ref item) => command::insert_invoice_item_for_activity(item, &activity.id).await,
None => command::mark_activity_billed(&activity.id).await,
}
}
/// A prorated charge (or credit, with `sign` = -1) for the plan recorded on
/// the activity's snapshot — the plan as of the activity, not the relay's
/// current plan — covering the fraction of the period remaining at the
/// activity. `None` for a free plan.
async fn make_prorated_item(
&self,
tenant: &Tenant,
activity: &Activity,
sign: i64,
description: &str,
) -> Result<Option<InvoiceItem>> {
let Snapshot::Relay { plan: plan_id, .. } = &*activity.snapshot;
let plan = query::get_plan(plan_id)?;
if plan.amount <= 0 {
return Ok(None);
}
Ok(subscription)
let period = BillingPeriod::at(tenant, activity.created_at)
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
let amount = sign * period.prorate(plan.amount, activity.created_at);
Ok(Some(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: Some(activity.id.clone()),
tenant_pubkey: activity.tenant_pubkey.clone(),
relay_id: activity.resource_id.clone(),
plan_id: plan.id,
amount,
description: description.to_string(),
created_at: activity.created_at,
voided_at: None,
}))
}
/// Make sure the tenant has an active subscription, creating one with the desired
/// items if it doesn't (Stripe rejects an itemless subscription).
async fn ensure_subscription_is_active(
/// The prorated delta for a plan change, read straight from the activity log:
/// `new` is this `update_relay` activity's recorded plan, `old` is the relay's
/// plan immediately before it. Because the renewal charges the relay's plan as
/// of the period boundary, this delta composes to the correct total regardless
/// of ordering and needs no coverage gate. `None` when nothing changed.
async fn make_plan_change_item(
&self,
tenant: &Tenant,
quantity_by_price_id: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
if let Some(sub) = self.get_subscription(tenant).await? {
return Ok(sub);
activity: &Activity,
) -> Result<Option<InvoiceItem>> {
let new_plan_id = match &*activity.snapshot {
Snapshot::Relay { plan, .. } => plan,
};
let Some(old_plan_id) =
query::get_relay_plan_before(&activity.resource_id, activity.created_at).await?
else {
return Err(anyhow!("no previous plan found for relay update activity"));
};
if &old_plan_id == new_plan_id {
return Ok(None);
}
let new_plan = query::get_plan(new_plan_id)?;
let old_plan = query::get_plan(&old_plan_id)?;
let period = BillingPeriod::at(tenant, activity.created_at)
.ok_or_else(|| anyhow!("billing anchor must be set before building prorated items"))?;
let amount = period.prorate(new_plan.amount, activity.created_at)
- period.prorate(old_plan.amount, activity.created_at);
if amount == 0 {
return Ok(None);
}
let sub = self
.stripe
.create_subscription(&tenant.stripe_customer_id, quantity_by_price_id)
.await?;
self.command.set_tenant_subscription(&tenant.pubkey, &sub.id).await?;
Ok(sub)
let description = format!("Plan changed from {} to {}", old_plan.name, new_plan.name);
Ok(Some(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: Some(activity.id.clone()),
tenant_pubkey: activity.tenant_pubkey.clone(),
relay_id: activity.resource_id.clone(),
plan_id: new_plan.id,
amount,
description,
created_at: activity.created_at,
voided_at: None,
}))
}
/// If the tenant has a subscription, cancel and clear it
async fn ensure_subscription_is_inactive(&self, tenant: &Tenant) -> Result<()> {
if let Some(s) = self.get_subscription(tenant).await? {
self.stripe.cancel_subscription(&s.id).await?;
self.command.clear_tenant_subscription(&tenant.pubkey).await?;
/// Charge a full-period renewal for every relay that was active on a paid plan
/// as of `period.start`, reading that state from each relay's most recent
/// activity snapshot before the boundary (relays with no prior activity didn't
/// exist yet and are skipped). Idempotent per period via the tenant's
/// `renewed_at` marker, so calling it on every generation can't renew twice;
/// a relay created/activated *within* the period isn't active before the
/// boundary, so it's covered by its own prorated charge instead.
async fn reconcile_renewal(&self, tenant: &Tenant, period: &BillingPeriod) -> Result<()> {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
let mut line_items = Vec::new();
for relay in relays {
let Some(activity) =
query::get_latest_relay_activity_before(&relay.id, period.start).await?
else {
continue;
};
let Snapshot::Relay {
plan: plan_id,
status,
..
} = &*activity.snapshot;
if status != RELAY_STATUS_ACTIVE {
continue;
}
let plan = query::get_plan(plan_id)?;
if plan.amount <= 0 {
continue;
}
line_items.push(InvoiceItem {
id: uuid::Uuid::new_v4().to_string(),
invoice_id: None,
activity_id: None,
tenant_pubkey: tenant.pubkey.clone(),
relay_id: relay.id,
plan_id: plan.id,
amount: plan.amount,
description: "Subscription renewal".to_string(),
created_at: period.start,
voided_at: None,
});
}
Ok(())
// Inserts the items and advances `renewed_at` to `period.start` in one
// transaction (idempotent via an in-tx guard), so a re-tick is a no-op.
command::insert_invoice_items_for_renewal(&line_items, period).await
}
/// Sync desired quantity_by_price_id with stripe
async fn ensure_subscription_items(
&self,
subscription: StripeSubscription,
quantity_by_price_id: BTreeMap<String, i64>,
) -> Result<()> {
let mut current: BTreeMap<String, (String, i64)> = BTreeMap::new();
for item in subscription.items {
current.insert(item.price.id, (item.id, item.quantity));
// --- Auto-churn ---
/// Churn a tenant whose oldest open invoice has blown past the grace period:
/// pause their relays and DM them once, on the transition into churn. Returns
/// whether the tenant is past due, so the caller can skip collecting this pass.
async fn maybe_churn_tenant(&self, tenant: &Tenant, invoices: &[Invoice]) -> Result<bool> {
let Some(oldest) = invoices.first() else {
return Ok(false);
};
let now = chrono::Utc::now().timestamp();
if now - oldest.created_at < GRACE_PERIOD_SECS {
return Ok(false);
}
for (price_id, &quantity) in &quantity_by_price_id {
if let Some((item_id, current_quantity)) = current.remove(price_id) {
if current_quantity != quantity {
self.stripe
.set_subscription_item_quantity(&item_id, quantity)
.await?;
}
} else {
self.stripe
.create_subscription_item(&subscription.id, price_id, quantity)
.await?;
// Past due. Churn once (the guard fires a single time on the transition)
// and notify the tenant, logging and continuing on DM failure.
if tenant.churned_at.is_none() {
let relays = query::list_relays_for_tenant(&tenant.pubkey).await?;
command::churn_tenant(&tenant.pubkey, now, &relays).await?;
let message = format!("{CHURN_DM}\n\n{}/account", env::get().app_url);
if let Err(e) = self.robot.send_dm(&tenant.pubkey, &message).await {
tracing::error!(tenant = %tenant.pubkey, error = %e, "failed to send churn DM");
}
}
for (_, (item_id, _)) in current {
self.stripe.delete_subscription_item(&item_id).await?;
Ok(true)
}
// --- Payments ---
/// Collect an invoice. We check the out-of-band rails first — a Lightning
/// invoice or Checkout session the tenant may have already paid — and only
/// then initiate a fresh charge (NWC, then a saved card), so a payment that's
/// already in flight is never duplicated. Falling all the way through sends a
/// manual-payment DM (when `notify`). A failing charge's error is stored on
/// the tenant (to warn them in the UI) but never aborts the cascade or future
/// retries; it's cleared when a method next succeeds. Caller-initiated
/// payments pass `notify = false` to skip the dunning DM, since the failure
/// is already surfaced on screen.
pub async fn reconcile_payments(
&self,
tenant: &Tenant,
invoice: &Invoice,
autopay: bool,
notify: bool,
) -> Result<()> {
// 1. Out-of-band lightning: settle a bolt11 paid out of band (e.g. a
// manual QR scan, or an NWC pay that completed but failed to record).
// Checked before any charge so we never bill on top of it.
if let Some(bolt11) = query::get_bolt11_for_invoice(&invoice.id).await?
&& bolt11.settled_at.is_none()
&& self.wallet.is_settled(&bolt11.lnbc).await.unwrap_or(false)
{
command::settle_invoice_out_of_band(&bolt11.id, &invoice.id).await?;
return self.cleanup_pending_payments(invoice).await;
}
// 2. Hosted Checkout: settle an invoice the tenant paid (and
// authenticated) on a Stripe Checkout session that has since completed.
// Also checked before any charge so we never bill on top of it.
if let Some(checkout) = query::get_checkout_for_invoice(&invoice.id).await?
&& checkout.settled_at.is_none()
&& self
.stripe
.is_checkout_paid(&checkout.session_id)
.await
.unwrap_or(false)
{
command::settle_invoice_via_checkout(&tenant.pubkey, &checkout.id, &invoice.id).await?;
return self.cleanup_pending_payments(invoice).await;
}
if !autopay {
return Ok(());
}
// 3. NWC auto-pay: if the tenant has configured an nwc_url, charge it.
if !tenant.nwc_url.is_empty()
&& self
.attempt_payment_using_nwc(tenant, invoice)
.await
.is_ok()
{
return self.cleanup_pending_payments(invoice).await;
}
// 4. Payment method on file: charge the tenant's cached Stripe payment
// method, kept fresh by sync_stripe_payment_method before collection.
if let Some(payment_method) = &tenant.stripe_payment_method_id
&& self
.attempt_payment_using_stripe(tenant, invoice, payment_method)
.await
.is_ok()
{
return self.cleanup_pending_payments(invoice).await;
}
if !notify {
return Ok(());
}
// 5. Manual payment: DM a link to the in-app payment page for this invoice.
if let Err(e) = self.attempt_payment_using_dm(tenant, invoice).await {
tracing::error!(
tenant = %tenant.pubkey,
error = %e,
"failed to send manual payment DM"
);
}
Ok(())
}
// --- Invoices ---
async fn attempt_payment_using_nwc(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let result: Result<()> = async {
let nwc_url = env::get().decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?;
let bolt11 = self.ensure_bolt11_for_invoice(invoice).await?;
/// return or generate a lightning invoice for an open stripe invoice
pub async fn ensure_lightning_invoice(
tenant_wallet.pay_invoice(bolt11.lnbc.clone()).await?;
command::settle_invoice_via_nwc(&tenant.pubkey, &bolt11.id, &invoice.id).await
}
.await;
// Record the failure on the tenant (to warn them in the UI) but still
// surface it, so the cascade can fall through and summarize it in the DM.
if let Err(error) = &result {
command::set_tenant_nwc_error(&tenant.pubkey, &format!("{error}")).await?;
}
result
}
async fn attempt_payment_using_stripe(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
amount_due: i64,
currency: &str,
) -> Result<LightningInvoice> {
tenant: &Tenant,
invoice: &Invoice,
payment_method_id: &str,
) -> Result<()> {
let intent = command::ensure_pending_intent(&invoice.id, payment_method_id).await?;
let payment_intent_id = match self
.stripe
.create_payment_intent(
&tenant.stripe_customer_id,
&intent.payment_method_id,
&invoice.id,
invoice.amount,
"usd",
)
.await
{
Ok(id) => id,
// Drop the attempt so the next pass retries cleanly on the tenant's
// current method, and record the failure to warn the user in the UI.
Err(error) => {
command::delete_intent(&intent.id).await?;
command::set_tenant_stripe_error(&tenant.pubkey, &format!("{error}")).await?;
return Err(error);
}
};
command::settle_invoice_via_intent(
&tenant.pubkey,
&intent.id,
&payment_intent_id,
&invoice.id,
)
.await
}
async fn attempt_payment_using_dm(&self, tenant: &Tenant, invoice: &Invoice) -> Result<()> {
let now = chrono::Utc::now().timestamp();
if let Some(existing) = self.query.get_lightning_invoice(stripe_invoice_id).await?
&& (existing.status != "pending" || now < existing.expires_at)
// If the invoice was just generated, give the user a chance to check the dashboard before nagging them.
if now - invoice.created_at < FRESH_INVOICE_DM_GRACE_SECS {
return Ok(());
}
// The dunning poll runs hourly; avoid excessive reminder DMs.
if invoice
.notified_at
.is_some_and(|t| now - t < MANUAL_PAYMENT_DM_INTERVAL_SECS)
{
return Ok(());
}
// Build the message
let invoice_id = &invoice.id;
let url_base = &env::get().app_url;
let payment_url = format!("{url_base}/account?invoice={invoice_id}");
let message = format!("{MANUAL_PAYMENT_DM}\n\n{payment_url}");
// Send via NIP 17
self.robot.send_dm(&tenant.pubkey, &message).await?;
// Record the send to avoid spammy notifications.
command::mark_invoice_notified(invoice_id).await
}
/// Run after an invoice is settled to invalidate out-of-band payment methods
/// so the tenant can't pay twice. Only Stripe Checkout sessions can actually
/// be invalidated (by expiring them); Lightning/NWC has no such mechanism.
/// The session that just paid (if any) was marked settled in its settle
/// transaction, so it's excluded here and not needlessly expired.
async fn cleanup_pending_payments(&self, invoice: &Invoice) -> Result<()> {
for checkout in query::list_pending_checkouts_for_invoice(&invoice.id).await? {
if let Err(error) = self
.stripe
.expire_checkout_session(&checkout.session_id)
.await
{
tracing::debug!(
invoice = %invoice.id,
checkout = %checkout.id,
error = %error,
"could not expire checkout session"
);
}
}
Ok(())
}
// --- Bolt11 utils ---
pub async fn ensure_bolt11_for_invoice(&self, invoice: &Invoice) -> Result<Bolt11> {
let now = chrono::Utc::now().timestamp();
// A resolved (paid or voided) invoice must never mint a new payable
// bolt11; return the existing one if present, otherwise refuse.
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
return query::get_bolt11_for_invoice(&invoice.id)
.await?
.ok_or_else(|| anyhow!("invoice is not open"));
}
if let Some(existing) = query::get_bolt11_for_invoice(&invoice.id).await?
&& existing.settled_at.is_none()
&& now < existing.expires_at
{
return Ok(existing);
}
let expiry: i64 = 3600;
let info = "Relay subscription payment";
let msats = bitcoin::fiat_to_msats(amount_due, currency).await?;
let bolt11 = self.wallet.make_invoice(msats, info, expiry as u64).await?;
let msats = bitcoin::fiat_to_msats(invoice.amount, "usd").await?;
let lnbc = self.wallet.make_invoice(msats, info, expiry as u64).await?;
let invoice = match self
.command
.insert_lightning_invoice(stripe_invoice_id, tenant_pubkey, &bolt11, now + expiry)
command::insert_bolt11(&invoice.id, &lnbc, msats as i64, now + expiry)
.await?
{
Some(invoice) => invoice,
None => self
.query
.get_lightning_invoice(stripe_invoice_id)
.await?
.ok_or_else(|| anyhow!("lightning_invoice {stripe_invoice_id} missing after upsert"))?,
};
Ok(invoice)
.ok_or_else(|| anyhow!("failed to insert bolt11"))
}
/// Attempt to pay and settle an invoice via nwc
pub async fn pay_invoice_nwc(&self, tenant: &Tenant, invoice: &LightningInvoice) -> Result<()> {
let nwc_url = self.env.decrypt(&tenant.nwc_url)?;
let tenant_wallet = Wallet::from_url(&nwc_url)?;
// --- Checkout utils ---
match tenant_wallet.pay_invoice(invoice.bolt11.clone()).await {
Ok(()) => self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await,
Err(pay_error) => {
// The pay request errored, but the payment may have landed
// before the response was lost. Confirm against the system
// wallet before reporting failure.
if self.wallet.is_settled(&invoice.bolt11).await.unwrap_or(false) {
self.settle_invoice(&invoice.stripe_invoice_id, &tenant.pubkey, "nwc").await
} else {
Err(pay_error)
}
}
}
}
/// Catch an out-of-band payment we never recorded — e.g. the user paid the
/// invoice but the frontend failed to notify us. If the invoice's bolt11 has
/// settled on the robot wallet, mark it paid and return the refreshed Stripe
/// invoice; otherwise return it unchanged. Meant to run before presenting a
/// payable invoice so we never hand back one that's already been paid.
pub async fn reconcile_invoice(&self, invoice: &StripeInvoice) -> Result<StripeInvoice> {
if invoice.status != "open" {
return Ok(invoice.clone());
}
let Some(row) = self.query.get_lightning_invoice(&invoice.id).await? else {
return Ok(invoice.clone());
};
let settled = match self.wallet.is_settled(&row.bolt11).await {
Ok(settled) => settled,
Err(error) => {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to look up bolt11 invoice settlement"
);
return Ok(invoice.clone());
}
};
if !settled {
return Ok(invoice.clone());
}
if let Err(error) = self.settle_invoice(&invoice.id, &row.tenant_pubkey, "manual").await {
tracing::warn!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to record settled bolt11 invoice as paid"
);
}
// Re-fetch so the caller sees the now-paid status; fall back to the
// pre-reconcile snapshot if Stripe momentarily 404s.
Ok(self
.stripe
.get_invoice(&invoice.id)
.await?
.unwrap_or_else(|| invoice.clone()))
}
/// Record a settled bolt11 invoice as paid by `method`, mark the Stripe
/// invoice paid out of band, and clear any lingering NWC error. The Stripe
/// call is idempotent across retries, and `mark_lightning_invoice_paid` is
/// first-writer-wins, so the recorded method reflects whoever settled first.
async fn settle_invoice(
/// Idempotently produce a hosted Stripe Checkout session for an open invoice,
/// reusing an unsettled, unexpired one if present — the on-session card
/// counterpart to [`Self::ensure_bolt11_for_invoice`]. Checkout lets the
/// tenant clear a 3D Secure challenge the off-session card charge can't. On
/// success Stripe returns the tenant to their account page, where collection
/// is reconciled (here and on the dunning poll) once the session reads paid.
pub async fn ensure_checkout_for_invoice(
&self,
stripe_invoice_id: &str,
tenant_pubkey: &str,
method: &str,
) -> Result<()> {
self.command
.mark_lightning_invoice_paid(stripe_invoice_id, method)
tenant: &Tenant,
invoice: &Invoice,
) -> Result<Checkout> {
let now = chrono::Utc::now().timestamp();
// Never open a Checkout for an invoice that's already resolved.
if invoice.paid_at.is_some() || invoice.voided_at.is_some() {
return Err(anyhow!("invoice is not open"));
}
// Reuse a still-valid pending session so repeated clicks land on one page.
if let Some(existing) = query::get_checkout_for_invoice(&invoice.id).await?
&& now < existing.expires_at
{
if existing.settled_at.is_some() {
return Err(anyhow!(
"a checkout has already been settled for this invoice"
));
}
return Ok(existing);
}
// Stripe returns the tenant to their account page on success or cancel.
// The landing page reconciles the tenant, which now settles a paid
// Checkout out of band, so the URL needs no per-invoice marker.
let return_url = format!("{}/account", env::get().app_url);
let (session_id, url, expires_at) = self
.stripe
.create_checkout_session(
&tenant.stripe_customer_id,
&invoice.id,
invoice.amount,
"usd",
&return_url,
&return_url,
)
.await?;
self.stripe.pay_invoice_out_of_band(stripe_invoice_id).await?;
self.command.clear_tenant_nwc_error(tenant_pubkey).await?;
Ok(())
command::insert_checkout(&invoice.id, &session_id, &url, expires_at)
.await?
.ok_or_else(|| anyhow!("failed to insert checkout"))
}
// --- Stripe utils ---
/// Refresh stripe-related state for a tenant, returning the synced payment
/// method id (the tenant's existing one on failure). Fails gracefully.
pub async fn sync_stripe_customer(&self, tenant: &Tenant) -> Result<Option<String>> {
match self.sync_stripe_payment_method(tenant).await {
Ok(payment_method_id) => Ok(payment_method_id),
Err(error) => {
tracing::error!(tenant = %tenant.pubkey, error = ?error, "failed to sync payment method");
Ok(tenant.stripe_payment_method_id.clone())
}
}
}
/// Refresh the cached Stripe payment method from Stripe so collection can charge
/// it directly and the UI reflects cards added via the portal.
async fn sync_stripe_payment_method(&self, tenant: &Tenant) -> Result<Option<String>> {
let payment_method_id = self
.stripe
.get_saved_payment_method(&tenant.stripe_customer_id)
.await?;
if payment_method_id != tenant.stripe_payment_method_id {
command::set_tenant_stripe_payment_method(&tenant.pubkey, &payment_method_id).await?;
}
Ok(payment_method_id)
}
}
/// One tenant's monthly billing period containing some timestamp, anchored at
/// the tenant's `billing_anchor`. Half-open `[start, end)` so a moment at
/// exactly `end` belongs to the next period.
pub struct BillingPeriod {
pub start: i64,
pub end: i64,
}
impl BillingPeriod {
/// The period containing `chrono::Utc::now()` for `tenant`. `None` when the
/// tenant has no `billing_anchor` yet — i.e. no billable activity has been seen.
pub fn current(tenant: &Tenant) -> Option<Self> {
Self::at(tenant, chrono::Utc::now().timestamp())
}
/// The period containing `at` for `tenant`. `None` when the tenant has no
/// `billing_anchor` yet — i.e. no billable activity has been seen.
fn at(tenant: &Tenant, at: i64) -> Option<Self> {
use chrono::{DateTime, Months, Utc};
let anchor = tenant.billing_anchor?;
let anchor_dt = DateTime::<Utc>::from_timestamp(anchor, 0).unwrap_or_default();
// Walk forward in whole calendar months from the anchor until the next
// step would pass `at`, so boundaries track months (2831 days) rather
// than a fixed span of seconds.
let mut start = anchor_dt;
let mut months = 1u32;
while let Some(next) = anchor_dt.checked_add_months(Months::new(months)) {
if next.timestamp() > at {
break;
}
start = next;
months += 1;
}
// Derive `end` from the anchor (the next walk boundary), not `start + 1
// month`: month-add clamps to the last valid day, so re-adding from a
// clamped `start` lands short of `anchor + months` and leaves non-tiling
// gap days for day-2931 anchors. `months` is already that next boundary.
let end = anchor_dt
.checked_add_months(Months::new(months))
.unwrap_or(start);
Some(Self {
start: start.timestamp(),
end: end.timestamp(),
})
}
/// Fraction of this period still unused at `at`, in `[0.0, 1.0]`, for
/// prorating a mid-period charge or credit. The remaining time is rounded to
/// the nearest day so proration tracks whole days rather than exact seconds.
/// Rounding by the day keeps a relay created within the first half-day of a
/// period billed at the full plan amount instead of a few cents short.
fn fraction_remaining(&self, at: i64) -> f64 {
const DAY: i64 = 24 * 60 * 60;
let len = (self.end - self.start) as f64;
if len <= 0.0 {
return 1.0;
}
let remaining = ((self.end - at) + DAY / 2) / DAY * DAY;
(remaining as f64 / len).clamp(0.0, 1.0)
}
/// Prorate a minor-unit `amount` by the fraction of this period remaining
/// at `at`, rounded to the nearest unit.
fn prorate(&self, amount: i64, at: i64) -> i64 {
(amount as f64 * self.fraction_remaining(at)).round() as i64
}
}
+4 -2
View File
@@ -1,5 +1,7 @@
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);
@@ -18,14 +20,14 @@ 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
body.data
.amount
.parse::<f64>()
.map_err(|e| anyhow!("invalid BTC spot quote for {currency}: {e}"))
+857 -339
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)
}
+77
View File
@@ -0,0 +1,77 @@
use std::sync::LazyLock;
use std::time::Duration;
use anyhow::Result;
use regex::Regex;
pub static CUSTOM_DOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$").unwrap());
/// Returns true if `domain` either has a CNAME pointing to `expected` or
/// resolves to the same IPv4 addresses. The A-record fallback accepts
/// Cloudflare CNAME flattening and ALIAS/ANAME records, which external
/// resolvers see as A records rather than CNAMEs.
pub async fn domain_points_to(domain: &str, expected: &str) -> Result<bool> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let cnames = doh_query(&client, domain, "CNAME").await?;
let expected_norm = expected.trim_end_matches('.');
for r in &cnames {
if r.record_type == 5
&& r.data
.trim_end_matches('.')
.eq_ignore_ascii_case(expected_norm)
{
return Ok(true);
}
}
let domain_ips = doh_query(&client, domain, "A").await?;
let expected_ips = doh_query(&client, expected, "A").await?;
let domain_set: Vec<&str> = domain_ips
.iter()
.filter(|r| r.record_type == 1)
.map(|r| r.data.as_str())
.collect();
let expected_set: Vec<&str> = expected_ips
.iter()
.filter(|r| r.record_type == 1)
.map(|r| r.data.as_str())
.collect();
if domain_set.is_empty() || expected_set.is_empty() {
return Ok(false);
}
Ok(domain_set.iter().any(|ip| expected_set.contains(ip)))
}
#[derive(serde::Deserialize)]
struct DohResponse {
#[serde(rename = "Answer")]
answer: Option<Vec<DohRecord>>,
}
#[derive(serde::Deserialize)]
struct DohRecord {
#[serde(rename = "type")]
record_type: u16,
data: String,
}
const DOH_URL: &str = "https://cloudflare-dns.com/dns-query";
async fn doh_query(client: &reqwest::Client, name: &str, qtype: &str) -> Result<Vec<DohRecord>> {
let resp: DohResponse = client
.get(DOH_URL)
.query(&[("name", name), ("type", qtype)])
.header("Accept", "application/dns-json")
.send()
.await?
.json()
.await?;
Ok(resp.answer.unwrap_or_default())
}
+23 -9
View File
@@ -1,12 +1,31 @@
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_host: String,
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,
@@ -26,23 +45,21 @@ pub struct Env {
pub livekit_api_key: String,
pub livekit_api_secret: String,
pub stripe_secret_key: String,
pub stripe_webhook_secret: String,
pub stripe_price_basic: String,
pub stripe_price_growth: String,
/// Parsed from `robot_secret`; used for nostr signing and nip44 encryption.
pub keys: Keys,
}
impl Env {
pub fn load() -> Self {
fn load() -> Self {
let keys = Keys::parse(&require_str("ROBOT_SECRET"))
.expect("ROBOT_SECRET is not a valid nostr secret key");
Self {
server_host: require_str("SERVER_HOST"),
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"),
@@ -62,9 +79,6 @@ impl Env {
livekit_api_key: require_str("LIVEKIT_API_KEY"),
livekit_api_secret: require_str("LIVEKIT_API_SECRET"),
stripe_secret_key: require_str("STRIPE_SECRET_KEY"),
stripe_webhook_secret: require_str("STRIPE_WEBHOOK_SECRET"),
stripe_price_basic: require_str("STRIPE_PRICE_BASIC"),
stripe_price_growth: require_str("STRIPE_PRICE_GROWTH"),
keys,
}
}
+373 -276
View File
@@ -1,306 +1,403 @@
//! 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::env::Env;
use crate::command;
use crate::db;
use crate::domains;
use crate::env;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
use crate::query::Query;
use crate::query;
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;
#[derive(Clone)]
pub struct Infra {
env: Env,
query: Query,
command: Command,
const DOMAIN_VERIFY_INTERVAL_SECS: u64 = 30;
const DOMAIN_REVERIFY_INTERVAL_SECS: u64 = 3 * 60 * 60;
/// Poll for relays with an unverified custom domain and attempt DNS verification.
/// Runs for the life of the process; each cycle waits for all checks to finish
/// before sleeping, so cycles never overlap.
pub async fn start_domain_verification() {
loop {
if let Err(e) = verify_pending_custom_domains().await {
tracing::error!(error = %e, "domain verification poll failed");
}
tokio::time::sleep(Duration::from_secs(DOMAIN_VERIFY_INTERVAL_SECS)).await;
}
}
impl Infra {
pub fn new(query: Query, command: Command, env: &Env) -> Self {
Self {
env: env.clone(),
query,
command,
}
}
pub async fn start(self) {
let mut rx = self.command.notify.subscribe();
if let Err(error) = self.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) = self.handle_activity(&activity).await {
tracing::error!(error = %e, "infra handle_activity failed");
async fn verify_pending_custom_domains() -> Result<()> {
let relays = query::list_relays_with_unverified_custom_domain().await?;
for relay in relays {
let canonical = format!("{}.{}", relay.subdomain, env::get().relay_domain);
match domains::domain_points_to(&relay.custom_domain, &canonical).await {
Ok(true) => {
tracing::info!(
relay = %relay.id,
domain = %relay.custom_domain,
"custom domain verified",
);
if let Err(e) = command::verify_relay_custom_domain(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to mark domain verified");
continue;
}
// Fetch the updated relay and sync so Zooid learns the new host.
match query::get_relay(&relay.id).await {
Ok(Some(updated)) => sync_relay(&updated).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay.id, error = %e, "failed to fetch relay after domain verify")
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(missed = n, "infra lagged");
}
Ok(false) => {
tracing::debug!(
relay = %relay.id,
domain = %relay.custom_domain,
target = %canonical,
"custom domain not yet pointing to relay",
);
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "DNS check failed for custom domain");
}
}
}
Ok(())
}
if let Err(error) = self.reconcile_relay_state("lagged").await {
tracing::error!(error = %error, "failed to reconcile relay state after lag");
/// Poll verified custom domains every 3 hours and un-verify any whose DNS no
/// longer points at the relay. Triggers a Zooid sync so the relay reverts to
/// its canonical subdomain as its host.
pub async fn start_domain_reverification() {
loop {
tokio::time::sleep(Duration::from_secs(DOMAIN_REVERIFY_INTERVAL_SECS)).await;
if let Err(e) = check_verified_custom_domains().await {
tracing::error!(error = %e, "verified domain check poll failed");
}
}
}
async fn check_verified_custom_domains() -> Result<()> {
let relays = query::list_relays_with_verified_custom_domain().await?;
for relay in relays {
let canonical = format!("{}.{}", relay.subdomain, env::get().relay_domain);
match domains::domain_points_to(&relay.custom_domain, &canonical).await {
Ok(true) => {
tracing::debug!(
relay = %relay.id,
domain = %relay.custom_domain,
"verified custom domain still points to relay",
);
}
Ok(false) => {
tracing::warn!(
relay = %relay.id,
domain = %relay.custom_domain,
"verified custom domain no longer points to relay; removing verification",
);
if let Err(e) = command::unverify_relay_custom_domain(&relay.id).await {
tracing::error!(relay = %relay.id, error = %e, "failed to un-verify custom domain");
continue;
}
match query::get_relay(&relay.id).await {
Ok(Some(updated)) => sync_relay(&updated).await,
Ok(None) => {}
Err(e) => {
tracing::error!(relay = %relay.id, error = %e, "failed to fetch relay after domain un-verify")
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}
async fn handle_activity(&self, 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(());
}
if activity.activity_type == "fail_relay_sync" {
self.schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
return Ok(());
}
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
return Ok(());
};
self.sync_relay(&relay).await;
Ok(())
}
async fn reconcile_relay_state(&self, source: &str) -> Result<()> {
let relays = self.query.list_relays_pending_sync().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(source, relay_count = relays.len(), "reconciling pending relay state");
for relay in relays {
if relay.sync_error.trim().is_empty() {
self.sync_relay(&relay).await;
} else {
self.schedule_relay_sync_retry(&relay.id, source).await?;
}
}
Ok(())
}
async fn schedule_relay_sync_retry(&self, 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 = self.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(());
};
tracing::info!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
let relay_id = relay_id.to_string();
let infra = self.clone();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
match infra.query.get_relay(&relay_id).await {
Ok(Some(relay)) => infra.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(&self, relay: &Relay) {
match self.try_sync_relay(relay).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::warn!(relay = %relay.id, error = %e, "DNS check failed for verified custom domain");
}
}
}
Ok(())
}
async fn try_sync_relay(&self, 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
&& self
.query
.get_latest_activity_for_resource_and_type(&relay.id, "complete_relay_sync")
.await?
.is_none();
/// 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();
let mut body = serde_json::json!({
"host": format!("{}.{}", relay.subdomain, self.env.relay_domain),
"schema": relay.schema,
"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,
},
"policy": {
"public_join": relay.policy_public_join == 1,
"strip_signatures": relay.policy_strip_signatures == 1,
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": if relay.blossom_enabled == 1 {
serde_json::json!({
"enabled": true,
"adapter": "s3",
"s3": {
"endpoint": self.env.blossom_s3_endpoint,
"region": self.env.blossom_s3_region,
"bucket": self.env.blossom_s3_bucket,
"access_key": self.env.blossom_s3_access_key,
"secret_key": self.env.blossom_s3_secret_key,
"key_prefix": relay.schema,
},
})
} else {
serde_json::json!({ "enabled": false })
},
"livekit": if relay.livekit_enabled == 1 {
serde_json::json!({
"enabled": true,
"server_url": self.env.livekit_url,
"api_key": self.env.livekit_api_key,
"api_secret": self.env.livekit_api_secret,
})
} else {
serde_json::json!({ "enabled": false })
},
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
"member": { "can_invite": true },
},
});
// 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()),
);
}
let method = if is_new { HttpMethod::POST } else { HttpMethod::PATCH };
self.request(method, &format!("relay/{}", relay.id), Some(&body))
.await?;
Ok(())
if let Err(error) = reconcile_relay_state("startup").await {
tracing::error!(error = %error, "failed to reconcile relay state on startup");
}
pub async fn list_relay_members(&self, relay_id: &str) -> Result<Vec<String>> {
#[derive(serde::Deserialize)]
struct MembersResponse {
members: Vec<String>,
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,
}
let response = self
.request(HttpMethod::GET, &format!("relay/{relay_id}/members"), None)
.await?;
let parsed: MembersResponse = response.json().await?;
Ok(parsed.members)
}
// Internal utilities
/// 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(
&self,
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 = self.env.zooid_api_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let url = format!("{base}/{path}");
let auth = self.env.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)
}
}
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(());
}
if activity.activity_type == "fail_relay_sync" {
schedule_relay_sync_retry(&activity.resource_id, "activity").await?;
return Ok(());
}
let Some(relay) = query::get_relay(&activity.resource_id).await? else {
return Ok(());
};
sync_relay(&relay).await;
Ok(())
}
async fn reconcile_relay_state(source: &str) -> Result<()> {
let relays = query::list_relays_pending_sync().await?;
if relays.is_empty() {
return Ok(());
}
tracing::info!(
source,
relay_count = relays.len(),
"reconciling pending relay state"
);
for relay in relays {
if relay.sync_error.trim().is_empty() {
sync_relay(&relay).await;
} else {
schedule_relay_sync_retry(&relay.id, source).await?;
}
}
Ok(())
}
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(());
};
tracing::info!(
relay = relay_id,
source,
consecutive_failures,
delay_secs = delay.as_secs(),
"scheduled relay sync retry"
);
let relay_id = relay_id.to_string();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
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");
}
}
}
}
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();
let host = relay.host();
let mut body = serde_json::json!({
"host": host,
"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,
},
"policy": {
"public_join": relay.policy_public_join == 1,
"strip_signatures": relay.policy_strip_signatures == 1,
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"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 },
"member": { "can_invite": true },
},
});
// 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()),
);
}
let method = if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
};
request(method, &format!("relay/{}", relay.id), Some(&body)).await?;
Ok(())
}
/// 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)
}
+2 -1
View File
@@ -2,10 +2,11 @@ pub mod api;
pub mod billing;
pub mod bitcoin;
pub mod command;
pub mod db;
pub mod domains;
pub mod env;
pub mod infra;
pub mod models;
pub mod pool;
pub mod query;
pub mod robot;
pub mod routes;
+27 -21
View File
@@ -2,10 +2,11 @@ mod api;
mod billing;
mod bitcoin;
mod command;
mod db;
mod domains;
mod env;
mod infra;
mod models;
mod pool;
mod query;
mod robot;
mod routes;
@@ -16,14 +17,10 @@ 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::env::Env;
use crate::infra::Infra;
use crate::query::Query;
use crate::robot::Robot;
use crate::stripe::Stripe;
@@ -36,36 +33,45 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let env = Env::load();
env::init();
let pool = pool::create_pool(&env.database_url).await?;
let robot = Robot::new(&env).await?;
let stripe = Stripe::new(&env);
let query = Query::new(pool.clone(), &env);
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), &env);
let infra = Infra::new(query.clone(), command.clone(), &env);
let api = Api::new(query, command, billing.clone(), stripe, robot, infra.clone(), &env);
db::init().await?;
let parsed = env
let robot = Robot::new().await?;
let stripe = Stripe::new();
let billing = Billing::new(robot.clone());
let api = Api::new(billing.clone(), stripe, robot);
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));
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 {
infra::start_domain_verification().await;
});
tokio::spawn(async {
infra::start_domain_reverification().await;
});
tokio::spawn(async move {
billing.start().await;
});
let listener =
tokio::net::TcpListener::bind(format!("{}:{}", env.server_host, env.server_port)).await?;
let url = format!("0.0.0.0:{}", env::get().server_port);
let listener = tokio::net::TcpListener::bind(url).await?;
axum::serve(listener, app).await?;
Ok(())
}
+150 -24
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)]
@@ -19,42 +28,57 @@ pub struct Plan {
pub id: String,
pub name: String,
pub amount: i64,
pub hidden: bool,
pub members: Option<i64>,
pub blossom: bool,
pub livekit: bool,
pub stripe_price_id: Option<String>,
}
#[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 LightningInvoice {
pub stripe_invoice_id: String,
pub struct Activity {
pub id: String,
pub tenant_pubkey: String,
pub bolt11: String,
pub status: String,
pub paid_method: Option<String>,
pub expires_at: i64,
pub created_at: i64,
pub updated_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 plan_id: String,
pub status: String,
pub sync_error: String,
pub info_name: String,
@@ -67,17 +91,31 @@ pub struct Relay {
pub blossom_enabled: i64,
pub livekit_enabled: i64,
pub push_enabled: i64,
pub custom_domain: String,
pub custom_domain_verified: i64,
pub synced: i64,
pub created_at: i64,
}
impl Relay {
/// The relay's effective public host: its verified custom domain when set,
/// otherwise its canonical `<subdomain>.<relay_domain>`.
pub fn host(&self) -> String {
if self.custom_domain_verified == 1 && !self.custom_domain.is_empty() {
self.custom_domain.clone()
} else {
format!("{}.{}", self.subdomain, crate::env::get().relay_domain)
}
}
}
impl Default for Relay {
fn default() -> Self {
Self {
id: String::new(),
tenant: String::new(),
schema: String::new(),
tenant_pubkey: String::new(),
subdomain: String::new(),
plan: String::new(),
plan_id: String::new(),
status: RELAY_STATUS_ACTIVE.to_string(),
sync_error: String::new(),
info_name: String::new(),
@@ -90,7 +128,95 @@ impl Default for Relay {
blossom_enabled: 0,
livekit_enabled: 0,
push_enabled: 1,
custom_domain: String::new(),
custom_domain_verified: 0,
synced: 0,
created_at: 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>,
}
-48
View File
@@ -1,48 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub 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)
}
+299 -155
View File
@@ -1,8 +1,7 @@
use anyhow::Result;
use sqlx::SqlitePool;
use anyhow::{Result, anyhow};
use crate::env::Env;
use crate::models::{Activity, LightningInvoice, Plan, Relay, Tenant};
use crate::db::pool;
use crate::models::{Activity, Bolt11, Checkout, Invoice, InvoiceItem, Plan, Relay, Tenant};
fn select_tenant(tail: &str) -> String {
format!("SELECT * FROM tenant {tail}")
@@ -16,161 +15,306 @@ fn select_activity(tail: &str) -> String {
format!("SELECT * FROM activity {tail}")
}
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
env: Env,
// --- Plans ---
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
amount: 0,
hidden: false,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
amount: 500,
hidden: false,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
hidden: false,
members: None,
blossom: true,
livekit: true,
},
]
}
impl Query {
pub fn new(pool: SqlitePool, env: &Env) -> Self {
Self {
pool,
env: env.clone(),
}
}
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}"))
}
// Plans
// --- Tenants ---
pub fn list_plans(&self) -> 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(self.env.stripe_price_basic.clone()),
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
amount: 2500,
members: None,
blossom: true,
livekit: true,
stripe_price_id: Some(self.env.stripe_price_growth.clone()),
},
]
}
pub async fn list_tenants() -> Result<Vec<Tenant>> {
Ok(
sqlx::query_as::<_, Tenant>(&select_tenant("ORDER BY created_at DESC"))
.fetch_all(pool())
.await?,
)
}
pub fn get_plan(&self, plan_id: &str) -> Option<Plan> {
self.list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn is_paid_plan(&self, plan_id: &str) -> bool {
self.get_plan(plan_id).is_some_and(|p| p.amount > 0)
}
// Tenants
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(&select_tenant(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
pub async fn get_tenant(pubkey: &str) -> Result<Option<Tenant>> {
Ok(
sqlx::query_as::<_, Tenant>(&select_tenant("WHERE pubkey = ?"))
.bind(pubkey)
.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_tenant("WHERE stripe_customer_id = ?"))
.bind(stripe_customer_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Relays
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(""))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_pending_sync(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay(
"WHERE synced = 0 OR TRIM(sync_error) != ''",
))
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(&select_relay("WHERE tenant = ?"))
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(&select_relay("WHERE id = ?"))
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Invoices
pub async fn get_lightning_invoice(
&self,
stripe_invoice_id: &str,
) -> Result<Option<LightningInvoice>> {
let row = sqlx::query_as::<_, LightningInvoice>(
"SELECT * FROM lightning_invoice WHERE stripe_invoice_id = ?",
)
.bind(stripe_invoice_id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
// Activity
pub async fn list_activity_for_resource(&self, resource_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(&select_activity("WHERE resource_id = ? ORDER BY created_at DESC"))
.bind(resource_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_latest_activity_for_resource_and_type(
&self,
resource_id: &str,
activity_type: &str,
) -> Result<Option<Activity>> {
let row = 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(&self.pool)
.await?;
Ok(row)
}
.fetch_optional(pool())
.await?,
)
}
// --- Relays ---
pub async fn list_relays() -> Result<Vec<Relay>> {
Ok(
sqlx::query_as::<_, Relay>(&select_relay("ORDER BY created_at DESC"))
.fetch_all(pool())
.await?,
)
}
pub async fn list_relays_with_unverified_custom_domain() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(
"WHERE custom_domain != '' AND custom_domain_verified = 0",
))
.fetch_all(pool())
.await?)
}
pub async fn list_relays_with_verified_custom_domain() -> Result<Vec<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(
"WHERE custom_domain != '' AND custom_domain_verified = 1",
))
.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 = ? ORDER BY created_at DESC",
))
.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(pool())
.await?)
}
pub async fn get_relay_by_verified_custom_domain(domain: &str) -> Result<Option<Relay>> {
Ok(sqlx::query_as::<_, Relay>(&select_relay(
"WHERE custom_domain = ? AND custom_domain_verified = 1",
))
.bind(domain)
.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?)
}
+34 -23
View File
@@ -5,11 +5,13 @@ use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::env::Env;
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 {
env: Env,
outbox_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
dm_cache: std::sync::Arc<Mutex<HashMap<String, CacheEntry>>>,
}
@@ -21,9 +23,9 @@ struct CacheEntry {
}
impl Robot {
pub async fn new(env: &Env) -> Result<Self> {
/// Build the robot and publish its Nostr identity (profile and relay lists).
pub async fn new() -> Result<Self> {
let robot = Self {
env: env.clone(),
outbox_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
dm_cache: std::sync::Arc::new(Mutex::new(HashMap::new())),
};
@@ -33,7 +35,7 @@ impl Robot {
}
async fn make_client(&self, relays: &[String]) -> Result<Client> {
let client = Client::new(self.env.keys.clone());
let client = Client::new(env::get().keys.clone());
for relay in relays {
client.add_relay(relay).await?;
}
@@ -41,29 +43,27 @@ impl Robot {
Ok(client)
}
async fn publish_identity(
&self,
) -> Result<()> {
async fn publish_identity(&self) -> Result<()> {
let mut metadata = Metadata::new();
if !self.env.robot_name.is_empty() {
metadata = metadata.name(&self.env.robot_name);
if !env::get().robot_name.is_empty() {
metadata = metadata.name(&env::get().robot_name);
}
if !self.env.robot_description.is_empty() {
metadata = metadata.about(&self.env.robot_description);
if !env::get().robot_description.is_empty() {
metadata = metadata.about(&env::get().robot_description);
}
if !self.env.robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&self.env.robot_picture)?);
if !env::get().robot_picture.is_empty() {
metadata = metadata.picture(Url::parse(&env::get().robot_picture)?);
}
let outbox_client = self.make_client(&self.env.robot_outbox_relays).await?;
let indexer_client = self.make_client(&self.env.robot_indexer_relays).await?;
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 = self.env.robot_outbox_relays
let outbox_tags = env::get()
.robot_outbox_relays
.iter()
.map(|r| Tag::parse(["r", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -71,7 +71,8 @@ impl Robot {
.send_event_builder(EventBuilder::new(Kind::Custom(10002), "").tags(outbox_tags))
.await?;
let messaging_tags = self.env.robot_messaging_relays
let messaging_tags = env::get()
.robot_messaging_relays
.iter()
.map(|r| Tag::parse(["relay", r.as_str()]))
.collect::<std::result::Result<Vec<_>, _>>()?;
@@ -82,6 +83,7 @@ impl Robot {
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() {
@@ -97,7 +99,9 @@ impl Robot {
let recipient_pubkey = PublicKey::parse(recipient)?;
let client = self.make_client(&dm_relays).await?;
client.send_private_msg(recipient_pubkey, message, []).await?;
client
.send_private_msg(recipient_pubkey, message, [])
.await?;
Ok(())
}
@@ -108,7 +112,7 @@ impl Robot {
let pubkey = PublicKey::parse(recipient)?;
let filter = Filter::new().author(pubkey).kind(Kind::Custom(10002));
let client = self.make_client(&self.env.robot_indexer_relays).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();
@@ -125,11 +129,18 @@ 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(&self.env.robot_indexer_relays).await.ok()?;
let events = client.fetch_events(filter, Duration::from_secs(5)).await.ok()?;
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
+28
View File
@@ -0,0 +1,28 @@
use std::collections::HashMap;
use axum::{extract::Query, http::StatusCode, response::IntoResponse};
use crate::env;
use crate::query;
/// Caddy on-demand TLS "ask" endpoint. Returns 200 if Caddy should provision a
/// cert for the domain, 404 if not. No authentication: Caddy calls this
/// internally before issuing a certificate, passing the domain as a `?domain=`
/// query parameter.
pub async fn check_domain(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let Some(domain) = params.get("domain") else {
return StatusCode::BAD_REQUEST;
};
let domain = domain.trim_end_matches('.');
let relay_domain = &env::get().relay_domain;
if domain == relay_domain.as_str() || domain.ends_with(&format!(".{relay_domain}")) {
return StatusCode::OK;
}
match query::get_relay_by_verified_custom_domain(domain).await {
Ok(Some(_)) => StatusCode::OK,
_ => StatusCode::NOT_FOUND,
}
}
+100 -46
View File
@@ -3,84 +3,138 @@ 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_tenant_invoices(
pub async fn list_invoices(
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.require_admin(&auth)?;
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await
.map_err(internal)?;
ok(invoices)
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 Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
.await
.map_err(internal)?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
ok(invoice)
}
pub async fn get_lightning_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 Some(invoice) = api.stripe.get_invoice(&id).await.map_err(internal)? else {
return Err(not_found("invoice not found"));
};
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(&invoice.customer)
let invoice = query::get_invoice(&id)
.await
.map_err(internal)?
else {
return Err(not_found("invoice not found"));
};
.ok_or_else(|| not_found("invoice not found"))?;
api.require_admin_or_tenant(&auth, &tenant.pubkey)?;
api.require_admin_or_tenant(&auth, &invoice.tenant_pubkey)?;
let invoice = api
.billing
.reconcile_invoice(&invoice)
// 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)?;
let lightning_invoice = api
.billing
.ensure_lightning_invoice(&invoice.id, &tenant.pubkey, invoice.amount_due, &invoice.currency)
api.billing
.reconcile_payments(&tenant, &invoice, true, false)
.await
.map_err(internal)?;
ok(serde_json::json!(lightning_invoice))
// 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)
}
+1 -1
View File
@@ -1,6 +1,6 @@
pub mod domains;
pub mod identity;
pub mod invoices;
pub mod plans;
pub mod relays;
pub mod stripe;
pub mod tenants;
+6 -7
View File
@@ -3,15 +3,14 @@ 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(api.query.list_plans())
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 {
match api.query.get_plan(&id) {
Some(plan) => ok(plan),
None => Err(not_found("plan not found")),
}
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)
}
+130 -86
View File
@@ -9,46 +9,14 @@ 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::domains;
use crate::env;
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,
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
unprocessable,
};
#[derive(Deserialize)]
pub struct CreateRelayRequest {
pub tenant: String,
pub subdomain: String,
pub plan: 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,
}
#[derive(Deserialize)]
pub struct UpdateRelayRequest {
pub subdomain: Option<String>,
pub plan: 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>,
}
use crate::{command, infra, query};
pub async fn list_relays(
State(api): State<Arc<Api>>,
@@ -56,7 +24,7 @@ pub async fn list_relays(
) -> ApiResult {
api.require_admin(&auth)?;
let relays = api.query.list_relays().await.map_err(internal)?;
let relays = query::list_relays().await.map_err(internal)?;
ok(relays)
}
@@ -66,7 +34,7 @@ pub async fn get_relay(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
ok(relay)
}
@@ -76,11 +44,9 @@ pub async fn list_relay_activity(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let activity = api
.query
.list_activity_for_resource(&id)
let activity = query::list_activity_for_resource(&id)
.await
.map_err(internal)?;
ok(serde_json::json!({ "activity": activity }))
@@ -92,18 +58,37 @@ pub async fn list_relay_members(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
api.require_admin_or_tenant(&auth, &relay.tenant_pubkey)?;
let members = fetch_relay_members(&api, &relay).await.map_err(internal)?;
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,
#[serde(default)]
pub custom_domain: String,
}
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)?;
api.require_admin_or_tenant(&auth, &payload.tenant_pubkey)?;
let relay_id = format!(
"{}_{}",
@@ -113,10 +98,9 @@ pub async fn create_relay(
let relay = Relay {
id: relay_id.clone(),
tenant: payload.tenant,
schema: relay_id.clone(),
tenant_pubkey: payload.tenant_pubkey,
subdomain: payload.subdomain,
plan: payload.plan,
plan_id: payload.plan_id,
info_name: payload.info_name,
info_icon: payload.info_icon,
info_description: payload.info_description,
@@ -127,18 +111,36 @@ pub async fn create_relay(
blossom_enabled: payload.blossom_enabled,
livekit_enabled: payload.livekit_enabled,
push_enabled: payload.push_enabled,
custom_domain: normalize_custom_domain(&payload.custom_domain)?,
..Default::default()
};
let relay = prepare_relay(&api, relay)?;
let relay = prepare_relay(relay)?;
api.command
.create_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 custom_domain: Option<String>,
}
pub async fn update_relay(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
@@ -146,16 +148,17 @@ pub async fn update_relay(
Json(payload): Json<UpdateRelayRequest>,
) -> ApiResult {
let mut relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
let current_plan = relay.plan.clone();
let requested_plan = payload.plan.clone();
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 = v;
relay.plan_id = v;
}
if let Some(v) = payload.info_name {
relay.info_name = v;
@@ -187,23 +190,28 @@ pub async fn update_relay(
if let Some(v) = payload.push_enabled {
relay.push_enabled = v;
}
// Changing the custom domain invalidates any prior verification, so clear the
// flag and let the background poller re-verify the new domain. An update that
// round-trips the existing domain (e.g. a feature toggle) leaves it untouched;
// the matching reset in `command::update_relay` keeps the persisted row right.
if let Some(v) = payload.custom_domain {
let normalized = normalize_custom_domain(&v)?;
if normalized != relay.custom_domain {
relay.custom_domain = normalized;
relay.custom_domain_verified = 0;
}
}
let relay = prepare_relay(&api, relay)?;
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 = api
.query
.get_plan(&relay.plan)
.expect("validated plan must exist");
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(&api, &relay)
.await
.map_err(internal)?
.len() as i64;
let current_members = fetch_relay_members(&relay).await.map_err(internal)?.len() as i64;
if current_members > limit {
let message = format!(
@@ -215,10 +223,10 @@ pub async fn update_relay(
}
}
api.command
.update_relay(&relay)
command::update_relay(&relay)
.await
.map_err(map_relay_write_error)?;
ok(relay)
}
@@ -228,20 +236,21 @@ pub async fn deactivate_relay(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
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"));
return Err(bad_request(
"relay-is-inactive",
"relay is already inactive",
));
}
api.command
.deactivate_relay(&relay)
.await
.map_err(internal)?;
command::deactivate_relay(&relay).await.map_err(internal)?;
ok(())
}
@@ -251,7 +260,7 @@ pub async fn reactivate_relay(
Path(id): Path<String>,
) -> ApiResult {
let relay = api.get_relay_or_404(&id).await?;
api.require_admin_or_tenant(&auth, &relay.tenant)?;
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"));
@@ -261,18 +270,19 @@ pub async fn reactivate_relay(
return Err(bad_request("relay-is-active", "relay is already active"));
}
api.command.activate_relay(&relay).await.map_err(internal)?;
command::activate_relay(&relay).await.map_err(internal)?;
ok(())
}
// --- helpers ----------------------------------------------------------------
async fn fetch_relay_members(api: &Api, relay: &Relay) -> Result<Vec<String>> {
async fn fetch_relay_members(relay: &Relay) -> Result<Vec<String>> {
if relay.synced == 0 {
return Ok(Vec::new());
}
api.infra.list_relay_members(&relay.id).await
infra::list_relay_members(&relay.id).await
}
const RESERVED_SUBDOMAINS: [&str; 3] = ["api", "admin", "internal"];
@@ -280,19 +290,26 @@ 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());
fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
/// 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()) {
|| RESERVED_SUBDOMAINS.contains(&relay.subdomain.as_str())
{
return Err(unprocessable("invalid-subdomain", "subdomain is invalid"));
}
let plan = api
.query
.get_plan(&relay.plan)
.ok_or_else(|| unprocessable("invalid-plan", "plan not found"))?;
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"));
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);
@@ -306,6 +323,33 @@ fn prepare_relay(api: &Api, mut relay: Relay) -> Result<Relay, ApiError> {
Ok(relay)
}
/// Normalize and validate a tenant custom domain. An empty string is allowed and
/// clears the domain. Rejects malformed hostnames and any domain under the
/// platform's relay domain, which belong in the subdomain field instead.
fn normalize_custom_domain(domain: &str) -> Result<String, ApiError> {
let domain = domain.trim().to_lowercase();
if !domain.is_empty() {
if !domains::CUSTOM_DOMAIN_RE.is_match(&domain) {
return Err(unprocessable(
"invalid-domain",
"domain must be a valid hostname (e.g. relay.example.com)",
));
}
let relay_domain = &env::get().relay_domain;
if domain == *relay_domain || domain.ends_with(&format!(".{relay_domain}")) {
return Err(unprocessable(
"reserved-domain",
"use the subdomain field for domains under the platform's relay domain",
));
}
}
Ok(domain)
}
/// 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")
-350
View File
@@ -1,350 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use axum::{
body::Bytes,
extract::{Path, Query as QueryParams, State},
http::HeaderMap,
};
use serde::Deserialize;
use crate::api::{Api, AuthedPubkey};
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT};
use crate::web::{ApiResult, bad_request, internal, ok};
const MANUAL_LIGHTNING_PAYMENT_DM: &str = "Payment is due for your relay subscription. Please visit the application to complete a manual Lightning payment.";
const NWC_ERROR_DM_PREFIX: &str = "NWC auto-payment failed:";
const NWC_ERROR_DM_MAX_CHARS: usize = 240;
#[derive(Deserialize)]
pub struct StripeSessionParams {
return_url: Option<String>,
}
pub async fn create_stripe_session(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
Path(pubkey): Path<String>,
QueryParams(params): QueryParams<StripeSessionParams>,
) -> ApiResult {
api.require_admin_or_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 }))
}
/// Stripe webhook endpoint. Authenticated via `Stripe-Signature` verification
/// on the raw body, not via NIP-98, so it does not use `AuthedPubkey`.
pub async fn stripe_webhook(
State(api): State<Arc<Api>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult {
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let payload = std::str::from_utf8(&body)
.map_err(|_| bad_request("bad-request", "invalid payload"))?;
handle_webhook(&api, payload, signature)
.await
.map_err(|e| bad_request("webhook-error", &e.to_string()))?;
ok(())
}
// --- Webhook event handlers ---
//
// These translate verified Stripe events into domain actions. The Stripe HTTP
// calls and Lightning/NWC payment orchestration they invoke live in
// [`crate::stripe`] and [`crate::billing`] respectively.
async fn handle_webhook(api: &Api, payload: &str, signature: &str) -> Result<()> {
let event = api.stripe.get_webhook_event(payload, signature)?;
let obj = &event.data.object;
match event.event_type.as_str() {
"invoice.created" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let amount_due = obj["amount_due"].as_i64().unwrap_or(0);
let currency = obj["currency"].as_str().unwrap_or("usd");
let stripe_invoice_id = obj["id"].as_str().unwrap_or_default();
handle_invoice_created(api, customer, amount_due, currency, stripe_invoice_id).await?;
}
"invoice.paid" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_paid(api, customer).await?;
}
"invoice.payment_failed" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_payment_failed(api, customer).await?;
}
"invoice.overdue" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_invoice_overdue(api, customer).await?;
}
"customer.subscription.updated" => {
let customer = obj["customer"].as_str().unwrap_or_default();
let status = obj["status"].as_str().unwrap_or_default();
handle_subscription_updated(api, customer, status).await?;
}
"customer.subscription.deleted" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_subscription_deleted(api, customer).await?;
}
"payment_method.attached" => {
let customer = obj["customer"].as_str().unwrap_or_default();
handle_payment_method_attached(api, customer).await?;
}
_ => {}
}
Ok(())
}
async fn handle_invoice_created(
api: &Api,
stripe_customer_id: &str,
amount_due: i64,
currency: &str,
stripe_invoice_id: &str,
) -> Result<()> {
if amount_due == 0 {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoice = api
.billing
.ensure_lightning_invoice(stripe_invoice_id, &tenant.pubkey, amount_due, currency)
.await?;
let mut nwc_error_for_dm: Option<String> = None;
// 1. NWC auto-pay: if the tenant has a nwc_url
if !tenant.nwc_url.is_empty() {
match api.billing.pay_invoice_nwc(&tenant, &invoice).await {
Ok(()) => return Ok(()),
Err(e) => {
let error_msg = format!("{e}");
api.command
.set_tenant_nwc_error(&tenant.pubkey, &error_msg)
.await?;
tracing::warn!(
error = %e,
tenant_pubkey = %tenant.pubkey,
stripe_customer_id,
stripe_invoice_id,
"nwc auto-payment failed for invoice.created"
);
nwc_error_for_dm = summarize_nwc_error_for_dm(&error_msg);
// Fall through to card / manual payment
}
}
}
// 2. Card on file: if the tenant has a payment method, Stripe charges automatically
if api
.stripe
.has_payment_method(&tenant.stripe_customer_id)
.await?
{
return Ok(());
}
// 3. Manual payment: send a DM
let dm_message = manual_lightning_payment_dm(nwc_error_for_dm.as_deref());
api.robot.send_dm(&tenant.pubkey, &dm_message).await?;
Ok(())
}
async fn handle_invoice_paid(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_some() {
api.command.clear_tenant_past_due(&tenant.pubkey).await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_DELINQUENT && api.query.is_paid_plan(&relay.plan) {
api.command.activate_relay(&relay).await?;
}
}
}
Ok(())
}
async fn handle_invoice_payment_failed(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
if tenant.past_due_at.is_none() {
api.command.set_tenant_past_due(&tenant.pubkey).await?;
api.robot
.send_dm(
&tenant.pubkey,
"Your payment has failed. Your relays may be deactivated if not resolved within a week.",
)
.await?;
}
Ok(())
}
async fn handle_subscription_updated(
api: &Api,
stripe_customer_id: &str,
status: &str,
) -> Result<()> {
if status != "canceled" && status != "unpaid" {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
Ok(())
}
async fn handle_subscription_deleted(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
api.command
.clear_tenant_subscription(&tenant.pubkey)
.await?;
Ok(())
}
async fn handle_invoice_overdue(api: &Api, stripe_customer_id: &str) -> Result<()> {
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let relays = api.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && api.query.is_paid_plan(&relay.plan) {
api.command.mark_relay_delinquent(&relay).await?;
}
}
api.robot
.send_dm(
&tenant.pubkey,
"Your paid relays have been deactivated due to non-payment.",
)
.await?;
Ok(())
}
async fn handle_payment_method_attached(api: &Api, stripe_customer_id: &str) -> Result<()> {
if stripe_customer_id.is_empty() {
return Ok(());
}
let Some(tenant) = api
.query
.get_tenant_by_stripe_customer_id(stripe_customer_id)
.await?
else {
return Ok(());
};
let invoices = api
.stripe
.list_invoices(&tenant.stripe_customer_id)
.await?;
for invoice in &invoices {
if invoice.status != "open" || invoice.amount_due == 0 {
continue;
}
if let Err(error) = api.stripe.pay_invoice(&invoice.id).await {
tracing::error!(
error = %error,
stripe_invoice_id = %invoice.id,
"failed to retry card payment for outstanding invoice"
);
}
}
Ok(())
}
fn summarize_nwc_error_for_dm(error: &str) -> Option<String> {
let normalized = error.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
return None;
}
if normalized.chars().count() <= NWC_ERROR_DM_MAX_CHARS {
return Some(normalized);
}
let prefix_len = NWC_ERROR_DM_MAX_CHARS.saturating_sub(3);
let mut truncated = normalized.chars().take(prefix_len).collect::<String>();
truncated.push_str("...");
Some(truncated)
}
fn manual_lightning_payment_dm(nwc_error: Option<&str>) -> String {
match nwc_error {
Some(error) if !error.is_empty() => {
format!("{MANUAL_LIGHTNING_PAYMENT_DM}\n\n{NWC_ERROR_DM_PREFIX} {error}")
}
_ => MANUAL_LIGHTNING_PAYMENT_DM.to_string(),
}
}
+166 -31
View File
@@ -2,24 +2,30 @@ use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::api::{Api, AuthedPubkey};
use crate::models::Tenant;
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_subscription_id: Option<String>,
pub past_due_at: Option<i64>,
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 {
@@ -28,40 +34,50 @@ impl From<Tenant> for 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_subscription_id: t.stripe_subscription_id,
past_due_at: t.past_due_at,
stripe_payment_method_id: t.stripe_payment_method_id,
churned_at: t.churned_at,
}
}
}
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn list_tenants(
State(api): State<Arc<Api>>,
AuthedPubkey(auth): AuthedPubkey,
) -> ApiResult {
api.require_admin(&auth)?;
let tenants = api.query.list_tenants().await.map_err(internal)?;
let tenants = query::list_tenants().await.map_err(internal)?;
ok(tenants
.into_iter()
.map(TenantResponse::from)
.collect::<Vec<_>>())
}
/// Creates the tenant row for the calling pubkey. Idempotent: if the tenant
/// already exists (including a unique-constraint race) we return the existing
/// row.
/// 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) = api.query.get_tenant(&pubkey).await.map_err(internal)? {
if let Some(t) = query::get_tenant(&pubkey).await.map_err(internal)? {
return ok(TenantResponse::from(t));
}
@@ -84,10 +100,10 @@ pub async fn create_tenant(
..Default::default()
};
match api.command.create_tenant(&tenant).await {
match command::create_tenant(&tenant).await {
Ok(()) => ok(TenantResponse::from(tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
match api.query.get_tenant(&pubkey).await {
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)),
@@ -97,14 +113,9 @@ pub async fn create_tenant(
}
}
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))
#[derive(Deserialize)]
pub struct UpdateTenantRequest {
pub nwc_url: Option<String>,
}
pub async fn update_tenant(
@@ -121,14 +132,15 @@ pub async fn update_tenant(
if nwc_url.is_empty() {
tenant.nwc_url = String::new();
} else {
tenant.nwc_url = api.env.encrypt(&nwc_url).map_err(internal)?;
tenant.nwc_url = env::get().encrypt(&nwc_url).map_err(internal)?;
}
}
api.command.update_tenant(&tenant).await.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,
@@ -136,10 +148,133 @@ pub async fn list_tenant_relays(
) -> ApiResult {
api.require_admin_or_tenant(&auth, &pubkey)?;
let relays = api
.query
.list_relays_for_tenant(&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 }))
}
+152 -265
View File
@@ -7,89 +7,25 @@
use anyhow::{Result, anyhow};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::BTreeMap;
use crate::env::Env;
use crate::env;
const STRIPE_API: &str = "https://api.stripe.com/v1";
// Webhooks
const WEBHOOK_TOLERANCE_SECS: i64 = 300;
#[derive(serde::Deserialize)]
pub struct StripeWebhookEvent {
#[serde(rename = "type")]
pub event_type: String,
pub data: StripeWebhookEventData,
}
#[derive(serde::Deserialize)]
pub struct StripeWebhookEventData {
pub object: serde_json::Value,
}
// API return types
#[derive(serde::Deserialize)]
pub struct StripeSubscription {
pub id: String,
pub status: String,
#[serde(deserialize_with = "deserialize_list")]
pub items: Vec<StripeSubscriptionItem>,
}
#[derive(serde::Deserialize)]
pub struct StripeSubscriptionItem {
pub id: String,
pub price: StripePrice,
#[serde(default = "default_quantity")]
pub quantity: i64,
}
#[derive(serde::Deserialize)]
pub struct StripePrice {
pub id: String,
}
#[derive(serde::Deserialize, serde::Serialize, Clone)]
pub struct StripeInvoice {
pub id: String,
pub customer: String,
pub status: String,
pub amount_due: i64,
pub currency: String,
}
#[derive(serde::Deserialize)]
struct StripeList<T> {
data: Vec<T>,
}
fn deserialize_list<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de>,
{
Ok(<StripeList<T> as serde::Deserialize>::deserialize(deserializer)?.data)
}
fn default_quantity() -> i64 {
1
}
// Stripe struct and impl
#[derive(Clone)]
pub struct Stripe {
env: Env,
http: reqwest::Client,
}
impl Default for Stripe {
fn default() -> Self {
Self::new()
}
}
impl Stripe {
pub fn new(env: &Env) -> Self {
pub fn new() -> Self {
Self {
env: env.clone(),
http: reqwest::Client::new(),
}
}
@@ -99,23 +35,17 @@ impl Stripe {
fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.get(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
.bearer_auth(&env::get().stripe_secret_key)
}
fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.post(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
}
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
self.http
.delete(format!("{STRIPE_API}{path}"))
.bearer_auth(&self.env.stripe_secret_key)
.bearer_auth(&env::get().stripe_secret_key)
}
fn idempotency_key(&self, parts: &[&str]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_secret_key.as_bytes())
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 {
@@ -128,6 +58,8 @@ impl Stripe {
// --- 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")
@@ -144,157 +76,154 @@ impl Stripe {
Ok(customer_id.to_string())
}
// --- Subscriptions ---
pub async fn get_subscription(
&self,
subscription_id: &str,
) -> Result<Option<StripeSubscription>> {
let body = self
.get(&format!("/subscriptions/{subscription_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
/// Stripe requires at least one item to create a subscription, so the desired
/// items are sent inline here; [`crate::billing`] reconciles from there.
pub async fn create_subscription(
&self,
customer_id: &str,
items: &BTreeMap<String, i64>,
) -> Result<StripeSubscription> {
let mut form: Vec<(String, String)> = vec![
("customer".to_string(), customer_id.to_string()),
(
"collection_method".to_string(),
"charge_automatically".to_string(),
),
];
let mut key_parts: Vec<String> =
vec!["create_subscription".to_string(), customer_id.to_string()];
for (index, (price_id, quantity)) in items.iter().enumerate() {
form.push((format!("items[{index}][price]"), price_id.clone()));
form.push((format!("items[{index}][quantity]"), quantity.to_string()));
key_parts.push(format!("{price_id}={quantity}"));
}
let key_refs: Vec<&str> = key_parts.iter().map(String::as_str).collect();
Ok(self
.post("/subscriptions")
.header("Idempotency-Key", self.idempotency_key(&key_refs))
.form(&form)
.send_ok()
.await?
.json()
.await?)
}
pub async fn create_subscription_item(
&self,
subscription_id: &str,
price_id: &str,
quantity: i64,
) -> Result<()> {
let quantity = quantity.to_string();
self.post("/subscription_items")
.header(
"Idempotency-Key",
self.idempotency_key(&["create_subscription_item", subscription_id, price_id]),
)
.form(&[
("subscription", subscription_id),
("price", price_id),
("quantity", quantity.as_str()),
])
.send_ok()
.await?;
Ok(())
}
pub async fn set_subscription_item_quantity(&self, item_id: &str, quantity: i64) -> Result<()> {
self.post(&format!("/subscription_items/{item_id}"))
.form(&[("quantity", quantity.to_string())])
.send_ok()
.await?;
Ok(())
}
pub async fn delete_subscription_item(&self, item_id: &str) -> Result<()> {
self.delete(&format!("/subscription_items/{item_id}"))
.send_ok()
.await?;
Ok(())
}
pub async fn cancel_subscription(&self, subscription_id: &str) -> Result<()> {
self.delete(&format!("/subscriptions/{subscription_id}"))
.send_ok()
.await?;
Ok(())
}
// --- Invoices ---
pub async fn list_invoices(&self, customer_id: &str) -> Result<Vec<StripeInvoice>> {
let list: StripeList<StripeInvoice> = self
.get("/invoices")
.query(&[("customer", customer_id)])
.send_ok()
.await?
.json()
.await?;
Ok(list.data)
}
pub async fn get_invoice(&self, invoice_id: &str) -> Result<Option<StripeInvoice>> {
let body = self
.get(&format!("/invoices/{invoice_id}"))
.send_optional_json()
.await?;
body.map(serde_json::from_value)
.transpose()
.map_err(Into::into)
}
pub async fn pay_invoice(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice", invoice_id]),
)
.send_ok()
.await?;
Ok(())
}
pub async fn pay_invoice_out_of_band(&self, invoice_id: &str) -> Result<()> {
self.post(&format!("/invoices/{invoice_id}/pay"))
.header(
"Idempotency-Key",
self.idempotency_key(&["pay_invoice_oob", invoice_id]),
)
.form(&[("paid_out_of_band", "true")])
.send_ok()
.await?;
Ok(())
}
// --- Payment methods ---
pub async fn has_payment_method(&self, customer_id: &str) -> Result<bool> {
/// 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().is_some_and(|a| !a.is_empty()))
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,
@@ -314,47 +243,13 @@ impl Stripe {
.map(str::to_string)
.ok_or_else(|| anyhow!("missing portal session url"))
}
// --- Webhooks ---
pub fn get_webhook_event(&self, payload: &str, signature: &str) -> Result<StripeWebhookEvent> {
let mut timestamp = None;
let mut sig = None;
for part in signature.split(',') {
if let Some(t) = part.strip_prefix("t=") {
timestamp = Some(t);
} else if let Some(v) = part.strip_prefix("v1=") {
sig = Some(v);
}
}
let timestamp = timestamp.ok_or_else(|| anyhow!("missing webhook timestamp"))?;
let signature = sig.ok_or_else(|| anyhow!("missing webhook signature"))?;
let signed_payload = format!("{timestamp}.{payload}");
let mut mac = Hmac::<Sha256>::new_from_slice(self.env.stripe_webhook_secret.as_bytes())
.map_err(|e| anyhow!("invalid webhook secret: {e}"))?;
mac.update(signed_payload.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if expected != signature {
return Err(anyhow!("webhook signature mismatch"));
}
let ts: i64 = timestamp
.parse()
.map_err(|_| anyhow!("bad webhook timestamp"))?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > WEBHOOK_TOLERANCE_SECS {
return Err(anyhow!("webhook timestamp outside tolerance"));
}
Ok(serde_json::from_str(payload)?)
}
}
// Stripe request util
trait StripeRequest {
async fn send_ok(self) -> Result<reqwest::Response>;
async fn send_json(self) -> Result<serde_json::Value>;
async fn send_optional_json(self) -> Result<Option<serde_json::Value>>;
}
impl StripeRequest for reqwest::RequestBuilder {
@@ -365,14 +260,6 @@ impl StripeRequest for reqwest::RequestBuilder {
async fn send_json(self) -> Result<serde_json::Value> {
Ok(self.send_ok().await?.json().await?)
}
async fn send_optional_json(self) -> Result<Option<serde_json::Value>> {
let resp = self.send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
Ok(Some(error_for_status(resp).await?.json().await?))
}
}
/// Give callers an actionable message instead of a bare "400 Bad Request"
+6 -4
View File
@@ -4,6 +4,9 @@ use nwc::prelude::{
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,
@@ -11,10 +14,9 @@ pub struct Wallet {
impl Wallet {
pub fn from_url(url: &str) -> Result<Self> {
let url = url
.parse::<NostrWalletConnectURI>()
.map_err(|_| anyhow!("invalid NWC URL"))?;
Ok(Self { url })
Ok(Self {
url: url.parse::<NostrWalletConnectURI>()?,
})
}
pub async fn make_invoice(
+5 -1
View File
@@ -71,7 +71,11 @@ pub fn err(status: StatusCode, code: &str, message: &str) -> ApiError {
}
pub fn unauthorized(reason: impl Display) -> ApiError {
err(StatusCode::UNAUTHORIZED, "unauthorized", &reason.to_string())
err(
StatusCode::UNAUTHORIZED,
"unauthorized",
&reason.to_string(),
)
}
pub fn forbidden(message: &str) -> ApiError {
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Substitute the real config into the prebuilt frontend bundle, then run the
# backend and static server, exiting (so the orchestrator restarts us) if either
# process dies.
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:-}"
VITE_PLATFORM_LOGO="${PLATFORM_LOGO:-/caravel.png}"
# 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" \
-e "s|__VITE_PLATFORM_LOGO__|$(esc "$VITE_PLATFORM_LOGO")|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 -L -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
+6
View File
@@ -1,5 +1,11 @@
# 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
# Platform logo shown in UI (path under public/, or an absolute URL)
VITE_PLATFORM_LOGO=/caravel.png
-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,64 @@
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 CopyNpub from "@/components/CopyNpub"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={props.invoice.tenant_pubkey} class="text-xs text-gray-500" />}
>
<span class="text-xs text-gray-500 truncate">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</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>
)
}
+92 -101
View File
@@ -1,23 +1,14 @@
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, PLATFORM_LOGO, PLATFORM_NAME } 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
}
function shortenPubkey(pubkey?: string) {
if (!pubkey) return ""
return `${pubkey.slice(0, 8)}${pubkey.slice(-6)}`
}
import BillingPrompts from "@/components/BillingPrompts"
import CopyNpub from "@/components/CopyNpub"
import { shortenNpub } from "@/lib/pubkey"
function SearchIcon() {
return (
@@ -32,77 +23,42 @@ function RelayIcon() {
return <img src={serverIcon} alt="" aria-hidden="true" class="h-5 w-5" />
}
function AdminIcon() {
return (
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
)
}
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)
const [adminOpen, setAdminOpen] = 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 displayName = createMemo(() => metadata()?.name || metadata()?.display_name)
const username = createMemo(() => {
const pubkey = account()?.pubkey
return displayName() || (pubkey ? shortenNpub(pubkey) : "")
})
const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey))
const nip05 = createMemo(() => profile().nip05 || "No NIP-05")
const initial = createMemo(() => (displayName() || account()?.pubkey || "?").slice(0, 1).toUpperCase())
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) => {
@@ -112,15 +68,28 @@ export default function AppShell(props: { children?: any }) {
: "block rounded-lg px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white"
}
const mobileNavItemClass = (href: string) => {
const active = location.pathname === href || location.pathname.startsWith(`${href}/`)
return active
? "block rounded-lg bg-gray-100 px-3 py-3 text-sm font-medium text-gray-900"
: "block rounded-lg px-3 py-3 text-sm text-gray-700 hover:bg-gray-100"
}
const openSearchModal = () => setSearchOpen(true)
const closeSearchModal = () => {
setSearchOpen(false)
setSearchQuery("")
}
const openAdminModal = () => setAdminOpen(true)
const closeAdminModal = () => setAdminOpen(false)
return (
<div class="min-h-screen bg-gray-50">
<aside class="hidden md:flex fixed inset-y-0 left-0 w-[260px] bg-slate-900 text-white flex-col z-10">
<A href="/" class="flex items-center gap-3 border-b border-white/15 px-7 py-5 text-lg font-bold text-white hover:bg-white/10">
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="h-7 w-7 rounded" />
<span class="truncate">{PLATFORM_NAME}</span>
</A>
<div class="flex-1 px-4 py-6">
<h2 class="px-3 text-xs font-semibold uppercase tracking-wider text-white/60">Resources</h2>
<ul class="mt-2 space-y-1">
@@ -143,14 +112,19 @@ export default function AppShell(props: { children?: any }) {
when={picture()}
fallback={
<div class="h-10 w-10 rounded-full bg-white/15 text-sm font-medium text-white flex items-center justify-center">
{username().slice(0, 1).toUpperCase()}
{initial()}
</div>
}
>
<img src={picture()} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="truncate text-sm font-medium text-white">{username()}</p>
<Show
when={displayName()}
fallback={<CopyNpub pubkey={account()?.pubkey ?? ""} class="text-sm font-medium text-white" />}
>
<p class="truncate text-sm font-medium text-white">{username()}</p>
</Show>
<p class="truncate text-xs text-white/60">{nip05()}</p>
</div>
</A>
@@ -158,40 +132,15 @@ 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={() => {
if (searchOpen()) closeSearchModal()
if (adminOpen()) closeAdminModal()
}}
>
<div class="flex h-16 items-center justify-between px-6">
@@ -210,11 +159,24 @@ export default function AppShell(props: { children?: any }) {
<A href="/relays" aria-label="Relays" class="rounded-lg p-2 text-gray-700 hover:bg-gray-100">
<RelayIcon />
</A>
<Show when={identity()?.is_admin}>
<button
type="button"
aria-label="Administration"
class="rounded-lg p-2 text-gray-700 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation()
openAdminModal()
}}
>
<AdminIcon />
</button>
</Show>
</div>
<A href="/account" aria-label="Account settings" class="rounded-full">
<Show
when={picture()}
fallback={<div class="h-9 w-9 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">{username().slice(0, 1).toUpperCase()}</div>}
fallback={<div class="h-9 w-9 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">{initial()}</div>}
>
<img src={picture()} alt="Profile" class="h-9 w-9 rounded-full object-cover" />
</Show>
@@ -269,7 +231,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>
)}
@@ -279,6 +241,35 @@ export default function AppShell(props: { children?: any }) {
</Show>
</div>
</Modal>
<Modal
open={adminOpen()}
onClose={closeAdminModal}
wrapperClass="fixed inset-x-0 top-0 bottom-16 z-30 flex items-end bg-black/40 p-0 md:hidden"
panelClass="w-full overflow-hidden rounded-t-2xl bg-white"
>
<div class="flex items-center justify-between border-b border-gray-200 p-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500">Administration</h2>
<button
type="button"
class="rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-100"
onClick={closeAdminModal}
>
Close
</button>
</div>
<ul class="space-y-1 p-4">
<For each={adminResources}>
{(item) => (
<li>
<A href={item.href} class={mobileNavItemClass(item.href)} onClick={closeAdminModal}>
{item.label}
</A>
</li>
)}
</For>
</ul>
</Modal>
</div>
)
}
+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>
)
}
+143
View File
@@ -0,0 +1,143 @@
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),
)
// A resource keeps its last value once its source goes falsy, so deepLinked()
// still returns the fetched invoice after the param is cleared. Gate on the
// param so clearing it actually closes the dialog (otherwise it can't be).
const deepLinkedInvoice = createMemo(() =>
searchParams.invoice ? deepLinked() : undefined,
)
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() ?? deepLinkedInvoice()}>
{(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()
}}
/>
</>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { copyToClipboard } from "@/lib/clipboard"
import { shortenNpub, toNpub } from "@/lib/pubkey"
// Renders a pubkey as a shortened npub followed by a small copy button. The button
// copies the full npub and toasts on success. The icon inherits the surrounding
// text color (currentColor + opacity) so it reads on both light and dark
// backgrounds, and the click stops propagation so it can sit inside links/cards
// without triggering them. Pass `class` to style the npub text.
export default function CopyNpub(props: { pubkey: string; class?: string }) {
const copy = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
void copyToClipboard(toNpub(props.pubkey), { successMessage: "npub copied to clipboard" })
}
return (
<span class="inline-flex items-center gap-1 min-w-0">
<span class={`truncate ${props.class ?? ""}`}>{shortenNpub(props.pubkey)}</span>
<button
type="button"
onClick={copy}
title="Copy npub"
aria-label="Copy npub"
class="shrink-0 opacity-60 transition-opacity hover:opacity-100"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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>
</span>
)
}
@@ -0,0 +1,73 @@
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 CopyNpub from "@/components/CopyNpub"
import { useProfileMetadata } from "@/lib/hooks"
import { formatPeriod, formatUsd } from "@/lib/format"
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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={props.invoice.tenant_pubkey} class="text-blue-600 group-hover:underline" />}
>
<span class="truncate text-blue-600 group-hover:underline">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</A>
</Field>
</dl>
</div>
)
}
+120 -81
View File
@@ -1,16 +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 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
@@ -24,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 [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
@@ -36,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")
@@ -51,38 +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")
} 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("")
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 (
<>
@@ -98,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"
@@ -117,53 +151,61 @@ 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>
<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"
>
Retry
</button>
</div>
{/* 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>
<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>
{/* 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>
</Show>
<div class="text-center pt-1">
<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={() => setShowPaymentSetup(true)}
class="text-sm text-blue-600 hover:text-blue-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"
>
Set up payment method instead
{checkout.redirecting() ? "Redirecting..." : `Pay ${amountLabel()} by card`}
</button>
</div>
</Show>
@@ -178,24 +220,19 @@ 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>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
class="mt-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Set up automatic payments
</button>
<Show when={!hasAutopay()}>
<button
type="button"
onClick={() => setShowPaymentSetup(true)}
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>
</Show>
</div>
</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
@@ -219,14 +256,16 @@ 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>
+29 -145
View File
@@ -1,8 +1,6 @@
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"
@@ -10,77 +8,41 @@ 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)
props.onSaved?.()
} 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)
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 once invoices are issued 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">
@@ -101,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. If an invoice is currently due, we will retry collection after card setup.</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>
)
}
+42 -6
View File
@@ -32,18 +32,33 @@ 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) {
// Hidden plans (e.g. the legacy "basic" tier) stay resolvable for billing via
// the backend but never show up in the public pricing table or plan selector.
// This is the only place the hidden flag is honored.
const visiblePlans = () => plans().filter((plan) => !plan.hidden)
// The full pricing page also shows the synthetic "Custom" card, so the column
// count tracks the real number of cards (literal classes so Tailwind keeps them).
const cardCount = () => visiblePlans().length + (props.selectable ? 0 : 1)
const gridCols: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
}
return (
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
<For each={plans()}>
<div class={`grid grid-cols-1 gap-6 items-start ${gridCols[cardCount()] ?? "lg:grid-cols-4"}`}>
<For each={visiblePlans()}>
{(plan) => {
const isPopular = plan.id === "basic"
const isSelected = () => props.selectable && props.selectedPlan === plan.id
const isSelected = () => props.selectable && props.selectedPlanId === plan.id
const card = (
<>
@@ -99,6 +114,27 @@ export default function PricingTable(props: PricingTableProps) {
)
}}
</For>
{!props.selectable && (
<div class="relative bg-white rounded-2xl p-8 border border-gray-200">
<h3 class="text-lg font-bold text-gray-900 mb-1">Custom</h3>
<div class="mb-8">
<span class="text-4xl font-extrabold text-gray-900">Let's talk</span>
</div>
<ul class="mb-8 text-sm text-gray-600 space-y-3">
<li class="flex items-start gap-2"><CheckIcon />White-labeled app</li>
<li class="flex items-start gap-2"><CheckIcon />Dedicated support</li>
<li class="flex items-start gap-2"><CheckIcon />Custom feature development</li>
</ul>
<a
href="https://cal.com/coracle.social/30min"
target="_blank"
rel="noopener noreferrer"
class="block w-full text-center py-2.5 px-4 text-sm rounded-xl font-semibold transition-colors border border-gray-200 text-gray-700 hover:bg-gray-50"
>
Contact us
</a>
</div>
)}
</div>
)
}
+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>
)
}
+119 -202
View File
@@ -1,29 +1,20 @@
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 { RELAY_DOMAIN } from "@/lib/subdomain"
import ConfirmDialog from "@/components/ConfirmDialog"
import CustomDomainModal from "@/components/relay/CustomDomainModal"
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, { StatusBadge } from "@/components/relay/RelayCardHeader"
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
import { setToastMessage } from "@/lib/state"
import { useProfileMetadata } from "@/lib/hooks"
import useMinLoading from "@/lib/useMinLoading"
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>
)
}
import useCustomDomain from "@/lib/useCustomDomain"
function DetailSection(props: { title: string; children: any }) {
return (
@@ -63,30 +54,32 @@ type RelayDetailCardProps = {
onToggleMediaStorage?: () => void
onToggleLivekitSupport?: () => void
onTogglePushNotifications?: () => void
onUpdatePlan?: (plan: PlanId) => Promise<void>
onUpdatePlan?: (planId: PlanId) => Promise<void>
enforcePlanLimits?: boolean
showPlanActions?: boolean
mutateRelay?: (relay: Relay) => void
}
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)
const [customDomainModalOpen, setCustomDomainModalOpen] = createSignal(false)
const { saving: cdSaving, verifying: cdVerifying, error: cdError, saveDomain, verifyDomain } =
useCustomDomain(() => props.relay.id, props.mutateRelay ?? (() => {}))
const cdVerifyingVisible = useMinLoading(cdVerifying)
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
@@ -107,18 +100,17 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
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
}
}
function openActionDialog(action: "deactivate" | "reactivate") {
setMenuOpen(false)
setPendingAction(action)
}
@@ -140,129 +132,95 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
setPendingAction(null)
}
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 (
<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 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"
<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}
onRequestManageCustomDomain={() => setCustomDomainModalOpen(true)}
/>
<hr class="border-gray-200" />
<div>
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">Custom Domain</h3>
<div class="space-y-3">
<div class="flex items-center justify-between gap-2">
<Show
when={r().custom_domain}
fallback={<span class="text-gray-400 text-sm">Not configured</span>}
>
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>
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{r().custom_domain}</span>
<Show when={r().custom_domain_verified === 1}>
<StatusBadge status="verified" />
</Show>
</div>
</Show>
</div>
</div>
<Show when={props.editHref && (props.onDeactivate || props.onReactivate)}>
<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)}
class="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => setCustomDomainModalOpen(true)}
>
<img src={menuDotsIcon} alt="" class="h-5 w-5" />
Update
</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={() => {
openActionDialog("deactivate")
}}
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={() => {
openActionDialog("reactivate")
}}
disabled={props.reactivating}
>
{props.reactivating ? "Reactivating..." : "Reactivate"}
</button>
</Show>
</div>
</div>
</Show>
<Show when={r().custom_domain && r().custom_domain_verified !== 1}>
<div class="rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 space-y-3">
<div class="flex items-start gap-2">
<span class="mt-0.5 text-yellow-600 text-sm shrink-0"></span>
<p class="text-sm font-medium text-yellow-800">
Not yet verified add this DNS record, then verify:
</p>
</div>
<div class="rounded border border-yellow-200 bg-white px-3 py-2 font-mono text-xs text-gray-700 break-all">
{r().custom_domain} CNAME {r().subdomain}.{RELAY_DOMAIN}
</div>
<p class="text-xs text-yellow-700">
For apex domains (e.g. example.com), use an ALIAS or ANAME record instead.
</p>
<Show when={cdError()}>
<p class="text-sm text-red-600">{cdError()}</p>
</Show>
<button
type="button"
onClick={verifyDomain}
disabled={cdVerifyingVisible()}
class="inline-flex items-center rounded-lg bg-yellow-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-yellow-700 disabled:opacity-50"
>
{cdVerifyingVisible() ? "Verifying…" : "Verify DNS record"}
</button>
</div>
</Show>
</div>
</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>
<CustomDomainModal
open={customDomainModalOpen()}
onClose={() => setCustomDomainModalOpen(false)}
relay={r}
saving={cdSaving}
error={cdError}
onSave={saveDomain}
/>
<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>
@@ -273,79 +231,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>
@@ -358,11 +280,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()}>
@@ -373,15 +290,15 @@ 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>
+37 -31
View File
@@ -1,23 +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 { validateSubdomainLabel } from "@/lib/subdomain"
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 ?? "")
@@ -27,7 +31,7 @@ export default function RelayForm(props: RelayFormProps) {
async function handleSubmit(e: Event) {
e.preventDefault()
if (!plan()) {
if (showPlanSelector() && !planId()) {
setToastMessage("Please select a plan")
return
}
@@ -43,7 +47,7 @@ export default function RelayForm(props: RelayFormProps) {
try {
await props.onSubmit({
plan: plan(),
plan_id: planId(),
info_name: name(),
subdomain: subdomain(),
info_icon: icon(),
@@ -56,7 +60,7 @@ export default function RelayForm(props: RelayFormProps) {
}
}
createEffect(() => setPlan(defaultPlanId()))
createEffect(() => setPlanId(defaultPlanId()))
createEffect(() => {
if (props.syncSubdomainWithName) {
@@ -84,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>
@@ -105,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().filter((p) => !p.hidden)}>
{(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()}
+45 -15
View File
@@ -1,6 +1,11 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import type { Relay } from "@/lib/api"
import { PlanBadge, StatusBadge } from "@/components/relay/RelayCardHeader"
import CopyNpub from "@/components/CopyNpub"
import { useProfileMetadata } from "@/lib/hooks"
import { RELAY_DOMAIN } from "@/lib/subdomain"
type RelayListItemProps = {
relay: Relay
@@ -9,28 +14,53 @@ 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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={props.relay.tenant_pubkey} class="text-xs text-gray-500" />}
>
<span class="text-xs text-gray-500 truncate">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</div>
</Show>
</div>
<Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
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}
<div class="flex items-center gap-2 shrink-0">
<Show
when={props.relay.sync_error}
fallback={<StatusBadge status={props.relay.status} />}
>
{props.relay.sync_error}
</span>
</Show>
<span
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}
>
Failed to sync
</span>
</Show>
<PlanBadge planId={props.relay.plan_id} />
</div>
</div>
</A>
</li>
+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,93 @@
import { Show, createEffect, createSignal } from "solid-js"
import Modal from "@/components/Modal"
import type { Relay } from "@/lib/api"
type Props = {
open: boolean
onClose: () => void
relay: () => Relay | undefined
saving: () => boolean
error: () => string | undefined
onSave: (domain: string) => Promise<void>
}
export default function CustomDomainModal(props: Props) {
const [input, setInput] = createSignal("")
createEffect(() => {
if (props.open) {
setInput(props.relay()?.custom_domain ?? "")
}
})
const current = () => props.relay()?.custom_domain ?? ""
const inputTrimmed = () => input().trim()
async function handleSubmit(e: Event) {
e.preventDefault()
await props.onSave(inputTrimmed())
if (!props.error()) props.onClose()
}
return (
<Modal
open={props.open}
onClose={props.onClose}
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
panelClass="w-full max-w-lg bg-white rounded-2xl shadow-xl p-6 space-y-5"
>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Custom domain</h2>
<button
type="button"
onClick={props.onClose}
class="text-gray-400 hover:text-gray-600 text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<form onSubmit={handleSubmit} class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Custom domain</label>
<input
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
placeholder="relay.example.com"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
autocomplete="off"
autocapitalize="none"
spellcheck={false}
/>
<p class="mt-1 text-xs text-gray-500">
Must be a domain you control, e.g. <span class="font-mono">relay.example.com</span>
</p>
</div>
<Show when={props.error()}>
<p class="text-sm text-red-600">{props.error()}</p>
</Show>
<div class="flex items-center gap-3 flex-wrap">
<button
type="submit"
disabled={props.saving() || !inputTrimmed() || inputTrimmed() === current()}
class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{props.saving() ? "Saving…" : "Save domain"}
</button>
<Show when={current()}>
<button
type="button"
onClick={() => props.onSave("")}
disabled={props.saving()}
class="inline-flex items-center rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{props.saving() ? "Removing…" : "Remove custom domain"}
</button>
</Show>
</div>
</form>
</Modal>
)
}
@@ -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,210 @@
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 CopyNpub from "@/components/CopyNpub"
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",
verified: "bg-green-50 text-green-700 border-green-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>
)
}
export function PlanBadge(props: { planId: string }) {
const styles = () => props.planId === "free"
? "bg-gray-100 text-gray-500 border-gray-200"
: "bg-blue-50 text-blue-700 border-blue-200"
return (
<span class={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${styles()}`}>
{props.planId}
</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
onRequestManageCustomDomain?: () => 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} />
<PlanBadge planId={r().plan_id} />
</div>
<a
href={`https://${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>
<Show
when={metadata()?.name || metadata()?.display_name}
fallback={<CopyNpub pubkey={r().tenant_pubkey} class="text-sm text-blue-600 group-hover:underline" />}
>
<span class="text-sm text-blue-600 group-hover:underline truncate">{metadata()?.name || metadata()?.display_name}</span>
</Show>
</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 || props.onRequestManageCustomDomain}>
<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(),
}}
>
<Show when={props.editHref}>
<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>
<Show when={props.onRequestManageCustomDomain}>
<button
type="button"
class="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => {
setMenuOpen(false)
props.onRequestManageCustomDomain?.()
}}
>
Manage custom domain
</button>
</Show>
<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>
</>
)
}

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