Compare commits

..

4 Commits

7 changed files with 82 additions and 27 deletions
+2 -2
View File
@@ -70,9 +70,9 @@ Public exceptions:
- `GET /plans/:id` - `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures) - `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) - `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) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant) - `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only) - `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
+14 -3
View File
@@ -46,9 +46,8 @@ Notes:
- Serves `GET /identity` - Serves `GET /identity`
- Authorizes anyone, but must be authorized - Authorizes anyone, but must be authorized
- If a tenant for the identity doesn't exist: - Side-effect-free: returns `{ pubkey, is_admin }` only
- Call the Stripe API to create a new customer - Clients must call `POST /tenants` before any tenant-scoped write
- 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.
- Return `data` is an `Identity` struct - Return `data` is an `Identity` struct
--- Tenant routes --- Tenant routes
@@ -59,6 +58,18 @@ Notes:
- Authorizes admin only - Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants` - 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` ## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey` - Serves `GET /tenants/:pubkey`
+36 -21
View File
@@ -143,7 +143,7 @@ impl Api {
.route("/identity", get(get_identity)) .route("/identity", get(get_identity))
.route("/plans", get(list_plans)) .route("/plans", get(list_plans))
.route("/plans/:id", get(get_plan)) .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", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay)) .route("/relays", get(list_relays).post(create_relay))
@@ -401,10 +401,17 @@ async fn get_identity(
) -> std::result::Result<Response, ApiError> { ) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?; let pubkey = state.api.extract_auth_pubkey(&headers)?;
let is_admin = state.api.admins.iter().any(|a| a == &pubkey); 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<AppState>,
headers: HeaderMap,
) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?;
// Ensure tenant exists.
match state.api.query.get_tenant(&pubkey).await { match state.api.query.get_tenant(&pubkey).await {
Ok(Some(_)) => {} Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
Ok(None) => { Ok(None) => {
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await { let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id, Ok(id) => id,
@@ -428,27 +435,35 @@ async fn get_identity(
}; };
match state.api.command.create_tenant(&tenant).await { match state.api.command.create_tenant(&tenant).await {
Ok(()) => {} Ok(()) => Ok(ok(StatusCode::OK, tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {} Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
Err(e) => { match state.api.query.get_tenant(&pubkey).await {
return Ok(err( Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
StatusCode::INTERNAL_SERVER_ERROR, Ok(None) => Ok(err(
"internal", StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(), "internal",
)); "tenant row missing after unique-constraint race",
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
} }
}; Err(e) => Ok(err(
} StatusCode::INTERNAL_SERVER_ERROR,
Err(e) => { "internal",
return Ok(err( &e.to_string(),
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<String>) -> Response { async fn get_plan(Path(id): Path<String>) -> Response {
@@ -203,6 +203,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</div> </div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<hr class="border-gray-200" /> <hr class="border-gray-200" />
<DetailSection title="Policy"> <DetailSection title="Policy">
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router" import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api" import type { Relay } from "@/lib/api"
type RelayListItemProps = { type RelayListItemProps = {
@@ -19,7 +20,17 @@ export default function RelayListItem(props: RelayListItemProps) {
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p> <p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)} )}
</div> </div>
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p> <Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
</span>
</Show>
</div> </div>
</A> </A>
</li> </li>
+4
View File
@@ -205,6 +205,10 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity") return callApi<undefined, Identity>("GET", "/identity")
} }
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) { export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`) return callApi<undefined, Plan>("GET", `/plans/${id}`)
} }
+7
View File
@@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner" import QrScanner from "qr-scanner"
import QRCode from "qrcode" import QRCode from "qrcode"
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state" import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
import { createTenant } from "@/lib/api"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] 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) { async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account) accountManager.addAccount(account)
accountManager.setActive(account) accountManager.setActive(account)
try {
await createTenant()
} catch (e) {
accountManager.removeAccount(account)
throw e
}
await props.onAuthenticated?.() await props.onAuthenticated?.()
} }