12 KiB
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 (seespec/env.md); supplies the NIP-98 host check, admin pubkeys, encryption, etc.query: Querycommand: Commandbilling: Billingstripe: Striperobot: Robotinfra: Infra
Notes:
- Authentication is done using NIP-98, comparing the event's
utag toenv.server_host, not the incoming request URL. - The shared
Apiis wrapped in anArcand handed to every handler asState<Arc<Api>>. - A handler that requires an authenticated caller takes an
AuthedPubkeyextractor; handlers that omit it are anonymous. - Each handler is responsible for authorization using
require_adminorrequire_admin_or_tenant. - Successful responses are
{ data, code: "ok" }; error responses are{ error, code }, both with an appropriate HTTP status (seespec/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
selfin anArcand returns anaxum::Routerwith the routes below as state-bearing routes
pub fn is_admin(&self, pubkey: &str) -> bool
- Whether
pubkeyis inenv.server_admin_pubkeys
pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>
Okifauthorized_pubkeyis an admin, otherwise a403
pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>
Okifauthorized_pubkeyis an admin or equalstenant_pubkey, otherwise a403
pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>
- Looks up a tenant, returning
404not-foundif missing and500on a query error
pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>
- Looks up a relay, returning
404not-foundif missing and500on 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
Authorizationheader, which must use theNostrscheme 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
utag to containenv.server_host(skipped whenserver_hostis empty) - Intentionally does not enforce exact request URL/method/query matching, and does not validate the
payloadtag/hash,created_atfreshness 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 /tenantsbefore any tenant-scoped write
--- Plans
list_plans — GET /plans
- No authentication required
datais the list of plans fromquery.list_plans
get_plan — GET /plans/:id
- No authentication required
datais the plan matchingid;404not-foundif it doesn't exist
--- Tenants
list_tenants — GET /tenants
- Admin only
datais a list ofTenantResponse(exposesnwc_is_set: boolinstead ofnwc_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 viastripe.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;datais aTenantResponse
get_tenant — GET /tenants/:pubkey
- Admin or matching tenant
datais aTenantResponse
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 viaenv.encrypt - Updates the tenant via
command.update_tenant datais the updatedTenantResponse
list_tenant_relays — GET /tenants/:pubkey/relays
- Admin or matching tenant
datais the tenant's relays fromquery.list_relays_for_tenant
--- Relays
list_relays — GET /relays
- Admin only
datais all relays fromquery.list_relays
get_relay — GET /relays/:id
404not-foundif the relay doesn't exist; then admin or relay ownerdatais 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 datais{ members }
create_relay — POST /relays
- Admin or the
tenantpubkey in the request body - Generates the relay
id/schema, validates and normalizes the relay viaprepare_relay, and creates it viacommand.create_relay - Duplicate subdomain →
422subdomain-exists datais the relay; HTTP201
update_relay — PUT /relays/:id
404if 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
422member-limit-exceeded - Updates via
command.update_relay; duplicate subdomain →422subdomain-exists datais the relay
list_relay_activity — GET /relays/:id/activity
404if missing; then admin or relay ownerdatais{ activity }fromquery.list_activity_for_resource
deactivate_relay — POST /relays/:id/deactivate
404if missing; then admin or relay owner- If status is
delinquent, return400relay-is-delinquent; if alreadyinactive, return400relay-is-inactive - Otherwise
command.deactivate_relay;datais empty
reactivate_relay — POST /relays/:id/reactivate
404if missing; then admin or relay owner- If status is
delinquent, return400relay-is-delinquent(a delinquent relay must be resolved through payment, not reactivated by the user); if alreadyactive, return400relay-is-active - Otherwise
command.activate_relay;datais 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 datais a list ofStripeInvoiceobjects:{ id, customer, status, amount_due, currency }
get_invoice — GET /invoices/:id
- Fetches the invoice from Stripe (
404not-foundif it doesn't exist) - Looks up the tenant by the invoice's
customer(404if none), then authorizes admin or matching tenant - Runs
billing.reconcile_invoice(marks it paid if its bolt11 already settled out of band) datais the (possibly refreshed)StripeInvoice
get_lightning_invoice — GET /invoices/:id/bolt11
- Fetches the invoice from Stripe (
404if it doesn't exist) and the tenant bycustomer(404if none), then authorizes admin or matching tenant - Runs
billing.reconcile_invoice, thenbilling.ensure_lightning_invoiceto get or (re)issue the bolt11 for the invoice'samount_due/currency datais theLightningInvoice(including itsbolt11)
--- Stripe portal
create_stripe_session — GET /tenants/:pubkey/stripe/session
- Admin or matching tenant; accepts an optional
return_urlquery parameter - Looks up the tenant and creates a Stripe Customer Portal session for its
stripe_customer_id datais{ url }— the portal session URL
--- Stripe webhook
stripe_webhook — POST /stripe/webhook
- No NIP-98 authentication — verified via the
Stripe-Signatureheader 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
200on 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:
- NWC auto-pay: if the tenant has a
nwc_url, runbilling.pay_invoice_nwc. On success, done. On failure, record the error viacommand.set_tenant_nwc_error, log it, summarize it for the eventual DM, and fall through. - Card on file: if
stripe.has_payment_method, do nothing — Stripe charges automatically for this attempt. - Manual payment: send a DM via
robot.send_dmtelling 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_atset, clear it (command.clear_tenant_past_due) and reactivate eachdelinquentrelay on a paid plan viacommand.activate_relay
invoice.payment_failed
- If the tenant doesn't already have
past_due_atset, 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
activerelay on a paid plandelinquent(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
canceledorunpaid, clearstripe_subscription_id(command.clear_tenant_subscription) and mark everyactivepaid relaydelinquent
customer.subscription.deleted
- Clear
stripe_subscription_id(command.clear_tenant_subscription)
payment_method.attached
- Retry Stripe collection (
stripe.pay_invoice) for everyopeninvoice withamount_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
subdomainagainst the allowed pattern and a reserved list (api,admin,internal) →422invalid-subdomain - Validates that
planmatches a known plan →422invalid-plan - If the relay enables
blossom/livekitbut the selected plan doesn't include it →422premium-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 }.