Add identity endpoint
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)`
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user