forked from coracle/caravel
Update spec and readme
This commit is contained in:
+220
-195
@@ -1,234 +1,259 @@
|
||||
# `pub struct Api`
|
||||
|
||||
Api manages the HTTP interface for the application
|
||||
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:
|
||||
|
||||
- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST`
|
||||
- `admins: Vec<String>` - a list of admin pubkeys from `ADMINS`
|
||||
- `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 `u` to `self.host`, not the incoming request
|
||||
- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant`
|
||||
- Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code.
|
||||
- Unsuccessful API responses should be of the form `{error, code}` with an appropriate http status code. `code` is a short error code (e.g. `duplicate-subdomain`) and `error` is a human-readable error message.
|
||||
- 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() -> Self`
|
||||
## `pub fn new(query, command, billing, stripe, robot, infra, env: &Env) -> Self`
|
||||
|
||||
- Reads environment and populates members
|
||||
- Stores the services and a clone of `env`
|
||||
|
||||
## `pub fn router(&self) -> Result<()>`
|
||||
## `pub fn router(self) -> Router`
|
||||
|
||||
- Returns an `axum::Router`
|
||||
- Wraps `self` in an `Arc` and returns an `axum::Router` with the routes below as state-bearing routes
|
||||
|
||||
--- Plan routes
|
||||
## `pub fn is_admin(&self, pubkey: &str) -> bool`
|
||||
|
||||
## `async fn list_plans(...) -> Response`
|
||||
- Whether `pubkey` is in `env.server_admin_pubkeys`
|
||||
|
||||
- Serves `GET /plans`
|
||||
- No authentication required
|
||||
- Return `data` is a list of plan structs from `Query::list_plans`
|
||||
## `pub fn require_admin(&self, authorized_pubkey: &str) -> Result<(), ApiError>`
|
||||
|
||||
## `async fn get_plan(...) -> Response`
|
||||
- `Ok` if `authorized_pubkey` is an admin, otherwise a `403`
|
||||
|
||||
- Serves `GET /plans/:id`
|
||||
- No authentication required
|
||||
- Return `data` is a single plan struct matching `id`
|
||||
- If plan does not exist, return `404` with `code=not-found`
|
||||
## `pub fn require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str) -> Result<(), ApiError>`
|
||||
|
||||
--- Identity routes
|
||||
- `Ok` if `authorized_pubkey` is an admin or equals `tenant_pubkey`, otherwise a `403`
|
||||
|
||||
## `async fn get_identity(...) -> Response`
|
||||
## `pub async fn get_tenant_or_404(&self, pubkey: &str) -> Result<Tenant, ApiError>`
|
||||
|
||||
- Serves `GET /identity`
|
||||
- Authorizes anyone, but must be authorized
|
||||
- Side-effect-free: returns `{ pubkey, is_admin }` only
|
||||
- Clients must call `POST /tenants` before any tenant-scoped write
|
||||
- Return `data` is an `Identity` struct
|
||||
- Looks up a tenant, returning `404` `not-found` if missing and `500` on a query error
|
||||
|
||||
--- Tenant routes
|
||||
## `pub async fn get_relay_or_404(&self, id: &str) -> Result<Relay, ApiError>`
|
||||
|
||||
## `async fn list_tenants(...) -> Response`
|
||||
- Looks up a relay, returning `404` `not-found` if missing and `500` on a query error
|
||||
|
||||
- Serves `GET /tenants`
|
||||
- Authorizes admin only
|
||||
- Return `data` is a list of `TenantResponse` structs (contains `nwc_is_set: bool` instead of `nwc_url`)
|
||||
# Authentication
|
||||
|
||||
## `async fn create_tenant(...) -> Response`
|
||||
## `pub struct AuthedPubkey(pub String)`
|
||||
|
||||
- Serves `POST /tenants`
|
||||
- Authorizes anyone, but must be authorized
|
||||
- No request body; target pubkey is derived from NIP-98 auth
|
||||
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
|
||||
- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
|
||||
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
|
||||
- If Stripe customer creation fails, return `code=stripe-customer-create-failed`
|
||||
- Always returns `200` (create-or-get is uniform)
|
||||
- Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
|
||||
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`.
|
||||
|
||||
## `async fn get_tenant(...) -> Response`
|
||||
## `fn extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String, ApiError>` / `fn decode_nip98_pubkey(&self, headers) -> Result<String>`
|
||||
|
||||
- Serves `GET /tenants/:pubkey`
|
||||
- Authorizes admin or matching tenant
|
||||
- Return `data` is a single `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
|
||||
|
||||
## `async fn update_tenant(...) -> Response`
|
||||
|
||||
- Serves `PUT /tenants/:pubkey`
|
||||
- Authorizes admin or matching tenant
|
||||
- Accepts `nwc_url` in the request body; encrypts it before storage using `cipher::encrypt`
|
||||
- Updates tenant using `command.update_tenant`
|
||||
- Return `data` is the updated `TenantResponse` struct (contains `nwc_is_set: bool` instead of `nwc_url`)
|
||||
|
||||
## `async fn list_tenant_relays(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/relays`
|
||||
- Authorizes admin or matching tenant
|
||||
- Return `data` is a list of relay structs from `query.list_relays_for_tenant`
|
||||
|
||||
--- Relay routes
|
||||
|
||||
## `async fn list_relays(...) -> Response`
|
||||
|
||||
- Serves `GET /relays`
|
||||
- Authorizes admin only
|
||||
- Return `data` is a list of relay structs from `query.list_relays`
|
||||
|
||||
## `async fn get_relay(...) -> Response`
|
||||
|
||||
- Serves `GET /relays/:id`
|
||||
- Authorizes admin or relay owner
|
||||
- Return `data` is a single relay struct from `query.get_relay`
|
||||
|
||||
## `async fn list_relay_members(...) -> Response`
|
||||
|
||||
- Serves `GET /relays/:id/members`
|
||||
- Authorizes admin or relay owner
|
||||
- For unsynced relays, returns an empty member list without calling zooid
|
||||
- For synced relays, proxies the member list from zooid via `infra`
|
||||
- Return `data` is `{ members }`
|
||||
|
||||
## `async fn create_relay(...) -> Response`
|
||||
|
||||
- Serves `POST /relays`
|
||||
- Authorizes admin or matching tenant pubkey in request body
|
||||
- Validates/prepares the relay data to be saved using `prepare_relay`
|
||||
- Creates a new relay using `command.create_relay`
|
||||
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
|
||||
- Return `data` is a single relay struct. Use HTTP `201`.
|
||||
|
||||
## `async fn update_relay(...) -> Response`
|
||||
|
||||
- Serves `PUT /relays/:id`
|
||||
- Authorizes admin or relay owner
|
||||
- Validates/prepares the relay data to be saved using `prepare_relay`
|
||||
- If the requested plan changes to a plan with a finite member limit and the current member count exceeds that limit, return a `422` with `code=member-limit-exceeded`
|
||||
- Updates the given relay using `command.update_relay`
|
||||
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
|
||||
- Return `data` is a single relay struct.
|
||||
|
||||
## `async fn list_relay_activity(...) -> Response`
|
||||
|
||||
- Serves `GET /relays/:id/activity`
|
||||
- Authorizes admin or relay owner
|
||||
- Get activity from `query.list_activity_for_resource`
|
||||
- Return `data` is `{activity}`
|
||||
|
||||
## `async fn deactivate_relay(...) -> Response`
|
||||
|
||||
- Serves `POST /relays/:id/deactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay status is `inactive` or `delinquent`, return a `400` with `code=relay-is-inactive`
|
||||
- Call `command.deactivate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
## `async fn reactivate_relay(...) -> Response`
|
||||
|
||||
- Serves `POST /relays/:id/reactivate`
|
||||
- Authorizes admin or relay owner
|
||||
- If relay is already active, return a `400` with `code=relay-is-active`
|
||||
- Call `command.activate_relay`
|
||||
- Return `data` is empty
|
||||
|
||||
--- Invoice routes
|
||||
|
||||
## `async fn list_tenant_invoices(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/invoices`
|
||||
- Authorizes admin or matching tenant
|
||||
- Looks up tenant by pubkey, fetches invoices from Stripe API using `stripe_customer_id`
|
||||
- Return `data` is a list of Stripe invoice objects: `{ id, status, amount_due, currency, hosted_invoice_url, period_start, period_end }`
|
||||
|
||||
## `async fn get_invoice(...) -> Response`
|
||||
|
||||
- Serves `GET /invoices/:id`
|
||||
- Fetches invoice from Stripe API by ID
|
||||
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
|
||||
- Return `data` is a single Stripe invoice object
|
||||
- If invoice does not exist, return `404` with `code=not-found`
|
||||
|
||||
## `async fn get_lightning_invoice(...) -> Response`
|
||||
|
||||
- Serves `GET /invoices/:id/bolt11`
|
||||
- Fetches invoice from Stripe API by ID
|
||||
- Looks up tenant by the invoice's `customer` field, authorizes admin or matching tenant
|
||||
- If invoice `status` is not `open`, return `400` with `code=invoice-not-open`
|
||||
- Creates a bolt11 Lightning invoice for the invoice's `amount_due` using `billing.create_bolt11(amount_due)`
|
||||
- Return `data` is `{ bolt11 }`
|
||||
|
||||
--- Stripe session route
|
||||
|
||||
## `async fn create_stripe_session(...) -> Response`
|
||||
|
||||
- Serves `GET /tenants/:pubkey/stripe/session`
|
||||
- Authorizes admin or matching tenant
|
||||
- Looks up tenant by pubkey
|
||||
- Creates a Stripe Customer Portal session for the tenant's `stripe_customer_id`
|
||||
- Return `data` is `{ url }` — the portal session URL
|
||||
|
||||
--- Stripe webhook route
|
||||
|
||||
## `async fn stripe_webhook(...) -> Response`
|
||||
|
||||
- Serves `POST /stripe/webhook`
|
||||
- No NIP-98 authentication — uses Stripe signature verification instead
|
||||
- Reads raw request body and `Stripe-Signature` header
|
||||
- Calls `billing.handle_webhook(payload, signature)`
|
||||
- Returns `200` on success, `400` on signature verification failure
|
||||
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
--- Utilities
|
||||
|
||||
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
|
||||
|
||||
- Parses `Authorization` header
|
||||
- Validates event kind (`27235`) and signature using `nostr_sdk`
|
||||
- Validates event `u` contains configured `HOST`
|
||||
- Intentionally does **not** enforce exact request URL/method/query matching
|
||||
- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache
|
||||
- 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 pubkey if header all checks pass
|
||||
- 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. Use `nostr_sdk` functionality where possible.
|
||||
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Uses `nostr_sdk` functionality where possible.
|
||||
|
||||
## `require_admin(&self, authorized_pubkey: &str)`
|
||||
# Routes
|
||||
|
||||
- Checks whether `authorized_pubkey` is in `self.admins`. If not, returns an forbidden error
|
||||
Handlers take `State<Arc<Api>>`, an optional `AuthedPubkey`, then path/query/body extractors, and return `ApiResult`.
|
||||
|
||||
## `require_admin_or_tenant(&self, authorized_pubkey: &str, tenant_pubkey: &str)`
|
||||
--- Identity
|
||||
|
||||
- Checks whether `authorized_pubkey` is an admin or matches `tenant_pubkey`
|
||||
## `get_identity` — `GET /identity`
|
||||
|
||||
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
|
||||
- Authenticated (any signer)
|
||||
- Side-effect-free: returns `{ pubkey, is_admin }`
|
||||
- Clients must call `POST /tenants` before any tenant-scoped write
|
||||
|
||||
- Validate `subdomain`
|
||||
- Validate that `plan` matches a known plan id from `Query::list_plans`
|
||||
- If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature`
|
||||
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
|
||||
- Populate `schema` if not already set
|
||||
- Populate missing fields using reasonable defaults
|
||||
--- 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 }`.
|
||||
|
||||
Reference in New Issue
Block a user