diff --git a/backend/README.md b/backend/README.md index 6e94422..4d91b1a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -61,6 +61,7 @@ See [spec](spec) for more details All routes are NIP-98 protected. +- `GET /identity` — get auth identity (`pubkey`, `is_admin`, `is_tenant`) - `GET /tenants` — list tenants (admin) - `POST /tenants` — create current auth pubkey as tenant - `GET /tenants/:pubkey` — get tenant (admin or same tenant) diff --git a/backend/spec/api.md b/backend/spec/api.md index 5c218e4..4eae22d 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -40,6 +40,14 @@ Notes: - Return `data` is a single plan struct matching `id` - If plan does not exist, return `404` with `code=not-found` +--- Identity routes + +## `async fn get_identity(...) -> Response` + +- Serves `GET /identity` +- Authorizes anyone, but must be authorized +- Return `data` is an `Identity` struct + --- Tenant routes ## `async fn list_tenants(...) -> Response` diff --git a/backend/spec/models.md b/backend/spec/models.md index d143311..7911ffc 100644 --- a/backend/spec/models.md +++ b/backend/spec/models.md @@ -3,6 +3,14 @@ This file describes the domain model. This description should be translated into - Fields marked as private should use `#[serde(skip_serializing)]` in their definition. - Fields marked as readonly should use `#[serde(skip_deserializing)]` in their definition. +# Identity + +Identity is a description of a user. + +- `pubkey` - the user's nostr pubkey +- `is_admin` - whether the user is an admin +- `is_tenant` - whether the user has an account + # Activity Activity is an audit log of all actions performed by a user or a worker process. This allows us to trace history to create invoices, synchronize actions to external services, and debug system behavior. diff --git a/backend/src/api.rs b/backend/src/api.rs index d727d9c..b7d2737 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -100,6 +100,7 @@ impl Api { }; Router::new() + .route("/identity", get(get_identity)) .route("/plans", get(list_plans)) .route("/plans/:id", get(get_plan)) .route("/tenants", get(list_tenants).post(create_tenant)) @@ -292,6 +293,13 @@ struct UpdateTenantBillingRequest { nwc_url: String, } +#[derive(Serialize)] +struct IdentityResponse { + pubkey: String, + is_admin: bool, + is_tenant: bool, +} + #[derive(Deserialize)] struct CreateRelayRequest { tenant: String, @@ -351,6 +359,35 @@ async fn list_plans( Ok(ok(StatusCode::OK, Repo::list_plans())) } +async fn get_identity( + State(state): State, + headers: HeaderMap, +) -> std::result::Result { + let pubkey = state.api.extract_auth_pubkey(&headers)?; + let is_admin = state.api.admins.iter().any(|a| a == &pubkey); + + let is_tenant = match state.api.repo.get_tenant(&pubkey).await { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => { + return Ok(err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal", + &e.to_string(), + )); + } + }; + + Ok(ok( + StatusCode::OK, + IdentityResponse { + pubkey, + is_admin, + is_tenant, + }, + )) +} + async fn get_plan( State(state): State, headers: HeaderMap, diff --git a/frontend/spec/lib/api.md b/frontend/spec/lib/api.md index 14cf0f6..0516968 100644 --- a/frontend/spec/lib/api.md +++ b/frontend/spec/lib/api.md @@ -16,6 +16,12 @@ The api allows the frontend to access the database. Most endpoints are authentic - Uses `makeAuth` to obtain a NIP 98 authorization header. - Calls the backend api and returns the decoded json or throws an `ApiError`. +## `function getIdentity()` + +- Calls `GET /identity` +- Requires authentication +- Returns `{ pubkey, is_admin, is_tenant }` for the authorized user + ## Plan methods ## `function listPlans()` diff --git a/frontend/spec/lib/hooks.md b/frontend/spec/lib/hooks.md index 274e648..7c55dd8 100644 --- a/frontend/spec/lib/hooks.md +++ b/frontend/spec/lib/hooks.md @@ -1,18 +1,24 @@ Hooks wrap data access and mutations so pages/components do not call API methods directly. +## `function useIdentity(source)` + +- Calls `getIdentity()` for the active pubkey +- Caches identity in module memory (not localStorage) +- Returns a Solid `createResource` tuple + ## `function useTenant()` -- Uses `createTenant()` then `getTenant(pubkey)` for the active account +- Uses `getTenant(pubkey)` for the active account - Returns a Solid `createResource` tuple ## `function useTenantRelays()` -- Uses `createTenant()` then `listTenantRelays(pubkey)` for the active account +- Uses `listTenantRelays(pubkey)` for the active account - Returns a Solid `createResource` tuple ## `function useTenantInvoices()` -- Uses `createTenant()` then `listTenantInvoices(pubkey)` for the active account +- Uses `listTenantInvoices(pubkey)` for the active account - Returns a Solid `createResource` tuple ## `function useRelay(relayId)` @@ -35,15 +41,9 @@ Hooks wrap data access and mutations so pages/components do not call API methods - Uses `getTenant(pubkey)` and `listTenantRelays(pubkey)` - Returns a Solid `createResource` tuple with `{ tenant, relays }` -## `function useAdminCheck(source)` - -- Uses `listTenants()` as an admin capability check -- Returns `{ is_admin: true }` on success -- Returns `{ is_admin: false }` on `403` - ## `function createRelayForActiveTenant(input)` -- Uses `createTenant()` then `createRelay({ tenant: activePubkey, ...input })` +- Uses `createRelay({ tenant: activePubkey, ...input })` ## `function updateActiveTenantBilling(nwc_url)` diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0f9a4cf..8279a99 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,8 +1,8 @@ -import { A, useLocation } from "@solidjs/router" +import { A, useLocation, useNavigate } from "@solidjs/router" import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" import Fuse from "fuse.js" import { eventStore, primeProfiles, useActiveAccount, useProfilePicture } from "../lib/nostr" -import { useAdminCheck, useTenantRelays, type Relay } from "../lib/hooks" +import { useIdentity, useTenantRelays, type Relay } from "../lib/hooks" import serverIcon from "../assets/server.svg" import Modal from "./Modal" @@ -32,15 +32,16 @@ function RelayIcon() { export default function AppShell(props: { children?: any }) { const location = useLocation() + const navigate = useNavigate() const account = useActiveAccount() const picture = useProfilePicture(() => account()?.pubkey) - const [adminCheck] = useAdminCheck(() => account()?.id) + const [identity] = useIdentity(() => account()?.pubkey) const [tenantRelays] = useTenantRelays() const [profile, setProfile] = createSignal({}) const [searchOpen, setSearchOpen] = createSignal(false) const [searchQuery, setSearchQuery] = createSignal("") - const isAdmin = createMemo(() => !!adminCheck()?.is_admin) + const isAdmin = createMemo(() => !!identity()?.is_admin) const username = createMemo(() => profile().name || profile().display_name || shortenPubkey(account()?.pubkey)) const nip05 = createMemo(() => profile().nip05 || "No NIP-05") const searchedRelays = createMemo(() => { @@ -82,6 +83,12 @@ export default function AppShell(props: { children?: any }) { }) }) + createEffect(() => { + const currentIdentity = identity() + if (!currentIdentity || currentIdentity.is_admin || currentIdentity.is_tenant || location.pathname === "/") return + navigate("/", { replace: true }) + }) + const myResources = [{ href: "/relays", label: "My Relays" }] const adminResources = [ { href: "/admin/tenants", label: "Tenants" }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2a53d10..19e7fc1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -71,6 +71,12 @@ export type Invoice = { period_end: number } +export type Identity = { + pubkey: string + is_admin: boolean + is_tenant: boolean +} + export type CreateRelayInput = { tenant?: string subdomain: string @@ -168,6 +174,10 @@ export function listPlans() { return callApi("GET", "/plans") } +export function getIdentity() { + return callApi("GET", "/identity") +} + export function getPlan(id: string) { return callApi("GET", `/plans/${id}`) } diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 08518c5..1753c2c 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -1,10 +1,9 @@ import { createResource } from "solid-js" import { Relay as NostrRelay, RelayManagement } from "applesauce-relay" import { - ApiError, createRelay, - createTenant, deactivateRelay, + getIdentity, getRelay, getTenant, listRelays, @@ -14,31 +13,61 @@ import { updateRelay, updateTenantBilling, type CreateRelayInput, + type Identity, type Relay, type Tenant, type UpdateRelayInput, } from "./api" import { getActivePubkey, getActiveSigner } from "./nostr" +type IdentityCache = { + pubkey: string + value: Identity +} + +let identityCache: IdentityCache | undefined +let identityInflight: Promise | undefined +let identityInflightPubkey: string | undefined + function requireActivePubkey() { const pubkey = getActivePubkey() if (!pubkey) throw new Error("Not logged in") return pubkey } -async function ensureActiveTenant() { - try { - await createTenant() - } catch (e) { - if (e instanceof ApiError && e.status === 422) return - throw e +async function loadIdentity(pubkey?: string) { + if (!pubkey) throw new Error("Not logged in") + + if (identityCache?.pubkey === pubkey) { + return identityCache.value } + + if (identityInflight && identityInflightPubkey === pubkey) { + return identityInflight + } + + const request = getIdentity().then((identity) => { + identityCache = { pubkey, value: identity } + return identity + }).finally(() => { + if (identityInflightPubkey === pubkey) { + identityInflight = undefined + identityInflightPubkey = undefined + } + }) + + identityInflight = request + identityInflightPubkey = pubkey + return request +} + +export function useIdentity(source: () => string | undefined) { + return createResource(source, loadIdentity) } export function useTenant() { return createResource(async () => { const pubkey = requireActivePubkey() - await ensureActiveTenant() return getTenant(pubkey) }) } @@ -46,7 +75,6 @@ export function useTenant() { export function useTenantRelays() { return createResource(async () => { const pubkey = requireActivePubkey() - await ensureActiveTenant() return listTenantRelays(pubkey) }) } @@ -54,7 +82,6 @@ export function useTenantRelays() { export function useTenantInvoices() { return createResource(async () => { const pubkey = requireActivePubkey() - await ensureActiveTenant() return listTenantInvoices(pubkey) }) } @@ -78,21 +105,8 @@ export function useAdminTenantDetail(pubkey: () => string) { }) } -export function useAdminCheck(source: () => string | undefined) { - return createResource(source, async () => { - try { - await listTenants() - return { is_admin: true } - } catch (e) { - if (e instanceof ApiError && e.status === 403) return { is_admin: false } - throw e - } - }) -} - export async function createRelayForActiveTenant(input: CreateRelayInput) { const pubkey = requireActivePubkey() - await ensureActiveTenant() return createRelay({ ...input, tenant: pubkey }) }