remove spec
This commit is contained in:
@@ -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`, 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, period_start, period_end }`
|
||||
|
||||
## `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). The DM includes a link to the dashboard payment view for this invoice — `{env.app_url}/account?invoice={stripe_invoice_id}` — where the tenant can review the invoice and pay by Lightning or card.
|
||||
|
||||
## `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 }`.
|
||||
@@ -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)`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,48 +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`
|
||||
- `app_url: String` - public base URL of the frontend app from `APP_URL`, with any trailing slash stripped; used to build tenant-facing links such as the invoice payment link in billing DMs
|
||||
- `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.
|
||||
@@ -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 `id`; 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
|
||||
@@ -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`
|
||||
@@ -1,103 +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
|
||||
- `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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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, period_start, period_end }` (the subset of invoice fields the API surfaces; `Serialize` + `Clone`)
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user