Add identity endpoint

This commit is contained in:
Jon Staab
2026-03-26 16:10:24 -07:00
parent 9da5e027a7
commit a2f9ca9688
9 changed files with 129 additions and 38 deletions
+1
View File
@@ -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)
+8
View File
@@ -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`
+8
View File
@@ -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.
+37
View File
@@ -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<AppState>,
headers: HeaderMap,
) -> std::result::Result<Response, ApiError> {
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<AppState>,
headers: HeaderMap,
+6
View File
@@ -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()`
+10 -10
View File
@@ -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)`
+11 -4
View File
@@ -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<Profile>({})
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<Relay[]>(() => {
@@ -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" },
+10
View File
@@ -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<undefined, Plan[]>("GET", "/plans")
}
export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity")
}
export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`)
}
+38 -24
View File
@@ -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<Identity> | 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 })
}