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`
- `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=<pubkey>` allowed for admin only)
+14 -3
View File
@@ -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`
+36 -21
View File
@@ -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<Response, ApiError> {
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<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 {
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<String>) -> Response {
@@ -203,6 +203,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show>
</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" />
<DetailSection title="Policy">
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api"
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>
)}
</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>
</A>
</li>
+4
View File
@@ -205,6 +205,10 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity")
}
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) {
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 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?.()
}