Make tenant creation implicit

This commit is contained in:
Jon Staab
2026-03-27 11:16:07 -07:00
parent 6415bcd7b7
commit 72b7a8db45
9 changed files with 39 additions and 79 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ See [spec](spec) for more details
All routes are NIP-98 protected. All routes are NIP-98 protected.
- `GET /identity` — get auth identity (`pubkey`, `is_admin`, `is_tenant`) - `GET /identity` — get auth identity (`pubkey`, `is_admin`)
- `GET /tenants` — list tenants (admin) - `GET /tenants` — list tenants (admin)
- `POST /tenants` — create current auth pubkey as tenant - `POST /tenants` — create current auth pubkey as tenant
- `GET /tenants/:pubkey` — get tenant (admin or same tenant) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
+2 -9
View File
@@ -13,7 +13,7 @@ Members:
Notes: Notes:
- Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request - Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request
- Each route is responsible for authorization using `self.is_admin(pubkey)` or `self.is_tenant(authorized_pubkey, tenant_pubkey)` - 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. - 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. - 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.
@@ -46,6 +46,7 @@ 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, one is created
- Return `data` is an `Identity` struct - Return `data` is an `Identity` struct
--- Tenant routes --- Tenant routes
@@ -62,14 +63,6 @@ Notes:
- Authorizes admin or matching tenant - Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `repo.get_tenant` - Return `data` is a single tenant struct from `repo.get_tenant`
## `async fn create_tenant(...) -> Response`
- Serves `POST /tenants`
- Authorizes anyone, but must be authorized
- Creates a new tenant using `repo.create_tenant` based on the authorized pubkey
- If tenant is a duplicate, return a `422` with `code=pubkey-exists`
- Return `data` is a single tenant struct. Use HTTP `201`.
## `async fn list_tenant_relays(...) -> Response` ## `async fn list_tenant_relays(...) -> Response`
- Serves `GET /tenants/:pubkey/relays` - Serves `GET /tenants/:pubkey/relays`
-1
View File
@@ -9,7 +9,6 @@ Identity is a description of a user.
- `pubkey` - the user's nostr pubkey - `pubkey` - the user's nostr pubkey
- `is_admin` - whether the user is an admin - `is_admin` - whether the user is an admin
- `is_tenant` - whether the user has an account
# Activity # Activity
+22 -47
View File
@@ -103,7 +103,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).post(create_tenant)) .route("/tenants", get(list_tenants))
.route("/tenants/:pubkey", get(get_tenant)) .route("/tenants/:pubkey", get(get_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/tenants/:pubkey/invoices", get(list_tenant_invoices)) .route("/tenants/:pubkey/invoices", get(list_tenant_invoices))
@@ -297,7 +297,6 @@ struct UpdateTenantBillingRequest {
struct IdentityResponse { struct IdentityResponse {
pubkey: String, pubkey: String,
is_admin: bool, is_admin: bool,
is_tenant: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -365,10 +364,16 @@ 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);
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
created_at: now_ts(),
billing_anchor: now_ts(),
};
let is_tenant = match state.api.repo.get_tenant(&pubkey).await { match state.api.repo.create_tenant(&tenant).await {
Ok(Some(_)) => true, Ok(()) => true,
Ok(None) => false, Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => true,
Err(e) => { Err(e) => {
return Ok(err( return Ok(err(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -383,7 +388,6 @@ async fn get_identity(
IdentityResponse { IdentityResponse {
pubkey, pubkey,
is_admin, is_admin,
is_tenant,
}, },
)) ))
} }
@@ -420,39 +424,6 @@ async fn get_tenant(
} }
} }
async fn create_tenant(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?;
let tenant = Tenant {
pubkey: pubkey.clone(),
nwc_url: String::new(),
created_at: now_ts(),
billing_anchor: now_ts(),
};
match state.api.repo.create_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::CREATED, tenant)),
Err(e) => {
if matches!(map_unique_error(&e), Some("pubkey-exists")) {
Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"pubkey-exists",
"tenant already exists",
))
} else {
Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
))
}
}
}
}
async fn list_relays( async fn list_relays(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -967,20 +938,24 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn create_tenant_and_duplicate_returns_expected_codes() { async fn identity_creates_tenant_if_missing() {
let repo = test_repo().await; let repo = test_repo().await;
let (admin_keys, tenant_keys, _) = keys(); let (admin_keys, tenant_keys, _) = keys();
let api = make_api(repo, pubkey_hex(&admin_keys)); let api = make_api(repo.clone(), pubkey_hex(&admin_keys));
let auth = auth_header(&tenant_keys, "https://api.test"); let auth = auth_header(&tenant_keys, "https://api.test");
let tenant_pubkey = pubkey_hex(&tenant_keys);
let (status, body) = request(&api, "POST", "/tenants", Some(auth.clone()), None).await; let (status, body) = request(&api, "GET", "/identity", Some(auth), None).await;
assert_eq!(status, StatusCode::CREATED); assert_eq!(status, StatusCode::OK);
assert_eq!(body["code"], "ok"); assert_eq!(body["code"], "ok");
assert_eq!(body["data"]["pubkey"], pubkey_hex(&tenant_keys)); assert_eq!(body["data"]["pubkey"], tenant_pubkey);
assert_eq!(body["data"]["is_admin"], false);
let (status, body) = request(&api, "POST", "/tenants", Some(auth), None).await; let tenant = repo
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); .get_tenant(&tenant_pubkey)
assert_eq!(body["code"], "pubkey-exists"); .await
.expect("lookup tenant after identity");
assert!(tenant.is_some());
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -20,7 +20,7 @@ The api allows the frontend to access the database. Most endpoints are authentic
- Calls `GET /identity` - Calls `GET /identity`
- Requires authentication - Requires authentication
- Returns `{ pubkey, is_admin, is_tenant }` for the authorized user - Returns `{ pubkey, is_admin }` for the authorized user
## Plan methods ## Plan methods
+4 -4
View File
@@ -14,7 +14,7 @@ import AdminTenantDetail from "./pages/admin/AdminTenantDetail"
import AdminRelayList from "./pages/admin/AdminRelayList" import AdminRelayList from "./pages/admin/AdminRelayList"
import AdminRelayDetail from "./pages/admin/AdminRelayDetail" import AdminRelayDetail from "./pages/admin/AdminRelayDetail"
import AdminRelayEdit from "./pages/admin/AdminRelayEdit" import AdminRelayEdit from "./pages/admin/AdminRelayEdit"
import { account, identity } from "./lib/hooks" import { identity } from "./lib/hooks"
function Layout(props: { children?: any }) { function Layout(props: { children?: any }) {
const location = useLocation() const location = useLocation()
@@ -32,7 +32,7 @@ function Layout(props: { children?: any }) {
return ( return (
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<Show when={account() && usesAppShell()} fallback={<main>{props.children}</main>}> <Show when={identity() && usesAppShell()} fallback={<main>{props.children}</main>}>
<AppShell>{props.children}</AppShell> <AppShell>{props.children}</AppShell>
</Show> </Show>
</div> </div>
@@ -56,8 +56,8 @@ export default function App() {
} }
} }
const requireAdmin = (Page: Component) => requireCondition(Page, () => Boolean(account() && identity()?.is_admin)) const requireAdmin = (Page: Component) => requireCondition(Page, () => Boolean(identity()?.is_admin))
const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(account() && identity()?.is_tenant)) const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity()))
return ( return (
<Router root={Layout}> <Router root={Layout}>
-1
View File
@@ -76,7 +76,6 @@ export type Invoice = {
export type Identity = { export type Identity = {
pubkey: string pubkey: string
is_admin: boolean is_admin: boolean
is_tenant: boolean
} }
export type CreateRelayInput = { export type CreateRelayInput = {
+1 -6
View File
@@ -26,6 +26,7 @@ import {
type CreateRelayInput, type CreateRelayInput,
type Relay, type Relay,
type Tenant, type Tenant,
type Identity,
type UpdateRelayInput, type UpdateRelayInput,
} from "./api" } from "./api"
@@ -141,12 +142,6 @@ export function primeProfiles(pubkeys: string[]) {
} }
} }
type Identity = {
pubkey: string
is_admin: boolean
is_tenant: boolean
}
export const useTenant = () => createResource(() => getTenant(account()!.pubkey)) export const useTenant = () => createResource(() => getTenant(account()!.pubkey))
export const useTenantRelays = () => createResource(() => listTenantRelays(account()!.pubkey)) export const useTenantRelays = () => createResource(() => listTenantRelays(account()!.pubkey))
+8 -9
View File
@@ -1,4 +1,4 @@
import { Show, createSignal, onCleanup } from "solid-js" import { Show, createSignal, createEffect, onCleanup } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccount } from "applesauce-accounts/accounts" import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccount } from "applesauce-accounts/accounts"
import { PasswordSigner } from "applesauce-signers" import { PasswordSigner } from "applesauce-signers"
@@ -56,16 +56,9 @@ export default function Login() {
let scanner: QrScanner | undefined let scanner: QrScanner | undefined
let abortController: AbortController | undefined let abortController: AbortController | undefined
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account) accountManager.addAccount(account)
accountManager.setActive(account) accountManager.setActive(account)
const dispose = createEffect(() => {
if (identity()) {
navigate("/relays")
dispose()
}
})
} }
async function loginWithNip07() { async function loginWithNip07() {
@@ -196,6 +189,12 @@ export default function Login() {
void navigator.clipboard.writeText(nostrConnectUri()) void navigator.clipboard.writeText(nostrConnectUri())
} }
createEffect(() => {
if (identity()) {
navigate("/relays")
}
})
onCleanup(() => { onCleanup(() => {
abortController?.abort() abortController?.abort()
scanner?.destroy() scanner?.destroy()