From 0705da8b09f68663161a64556996e1de53f07f1c Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 17 Apr 2026 16:50:40 -0700 Subject: [PATCH] Add tenant create endpoint --- backend/README.md | 4 +-- backend/spec/api.md | 17 +++++++++-- backend/src/api.rs | 57 +++++++++++++++++++++++------------- frontend/src/lib/api.ts | 4 +++ frontend/src/views/Login.tsx | 7 +++++ 5 files changed, 63 insertions(+), 26 deletions(-) diff --git a/backend/README.md b/backend/README.md index 1070de9..783a90d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -70,9 +70,9 @@ Public exceptions: - `GET /plans/:id` - `POST /stripe/webhook` (validated with Stripe signatures) -- `GET /identity` — get auth identity (`pubkey`, `is_admin`) +- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free - `GET /tenants` — list tenants (admin) -- `POST /tenants` — create current auth pubkey as tenant +- `POST /tenants` — idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise) - `GET /tenants/:pubkey` — get tenant (admin or same tenant) - `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant) - `GET /relays` — list relays (`?tenant=` allowed for admin only) diff --git a/backend/spec/api.md b/backend/spec/api.md index e9dc9a4..0fcbbf7 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -46,9 +46,8 @@ Notes: - Serves `GET /identity` - Authorizes anyone, but must be authorized -- If a tenant for the identity doesn't exist: - - Call the Stripe API to create a new customer - - Create a new tenant using `command.create_tenant` with payload and `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added. +- Side-effect-free: returns `{ pubkey, is_admin }` only +- Clients must call `POST /tenants` before any tenant-scoped write - Return `data` is an `Identity` struct --- Tenant routes @@ -59,6 +58,18 @@ Notes: - Authorizes admin only - Return `data` is a list of tenant structs from `query.list_tenants` +## `async fn create_tenant(...) -> Response` + +- Serves `POST /tenants` +- Authorizes anyone, but must be authorized +- No request body; target pubkey is derived from NIP-98 auth +- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB +- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added. +- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant +- If Stripe customer creation fails, return `code=stripe-customer-create-failed` +- Always returns `200` (create-or-get is uniform) +- Return `data` is a single `Tenant` struct + ## `async fn get_tenant(...) -> Response` - Serves `GET /tenants/:pubkey` diff --git a/backend/src/api.rs b/backend/src/api.rs index d39a040..7a9ed63 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -143,7 +143,7 @@ impl Api { .route("/identity", get(get_identity)) .route("/plans", get(list_plans)) .route("/plans/:id", get(get_plan)) - .route("/tenants", get(list_tenants)) + .route("/tenants", get(list_tenants).post(create_tenant)) .route("/tenants/:pubkey", get(get_tenant).put(update_tenant)) .route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/relays", get(list_relays).post(create_relay)) @@ -401,10 +401,17 @@ async fn get_identity( ) -> std::result::Result { let pubkey = state.api.extract_auth_pubkey(&headers)?; let is_admin = state.api.admins.iter().any(|a| a == &pubkey); + Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })) +} + +async fn create_tenant( + State(state): State, + headers: HeaderMap, +) -> std::result::Result { + let pubkey = state.api.extract_auth_pubkey(&headers)?; - // Ensure tenant exists. match state.api.query.get_tenant(&pubkey).await { - Ok(Some(_)) => {} + Ok(Some(t)) => Ok(ok(StatusCode::OK, t)), Ok(None) => { let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await { Ok(id) => id, @@ -428,27 +435,35 @@ async fn get_identity( }; match state.api.command.create_tenant(&tenant).await { - Ok(()) => {} - Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {} - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); + Ok(()) => Ok(ok(StatusCode::OK, tenant)), + Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => { + match state.api.query.get_tenant(&pubkey).await { + Ok(Some(t)) => Ok(ok(StatusCode::OK, t)), + Ok(None) => Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + "tenant row missing after unique-constraint race", + )), + Err(e) => Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )), + } } - }; - } - Err(e) => { - return Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )); + Err(e) => Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )), + } } + Err(e) => Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )), } - - Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin })) } async fn get_plan(Path(id): Path) -> Response { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4c23b6c..a5068a8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -205,6 +205,10 @@ export function getIdentity() { return callApi("GET", "/identity") } +export function createTenant() { + return callApi("POST", "/tenants") +} + export function getPlan(id: string) { return callApi("GET", `/plans/${id}`) } diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index 83958de..23bc9d3 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers" import QrScanner from "qr-scanner" import QRCode from "qrcode" import { accountManager, identity, PLATFORM_NAME } from "@/lib/state" +import { createTenant } from "@/lib/api" import useMinLoading from "@/components/useMinLoading" const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] @@ -69,6 +70,12 @@ export default function Login(props: LoginPageProps = {}) { async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { accountManager.addAccount(account) accountManager.setActive(account) + try { + await createTenant() + } catch (e) { + accountManager.removeAccount(account) + throw e + } await props.onAuthenticated?.() } -- 2.52.0