Add frontend skill

This commit is contained in:
Jon Staab
2026-06-02 14:17:27 -07:00
parent 240304b302
commit 5e7aa7df10
5 changed files with 278 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
---
name: frontend
description: Architecture and conventions for the Caravel frontend — a SolidJS + Vite + TypeScript app (NOT React, NOT TanStack Query, NOT nonboard despite the README). Covers the pages/components/lib layout, the `@/` import alias, strict TS rules (verbatimModuleSyntax), the createResource + api.ts data layer, lazy tenant provisioning, applesauce Nostr singletons, the Tailwind v4 brand-remap (blue utilities render brown), the shared component/modal/toast kit, and the `bun run build` verification gate. Use this whenever working anywhere in frontend/ — adding a page, hook, API call, component, or style — to follow house conventions and avoid stale-README traps.
---
# Caravel frontend
This is the map of the Caravel frontend: a SolidJS + Vite + TypeScript single-page app living under `frontend/src`. It explains how the frontend is organized and *why*, and points you at the code for the *how* — so reach for this for orientation and conventions, and reach for the modules it names for implementation detail. The deep, lookup-style topics live in `references/`.
One warning up front, because it is the single most dangerous thing about this codebase: **`frontend/README.md` is stale and lies about the stack.** Do not trust it for what libraries are in use, how to run the app, or what routes exist. Its specific traps are called out below.
## It's SolidJS, not React — and the README lies about the stack
Before touching anything, internalize the actual stack, because the wrong defaults here are exactly the ones an agent reaches for:
- **SolidJS, not React.** Use `class`, not `className`. Use `<Show>`/`<For>` for conditional and list rendering, not ternaries and `.map()`. Access props lazily as `props.foo` — never destructure props at the top, because that breaks reactivity (see `src/components/relay/PlanGatedToggle.tsx`, whose own header comment says so). State is `createSignal`/`createResource`/`createMemo`/`createEffect`.
- **Data fetching is hand-rolled `fetch` in `lib/api.ts` wrapped in SolidJS `createResource` — NOT TanStack Query.** `@tanstack/solid-query` is in `package.json` and the README, but it is imported *nowhere* in `src` (verified: zero references). Do not introduce it.
- **Login is built directly on `applesauce-accounts` / `applesauce-signers` — NOT "nonboard".** There is no such dependency; the README invented it.
- **`applesauce-wallet-connect` and `@tailwindcss/forms` are declared dependencies that are never imported.** NWC on the frontend is just a text input whose value is POSTed to the backend; form inputs are styled with explicit utility classes.
Bottom line: follow the `createResource` + applesauce patterns the codebase actually uses.
## Where things live
There are four top-level code directories under `src`, each with one job (plus an `assets/` dir and loose root files `App.tsx`, `index.tsx`, `index.css`, `global.d.ts`):
- **`pages/`** — route components. Subfolders group screens: `relays/` (tenant relay screens) and `admin/` (the admin console).
- **`components/`** — shared, reusable UI, with feature subfolders `login/`, `payment/`, `account/`, `relay/`.
- **`lib/`** — all non-UI logic.
- **`views/`** — holds only `Login.tsx` (see the quirk below).
Within `lib/`, four modules are the load-bearing pillars:
- **`api.ts`** — the *only* place backend wire types and `fetch` wrappers live.
- **`state.ts`** — app-lifetime singletons plus global signals/resources.
- **`hooks.ts`** — the `use*` `createResource` read hooks and the active-tenant action helpers.
- **`nostr.ts`** — the `useNostr()` DI seam for reaching the Nostr singletons.
The rest of `lib/` is camelCase pure-helper modules and `use*` hook files. One quirk to know: **Login lives in `src/views/Login.tsx`, not `pages/`** — it is the lone occupant of `views/`.
Where a *new* thing goes: a route page → `pages/`; shared UI → `components/`; a hook → `lib/use*.ts`; a backend call → an `api.ts` wrapper; pure decision logic → its own `lib` module. A concrete file inventory and the entrypoint/route wiring are in [references/file-inventory.md](references/file-inventory.md) if you need it.
## Naming, imports, and TypeScript rules
To compile and fit in, an edit must satisfy these:
- **File naming.** Components/pages/views are PascalCase `.tsx` with one default-exported function named after the file. Lib modules are camelCase `.ts` (no default exports). Hooks are camelCase `use*.ts`. Files that export multiple hooks/symbols use named exports (e.g. `PaymentSetupShell.tsx` exports `PaymentSetupShell` plus sibling components). Single-hook files are inconsistent: `useRelayToggles.ts` and `useMinLoading.ts` default-export, but `useInvoicePdf.ts` uses a named export — so don't assume a single-purpose hook default-exports.
- **Imports always use the `@/` alias** (maps to `src/`), e.g. `import RelayList from "@/pages/relays/RelayList"`. There is not a single relative `./`/`../` cross-module import in the tree (the only relative import is `index.tsx`'s `import "./index.css"`). The alias is configured in **two places that must stay in sync**: `vite.config.ts` and `tsconfig.app.json`.
- **No barrel `index` files.** The only `index.{ts,tsx,js,jsx}` under `src/` is the entrypoint `index.tsx`, which renders the app rather than re-exporting a module. Import each symbol from its concrete module. (`index.css` also exists, so `index.tsx` isn't literally the only `index.*` file.)
- **One canonical export per symbol — no re-export shims.** When code moves, update every call site (root `AGENTS.md`). Note one existing wrinkle: `hooks.ts` re-exports four `api.ts` wire types (`Activity`, `Invoice`, `Relay`, `Tenant`), so each is importable from two paths — **prefer `@/lib/api`** for wire types. `hooks.ts` also re-exports `ProfileContent`, but that type comes from `applesauce-core/helpers/profile` (not `api.ts`), so import it from there.
- **Strict TS.** `verbatimModuleSyntax` is on, so type-only symbols need `import type` or an inline `type` specifier, e.g. `import { invoiceStatus, type Invoice } from "@/lib/api"`. `noUnusedLocals`/`noUnusedParameters` and `erasableSyntaxOnly` are also on.
- **`tenant_pubkey` naming.** Name a tenant's pubkey `tenant_pubkey` on FK fields/inputs; the exception is already-tenant-scoped contexts (`Tenant.pubkey`, `getTenant(pubkey)`). See root `AGENTS.md`.
- **Don't over-DRY.** Extract a helper only when it's a distinct concern, repeated 3+ times, or complex enough that naming it clarifies the flow (root `AGENTS.md`). `relayFlags.ts` is the canonical example — it was extracted because the 0/1↔bool conversion recurred across toggle UI and mutations.
A strong idiom: keep **pure decision logic** (payload shapes, validation ladders, discriminated-union decisions) in dedicated `lib` modules with no signals/effects/awaits (`relayPlanFlow.ts`, `loginInput.ts`), and keep the effect layer in the importing hook/component. These modules carry a header comment stating they are pure.
## The data layer: api.ts + createResource
Every backend call goes through `lib/api.ts`. A generic `callApi(method, path, body)` attaches a cached NIP-98 auth header, unwraps the `{ data, code }` envelope (returning `.data`), and throws `ApiError(message, status)` on a non-2xx response. Each endpoint is a thin exported wrapper with explicit generics; add new endpoints the same way (use an `undefined` request type for bodyless requests — both GETs and parameterless POSTs). The lone exception is `listPlans`, which bypasses `callApi` and does its own raw `fetch` with no auth machinery. (The public/unauthed routes are `/plans` and `/plans/:id` — the two backend handlers that omit the `AuthedPubkey` extractor every other route requires; `getPlan` still goes through `callApi`, which simply omits the `Authorization` header when logged out.)
Wire types are snake_case. Two modeling choices to respect: relay boolean settings are **numeric 0/1 flags**, not JS booleans — toggle them through the `relayFlags` helpers; and an invoice's lifecycle is **derived from nullable timestamps** via `invoiceStatus`, not read from a status column.
Screen reads expose a `use*` `createResource` hook in `hooks.ts` (again: not TanStack Query). Pass an accessor as the resource source when it should refetch on id change; pass a thunk reading `account()!.pubkey` for active-tenant reads. Render with `ResourceState` + `useMinLoading` + `Show`/`For`, defaulting the value with `?? []`.
Mutations are optimistic and follow one shape: `mutate(next)``await update``await refetch()`, and on error `mutate(previous)` + `setToastMessage(...)` to roll back and report.
The deeper mechanics — the `ApiOk` envelope and `ApiError` shape, NIP-98 auth caching, and the `api.ts``state.ts` circular-import DI seam — are in [references/data-and-state-lifecycle.md](references/data-and-state-lifecycle.md).
## Global state, the session, and lazy tenant provisioning
`state.ts` holds app-lifetime singletons and global reactive state: the `account` signal, the `plans`/`identity` resources, the `billing*` resources, the toast signals, and `PLATFORM_NAME`.
The one ordering rule you must respect: **a tenant row is provisioned lazily.** `ensureSessionTenant()` does a `POST /tenants`, run by the account-activation subscriber on every account switch (it's idempotent — later runs just return the existing row). Some tenant-scoped reads 404 before the row exists: `getTenant` and `getDraftInvoice` 404 via `get_tenant_or_404`. (Note `listTenantInvoices` and `listTenantRelays` do *not* 404 — they query directly and return empty results — so the gate mainly protects the tenant/draft reads.) That is why the billing resources are gated on a separate `billingPubkey` signal rather than directly on `account()``billingPubkey` is cleared (set to `undefined`) on every account switch and re-set only once the tenant is ensured. Convention: gate any new tenant-scoped resource behind `billingPubkey` (or run `ensureSessionTenant()` first), and after a billing-affecting mutation call `refetchBilling()` where the flow needs it.
For the billing *domain* (why proration, dunning, churn, and reactivation behave as they do), read the [billing-model skill](../billing-model/SKILL.md) — that skill owns the intent. This skill only documents the frontend data-layer wiring. The detailed account-switch lifecycle is in [references/data-and-state-lifecycle.md](references/data-and-state-lifecycle.md).
## Nostr integration (applesauce)
Most agents touch this rarely. Three app-lifetime applesauce singletons live in `state.ts`: `eventStore`, `pool` (a `RelayPool`), and `accountManager`. The reactive `account` signal mirrors `accountManager.active$` — never set `account` directly; activate accounts through `accountManager`.
To reach the singletons: inside a SolidJS reactive scope use `const nostr = useNostr()` (the DI seam in `nostr.ts`, which falls back to the module singletons when no provider is mounted); outside one (event handlers, plain async, `api.ts`) import the module singletons directly.
Render profiles through the `useProfileMetadata` / `useProfileMetadataMap` / `useProfilePicture` hooks, which subscribe to `eventStore.profile(...)` and optionally prime over the network — never hand-roll relay queries. When a parent batch-primes a list, children subscribe with `{ prime: false }` so they don't each re-prime.
Pubkeys are hex strings; shorten them for display with the canonical `shortenPubkey` in `lib/pubkey.ts`. Gotcha: `AppShell.tsx` has its own *duplicate* `shortenPubkey` with a different format (8/6 split with a `…` character vs. the canonical 8/8 split with `...`) — **prefer the canonical `lib/pubkey.ts` one.** Auth is a session-style NIP-98 variant: a kind-27235 event signed by the active account, with a `u`-tag equal to `VITE_API_URL`, cached ~10 minutes.
## Routing and auth gating
All routes are declared centrally in `App.tsx` under one `Router` with `root={Layout}`. Protected pages are wrapped in `requireTenant()` (logged in) or `requireAdmin()` (`identity().is_admin`), both built on a `requireCondition` HOC that redirects to `/` when the check fails and waits on the `identity` resource to finish loading. A new gated page must be wired into `App.tsx` with the right wrapper. `AppShell` wraps the authenticated paths (matched by a path regex in `Layout`).
The gate is **rendering-only** (it just conditionally renders the page and client-redirects). Real security is server-side in two distinct layers: (1) **authentication** — the backend decodes the NIP-98 `Authorization` header, verifies the kind-27235 event's signature, and checks the `u`-tag equals `SERVER_URL` for host affinity, recovering the signer pubkey; (2) **authorization** — each handler then calls `require_admin` / `require_tenant` / `require_admin_or_tenant` to compare that pubkey against the configured admin pubkeys or the tenant's pubkey. The `u`-tag/`server_url` check is authentication/host-binding, not the authorization decision. And note: the README's `/login` route does not exist — login is a modal/embedded component, not a route.
## Styling: Tailwind v4 + the brand remap + shared kit
Styling is inline Tailwind v4 utility strings in `class=` — no CSS modules, no `@apply`, no `tailwind.config.js` (config is CSS-first, in `src/index.css`, wired via the `@tailwindcss/vite` plugin).
**THE pitfall:** `index.css` remaps the entire `--color-blue-*` scale to a brown brand palette, so `bg-blue-600`, `text-blue-700`, and `focus:ring-blue-500` render **brown** (`#c18254`) and *are* the intended brand accent. Do not "fix" them to a brown hex, and do reach for blue utilities for any primary accent.
Compose from the shared kit rather than bespoke markup: `PageContainer` (page wrapper), `ResourceState`/`LoadingState` (paired with `createResource` + `useMinLoading`), `Modal` plus `ConfirmDialog`/`PaymentDialog`, and the form primitives `Field`/`ToggleField`/`Checkbox`/`SearchInput`/`ToggleButton`/`RelayForm`. Most transient/global errors and successes go through `setToastMessage(message, variant)` (a single bottom-right `Toast` in `components/Toast.tsx`, mounted once in `App.tsx`; default variant `"error"` = red, `"success"` = green) — prefer it over ad-hoc alert UI. It is not the only error surface, though: contextual/inline errors bypass the toast — e.g. Login's local `error()` signal (`views/Login.tsx`), the payment flow (`PaymentDialog` bolt11 error, `PaymentSetupShell`), resource-load failures (`ResourceState` errorText), relay provisioning/sync errors (`RelayCardHeader`), and the `PromptBanner` banner.
Preline's `HSStaticMethods.autoInit()` is re-run on pathname changes by a `createEffect` in `Layout` (`App.tsx`). However, this codebase does not actually use any `data-hs-*` markup — all dropdowns, tabs, and overlays are hand-rolled with SolidJS signals and manual listeners (see `RelayCardHeader.tsx`, `LoginTabsScreen.tsx`, `PaymentSetup.tsx`). Don't assume `data-hs-*` needs no wiring: the effect fires only on full-path changes (not same-path query/hash navigation), and because SolidJS renders DOM lazily via `<Show>`, any Preline markup that mounts after the effect last ran will be uninitialized. If you do use Preline `data-hs-*` components, wire initialization explicitly rather than relying on this route-level effect.
## Building and verifying a change
There is no lint or test step for the frontend. The verification gate is the **build**, which runs a strict typecheck (`tsc -b`, project references, `noEmit`) then a production bundle (`vite build`). Verify with `just build-frontend` (which runs `bun i && bun run build`) or, inside `frontend/`, `bun run build`. **Bun is the package manager** (`bun.lock`; no npm/yarn/pnpm lockfile) — ignore the README's `npm` instructions. `just build` also compiles the Rust backend, so prefer `build-frontend` to verify only frontend changes.
Three `VITE_*` vars (`VITE_API_URL`, `VITE_RELAY_DOMAIN`, optional `VITE_PLATFORM_NAME`) are read once into named lib constants (`API_URL`, `RELAY_DOMAIN`, `PLATFORM_NAME`) and baked at build time. A fourth, `VITE_PORT`, is read only by `vite.config.ts` and is undocumented (absent from both `.env.template` and `global.d.ts`). Adding a new client env var means: prefix it `VITE_`, declare it in `src/global.d.ts`, add it to `.env.template`, and (for Docker) wire the placeholder + entrypoint `sed` substitution. The full env/Docker substitution matrix is in [references/build-env-and-docker.md](references/build-env-and-docker.md).
@@ -0,0 +1,53 @@
# Build, TypeScript config, env vars, and Docker (deep detail)
Lookup-depth companion to the SKILL.md "Building and verifying" section.
## Build and typecheck
The only `package.json` scripts are `dev` (`vite`), `build` (`tsc -b && vite build`), and `preview` (`vite preview`). There is no lint or test script — the build *is* the verification gate.
`tsc -b` builds via TypeScript **project references**: the root `tsconfig.json` has `files: []` and references `tsconfig.app.json` (compiles `src`) and `tsconfig.node.json` (compiles `vite.config.ts`). Both set `noEmit: true`, so `tsc -b` is purely a typecheck; the actual emit comes from `vite build`.
The strict flags a change must satisfy (in `tsconfig.app.json`): `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, `noUncheckedSideEffectImports`, plus `verbatimModuleSyntax` and `moduleDetection: force`. JSX is `preserve` with `jsxImportSource: solid-js`. The `@/*``src/*` path alias is declared here (and mirrored in `vite.config.ts`).
## Commands and the package manager
Bun is the package manager: `bun.lock` is present and there is no `package-lock.json` / `yarn.lock` / `pnpm-lock.yaml`.
- `just build-frontend``cd frontend && bun i && bun run build` — the canonical way to verify a frontend change compiles.
- `just build``build-backend build-frontend` — also compiles the Rust backend, so it needs a working Rust toolchain. Use `build-frontend` to verify only frontend changes.
- `just dev-frontend``cd frontend && bun run dev`.
- There is **no** frontend lint or test target in the `justfile`; the aggregate `lint`/`test`/`fmt` tasks are backend-only.
## The four VITE_* env vars
| Var | Declared in `global.d.ts`? | In `.env.template`? | Where it's read | Role |
| --- | --- | --- | --- | --- |
| `VITE_API_URL` | yes | yes (`http://127.0.0.1:2892`) | `api.ts``API_URL` | backend base URL for requests; also the NIP-98 `u`-tag value |
| `VITE_RELAY_DOMAIN` | yes | yes (`spaces.coracle.social`) | `subdomain.ts``RELAY_DOMAIN` | relay subdomain suffix; must match the backend's `RELAY_DOMAIN` |
| `VITE_PLATFORM_NAME` | yes (optional) | yes (`Caravel`) | `state.ts``PLATFORM_NAME` | platform display name (title, login, invoice PDF) |
| `VITE_PORT` | **no** | **no** | `vite.config.ts` only (`Number(env.VITE_PORT) || 5173`) | dev-server port; never reaches client code |
Convention: read each client var once at module top-level into a named `const` typed via `global.d.ts`'s `ImportMetaEnv`, rather than scattering `import.meta.env` reads through components. A new client-readable var must be `VITE_`-prefixed, declared in `ImportMetaEnv` in `src/global.d.ts`, and added to `.env.template` with a default. Non-client build-tool vars (like `VITE_PORT`) are read only in `vite.config.ts` via `loadEnv(mode, process.cwd(), '')`.
`global.d.ts` is the single ambient-declarations file: it also declares `Window.HSStaticMethods` (Preline) and `Window.nostr?` (NIP-07). Extend the existing interfaces there rather than re-declaring elsewhere.
## Build-time bake vs. Docker runtime substitution
`VITE_*` values are **baked into the bundle at build time**, so editing `frontend/.env` requires a rebuild to take effect.
In Docker this is sidestepped: the frontend is built once with sentinel placeholder values (`VITE_API_URL=__VITE_API_URL__`, etc.). The container entrypoint then maps runtime env (`SERVER_URL`, `RELAY_DOMAIN`, `PLATFORM_NAME`) onto those placeholders and `sed`-substitutes them into every `*.js`/`*.html` in `/app/dist` at startup, before serving with `serve -s /app/dist -l 3000`. So one image can be deployed with any config without a rebuild, but runtime config there does *not* require a rebuild — unlike a local `.env` change.
When adding a new build-time-substituted `VITE_` var intended for Docker, mirror the existing pattern: declare the placeholder `ENV` in the build stage and add the matching `sed -e` mapping in the entrypoint.
## Sources
- scripts, no lint/test — `frontend/package.json:6-10`
- project references, `noEmit``frontend/tsconfig.json:1-7`
- strict flags + alias — `frontend/tsconfig.app.json:12-30`
- bun lockfile — `frontend/bun.lock` (no other lockfile present)
- `just` targets — `justfile:11-12,32-43`
- env var declarations — `frontend/src/global.d.ts:17-31`
- env defaults — `frontend/.env.template:1-8`
- `VITE_PORT` dev-server port — `frontend/vite.config.ts:6-18`
- Docker placeholder build + entrypoint `sed``Dockerfile` frontend-build stage and entrypoint
@@ -0,0 +1,60 @@
# Data and state lifecycle (deep detail)
This is the lookup-depth companion to the SKILL.md "data layer" and "global state" sections. It documents the exact mechanics of the API client, NIP-98 auth, the circular-import seam, the session/provisioning lifecycle, and the optimistic-mutation flow. All of it lives in `frontend/src/lib/api.ts`, `state.ts`, and `useRelayToggles.ts`.
## The API envelope and errors
`callApi<TRequest, TResponse>(method, path, body?)` is the single fetch wrapper (`api.ts`). It calls `makeAuth()`, builds the URL with `new URL(path, API_URL)`, sets `Content-Type: application/json` and an `Authorization` header *only when auth is present*, and JSON-stringifies the body — an `undefined` body sends no body at all.
Responses are unwrapped from an `ApiOk<T> = { data: T; code: string }` envelope: `callApi` returns `payload.data`, not the raw payload. A `204` returns `undefined` cast to `TResponse`.
On a non-2xx response it throws `new ApiError(message, response.status)`. `ApiError extends Error` and carries a numeric `status`. The message comes from the JSON body's `error` field when present, otherwise `Request failed (<status>)`.
`listPlans()` deliberately bypasses `callApi`: it does its own raw `fetch` with no auth and unwraps `{ data: Plan[] }` directly. It is the only wrapper that bypasses `callApi`, but not the only public/unauthed endpoint — `/plans` and `/plans/:id` are both public (their backend handlers omit the `AuthedPubkey` extractor); `getPlan` reaches its public route through `callApi`, which omits the `Authorization` header when logged out.
## NIP-98 auth and the 10-minute cache
`makeAuth()` builds a session-style NIP-98 header. It reads the active account via the injected getter (see the DI seam below), and if none is logged in it returns `undefined` — so `callApi` silently sends an *unauthenticated* request rather than failing fast, and authed endpoints then 401 from the backend.
When an account is present it signs a **kind-27235** event with empty content and a single `["u", API_URL]` tag, base64-encodes the JSON, and returns `Nostr <base64>`. The result is cached per-pubkey for **10 minutes** (`expiresAt = now + 10 * 60 * 1000`). Switching accounts invalidates the cache via the pubkey check. Within the cache window the event's `created_at` is fixed at sign time, so freshness relies on the backend's own NIP-98 window.
## The api.ts ↔ state.ts circular-import seam
`api.ts` needs the active account to build auth, but `state.ts` (which owns the `account` signal) imports from `api.ts` — a cycle. It is broken by dependency injection: `api.ts` holds a private module-level `getAccount` and exposes `registerAccountGetter(fn)`; `state.ts` calls `registerAccountGetter(account)` to inject the signal.
Side-effecting initialization in `state.ts` (localStorage hydration + the `accountManager.active$` subscription) is wrapped in `queueMicrotask(...)` to defer it past module evaluation and avoid temporal-dead-zone errors from the cycle. When adding new cross-module state that both files touch, follow the same pattern: a `register*Getter` injection plus deferred init.
## Session and lazy tenant provisioning
The tenant row is created lazily, which drives the whole session ordering.
`ensureSessionTenant()` is idempotent lazy provisioning: it reads the active pubkey, `await`s `createTenant()` (`POST /tenants`), then — only if the active pubkey is still the same — sets `billingPubkey` to unlock billing reads. The in-flight promise is shared per-pubkey via a module-level `tenantEnsure` object, so the login flow and the activation subscriber don't double-provision; `createTenant` is itself idempotent.
The `accountManager.active$` subscriber runs on **every account switch** and performs this ordering: `setAccount(account)` → persist accounts + active id to `localStorage``refetchIdentity()``setBillingPubkey(undefined)` to **lock** billing reads → `void ensureSessionTenant()` to re-unlock once provisioned. Locking first is what stops billing reads from firing against a not-yet-provisioned tenant during signup.
Login's `completeLogin` (`views/Login.tsx`) `await`s `ensureSessionTenant()` and rolls back via `accountManager.removeAccount(...)` if it throws — so a failed provision aborts the login.
This is why the billing resources (`billingTenant`, `billingInvoices`, `billingRelays`, `billingDraftInvoice`) are gated on a `billingPubkey` signal, not on `account()` directly. The gate matters because `getTenant` and `getDraftInvoice` (which use `get_tenant_or_404`) 404 before the row exists; `listTenantInvoices` and `listTenantRelays` instead return empty results, so the gate primarily protects the tenant/draft reads.
`refetchBilling()` refetches all four via `Promise.allSettled`, and on any rejected result it `console.error`s and toasts `"Failed to refresh billing data"`. It is the pure billing refresh that callers invoke after select billing-affecting flows (payment dialog/setup close, paid relay create, `autopayBilling`'s `finally` block) — it is **not** called after every billing-affecting mutation: relay deactivate/reactivate, plan toggles, and plan changes (including downgrade-to-free) in `useRelayToggles` only run the per-relay `refetch()`, and creating a free relay in `Home.tsx` skips it.
## The optimistic mutation flow
The canonical mutation shape (`useRelayToggles.ts`):
1. `mutate(next)` — optimistically push the new value into the resource.
2. `await updateRelayById(...)` — perform the API write.
3. `await refetch()` — reconcile against the server's truth.
4. On error: `mutate(previous)` to roll back, and `setToastMessage(e instanceof Error ? e.message : <fallback>)`.
`setToastMessage` defaults the variant to `"error"`; pass `"success"` for confirmations and `setToastMessage("")` to clear. Rethrow only when a caller needs to react (e.g. the plan-upgrade flow continues into `resolvePostPaidFlow()` for paid upgrades).
## Sources
- API client, envelope, `ApiError`, `listPlans` bypass — `frontend/src/lib/api.ts:9-32,208-248`
- NIP-98 `makeAuth` + 10-minute cache — `frontend/src/lib/api.ts:181-206`
- circular-import DI seam — `frontend/src/lib/api.ts:3-7` and `frontend/src/lib/state.ts:48,168-211`
- billing-resource gating + `refetchBilling``frontend/src/lib/state.ts:74-103`
- `ensureSessionTenant` + activation ordering — `frontend/src/lib/state.ts:149-165,187-208`
- login rollback — `frontend/src/views/Login.tsx:57-67`
- optimistic mutation — `frontend/src/lib/useRelayToggles.ts:27-36`
@@ -0,0 +1,57 @@
# File inventory and route wiring
A concrete map of `frontend/src`, so the SKILL.md "where things live" section can stay prose-only. Use this when you need to find the right existing module instead of guessing.
## Entry points
- `index.tsx` — bootstrap. Imports `index.css`, dynamically imports Preline, then `render`s `<App />`.
- `App.tsx``Router` + central route table + the auth-gate HOCs (see "Routes" below).
- `index.css` — Tailwind v4 CSS-first config and the brand color remap.
- `global.d.ts` — ambient declarations (`ImportMetaEnv`, `Window.HSStaticMethods`, `Window.nostr`).
## `pages/` — route components
- top level: `Home.tsx`, `Account.tsx`
- `relays/` (tenant relay screens): `RelayList`, `RelayNew`, `RelayDetail`, `RelayEdit`
- `admin/` (admin console): `AdminTenantList`, `AdminTenantDetail`, `AdminRelayList`, `AdminRelayDetail`, `AdminRelayEdit`, `AdminInvoiceList`, `AdminInvoiceDetail`
## `views/` — the lone exception
- `Login.tsx` — the login flow, the only thing under `views/` (not `pages/`).
## `components/` — shared UI
- shared kit: `PageContainer`, `ResourceState`, `LoadingState`, `Modal`, `ConfirmDialog`, `PaymentDialog`, `Field`, `ToggleField`, `Checkbox`, `SearchInput`, `ToggleButton`, `RelayForm`, `Toast`, `PromptBanner`, `PricingTable`, `BackLink`, plus small icon components
- `AppShell` — the authenticated layout shell (note: carries a duplicate local `shortenPubkey`; prefer `lib/pubkey.ts`)
- feature subfolders:
- `login/``LoginTabsScreen`, `LoginKeyScreen`, `LoginSignerScreen`, `QrScannerOverlay`
- `payment/``InvoiceItemsList`, `LightningPayBody`
- `account/``InvoiceListItem`, `PaymentMethodRow`
- `relay/``PlanGatedToggle`, `RelayCardHeader`
- plus billing/payment/relay cards at the components top level (`BillingPrompts`, `InvoiceDetailCard`, `RelayDetailCard`, `RelayListItem`, `PaymentSetup*`, `ActivityFeed`, `AdminInvoiceListItem`)
## `lib/` — non-UI logic
- **pillars:** `api.ts` (wire types + fetch wrappers), `state.ts` (singletons + global signals/resources), `hooks.ts` (`use*` read hooks + active-tenant action helpers), `nostr.ts` (`useNostr` DI seam)
- **`use*` hooks:** `useMinLoading`, `useRelayToggles`, `usePaymentSetup`, `useInvoicePdf`
- **pure helpers:** `relayPlanFlow`, `relayFlags`, `paymentMethod`, `loginInput`, `subdomain`, `slugify`, `search`, `format`, `clipboard`, `validation`, `pubkey`, `billing`
## Routes (`App.tsx`)
All declared under one `Router root={Layout}`:
- `/``Home` (ungated)
- `/relays`, `/relays/new`, `/relays/:id`, `/relays/:id/edit``requireTenant(...)`
- `/account``requireTenant(Account)`
- `/admin/tenants`, `/admin/tenants/:id`, `/admin/relays`, `/admin/relays/:id`, `/admin/relays/:id/edit`, `/admin/invoices`, `/admin/invoices/:id``requireAdmin(...)`
`requireTenant(Page)` and `requireAdmin(Page)` are both built on a `requireCondition(Page, condition)` HOC: it waits for the `identity` resource to finish loading, redirects to `/` when the condition is false, and otherwise renders the page inside a `<Show>`. `requireTenant` checks `Boolean(identity())`; `requireAdmin` checks `Boolean(identity()?.is_admin)`. `Layout` wraps the page in `AppShell` when the path matches `/^\/(relays|account|admin)/` and identity has loaded.
There is **no `/login` route** — login is rendered as a modal/embedded component (`views/Login.tsx`, mounted as a `<Modal>` in `pages/Home.tsx`), contrary to `frontend/README.md` (not the repo-root README), which lists a `/login` route.
## Sources
- src tree — verified live listing of `frontend/src`
- route table + guards — `frontend/src/App.tsx:43-81`
- entry bootstrap — `frontend/src/index.tsx:3-13`
- `AppShell` duplicate `shortenPubkey``frontend/src/components/AppShell.tsx:11-14`
+1
View File
@@ -4,6 +4,7 @@ node_modules
target
data
.env
.claude
**/.env
.playwright-cli
.agents/settings.local.json