forked from coracle/caravel
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41602e21c2 | |||
| c47727b909 | |||
| 0705da8b09 | |||
| ca26d41eef |
+2
-2
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user