From 87a9219fd3f6bd0b9d96b9b0bfb06085dd806df2 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 16 May 2026 21:30:16 -0400 Subject: [PATCH] feat: add some basic mutations --- CODEBASE_FINDINGS.md | 48 ++++--- app/server/context.ts | 13 +- app/server/fate.ts | 315 +++++++++++++++++++++++++++++++++++++++++- app/spa/app.tsx | 7 +- app/spa/router.tsx | 267 ++++++++++++++++++++++++++++++++++- 5 files changed, 623 insertions(+), 27 deletions(-) diff --git a/CODEBASE_FINDINGS.md b/CODEBASE_FINDINGS.md index 15fbb66..6cd2406 100644 --- a/CODEBASE_FINDINGS.md +++ b/CODEBASE_FINDINGS.md @@ -549,14 +549,29 @@ Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDa - Removed the earlier `HeadplaneRuntime`, `HeadplaneDataContext`, and `HeadplaneLivePublisher` scaffolding. - Added direct dependencies: `react-fate`, `@nkzw/fate`, `@tanstack/react-router`, `@tanstack/router-plugin`, and `@vitejs/plugin-react`. -- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and a raw Fate client shell using `createClient` + `createHTTPTransport`. +- Added a plain Vite SPA `index.html` and `app/spa` entry with TanStack Router and the generated raw Fate client from `react-fate/client`. - Moved the old React Router Vite config to `vite-old.config.ts` and replaced `vite.config.ts` with a clean SPA config. - Added Hono and `@hono/node-server`, plus a minimal Hono server shell in `app/server/hono-app.ts`, `app/server/hono-dev.ts`, and `app/server/hono-main.ts`. - Added `app/server/fate.ts`, which exports a raw Fate server and live bus. Hono mounts Fate's `createHonoFateHandler(fate)` at `/admin/fate` and `/admin/fate/*`, passing the existing app context through Hono variables. - Wired the official `react-fate/vite` plugin to `app/server/fate.ts`, ignored generated `.fate/` output, and made `pnpm run typecheck` run `fate generate` before `tsgo` so generated `react-fate/client` typings exist from a clean checkout. +- Added the first real Fate read roots: `machines` and `users`. They use Fate `dataView(...)`, `list(...)`, and source executors directly, call the existing principal-scoped Headscale runtime API client, and enforce existing `read_machines` / `read_users` capabilities. +- Added minimal SPA `/machines` and `/users` routes that fetch with raw `useRequest(...)`, render records with `useLiveView(...)`, and subscribe to root list connections with `useLiveListView(...)`. These routes intentionally do not port filters, actions, or optimistic updates yet. +- Bridged existing `hsLive` resource changes directly to Fate connection invalidations: `nodes` invalidates the `machines` root connection and `users` invalidates the `users` root connection. This is a temporary seam so converted routes can exercise Fate live primitives before the old live store is deleted. +- Added the first raw Fate mutation, `machine.rename`. It reuses existing `canManageNode` authorization, calls the Headscale API, refreshes the transitional `hsLive` nodes resource, emits direct Fate entity/list live events, and returns the client-selected `Machine` view. - `pnpm dev` now runs the Hono/Vite middleware shell with local `.data` storage for the example config; `pnpm build` now runs `vite build` for the SPA. - Fate's Drizzle peer currently warns against the repo's Drizzle `1.0.0-beta.21`; avoid Fate's Drizzle adapter until that compatibility is resolved, and start with a direct Headscale source/resolver instead. +### Fate context decision + +The current Fate request context should stay pragmatic rather than heavily decomposed: + +- expose `api`, the principal-scoped Headscale runtime client, as the primary data access path for remote Headscale data; +- keep `principal` and `request` available for authorization and future audit/session needs; +- keep `app` available during the migration so resolvers can reuse the existing auth/config/agent services without inventing a new service layer first; +- do not pass an unstructured context into every helper by default once a data domain settles. If a `machines`, `users`, or `authKeys` module becomes large, give that module explicit functions that accept the concrete pieces it uses. + +In other words: full app context is acceptable as migration scaffolding, but the resolver code should prefer the smallest direct dependency (`ctx.api`, `ctx.app.auth`, etc.) and should not become a new `HeadplaneRuntime` abstraction. + ### Recommended migration sequence 1. **Remove transitional abstractions** and make the branch clearly one-way toward SPA + raw Fate. @@ -570,9 +585,11 @@ Do not add project-level wrappers such as `HeadplaneLivePublisher`, `HeadplaneDa - `/fate/live` for SSE and subscription control; - request context resolves auth using the existing auth service and calls `context.hsApi.getRuntimeClient(...)` directly. 5. **Convert the machines page first** using raw Fate views and live list/view hooks. -6. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side. -7. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz. -8. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving. +6. **Add live updates to the converted lists** by publishing Fate list/entity invalidations from the existing polling/mutation seams. Keep this raw Fate live bus usage, not a Headplane live wrapper. +7. **Port one mutation at a time**, starting with a low-risk machine mutation such as rename. The mutation should call the existing Headscale API, return the selected entity, and emit the relevant Fate live event. +8. **Delete the old React Router loader/action/SSE path for converted data**, rather than running duplicate data models side-by-side. +9. **Fix critical auth/session issues early**: self-linking, raw API-key cookie, agent-route authz. +10. **Keep runtime changes minimal** until the SPA actually needs them: Hono routes, static SPA fallback, Fate routes, and later SEA asset serving. ### Vite+ / VoidZero tooling assessment @@ -586,15 +603,14 @@ Recommended Vite+ approach: ## Suggested immediate backlog -1. Fix OIDC self-linking authorization. -2. Make API-key sessions opaque/server-side and set explicit auth cookie flags. -3. Add capability checks to `/settings/agent` loader/action. -4. Fix live-data pause cleanup/refcounting. -5. Make `hsLive` use a stable server credential or principal-scoped cache. -6. Add raw Fate server/client dependencies and wire the Vite plugin. -7. Add the Vite SPA entry and TanStack Router skeleton. -8. Mount Fate's native `/fate` and `/fate/live` handlers directly. -9. Convert the machines page to raw Fate views/actions/live subscriptions. -10. Serialize config patches with a real mutex/queue and clone config arrays before editing. -11. Add CI `pnpm run typecheck` and `pnpm run lint`. -12. Add WebSSH top-level dispose and release Go `js.Func` values. +1. Replace the temporary `hsLive` bridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source. +2. Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists. +3. Port the next machine mutations: expire/delete/tags/routes, one at a time, each returning selected data or deleting/updating the normalized cache explicitly. +4. Fix OIDC self-linking authorization. +5. Make API-key sessions opaque/server-side and set explicit auth cookie flags. +6. Add capability checks to `/settings/agent` loader/action. +7. Fix live-data pause cleanup/refcounting for unconverted React Router routes. +8. Make `hsLive` use a stable server credential or principal-scoped cache while it still exists. +9. Serialize config patches with a real mutex/queue and clone config arrays before editing. +10. Add CI `pnpm run typecheck` and `pnpm run lint`. +11. Add WebSSH top-level dispose and release Go `js.Func` values. diff --git a/app/server/context.ts b/app/server/context.ts index 7f524c9..7a95e04 100644 --- a/app/server/context.ts +++ b/app/server/context.ts @@ -5,6 +5,7 @@ import log from "~/utils/log"; import type { HeadplaneConfig } from "./config/config-schema"; import { loadIntegration } from "./config/integration"; import { createDbClient } from "./db/client.server"; +import { live as fateLive } from "./fate"; import { createHeadscaleInterface } from "./headscale/api"; import { loadHeadscaleConfig } from "./headscale/config-loader"; import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; @@ -83,6 +84,16 @@ export async function createAppContext(config: HeadplaneConfig) { } : undefined; + const hsLive = createLiveStore([nodesResource, usersResource]); + hsLive.subscribe((resource, version) => { + const eventId = `${resource}:${version}`; + if (resource === nodesResource.key) { + fateLive.connection("machines").invalidate({ eventId }); + } else if (resource === usersResource.key) { + fateLive.connection("users").invalidate({ eventId }); + } + }); + return { config, db, @@ -91,7 +102,7 @@ export async function createAppContext(config: HeadplaneConfig) { agents, auth, oidc, - hsLive: createLiveStore([nodesResource, usersResource]), + hsLive, hs: await loadHeadscaleConfig( config.headscale.config_path, config.headscale.config_strict, diff --git a/app/server/fate.ts b/app/server/fate.ts index 742aa7f..4830a01 100644 --- a/app/server/fate.ts +++ b/app/server/fate.ts @@ -1,14 +1,24 @@ import { createFateServer, createLiveEventBus, + dataView, + FateRequestError, + list, + resolveSourceById, + type Entity, type SourceDefinition, type SourceRegistry, } from "@nkzw/fate/server"; import type { Context } from "hono"; +import type { Machine as HeadscaleMachine, User as HeadscaleUser } from "~/types"; + import type { AppContext } from "./context"; import type { RuntimeApiClient } from "./headscale/api/endpoints"; +import { isConnectionError, isDataWithApiError } from "./headscale/api/error-client"; +import { nodesResource } from "./headscale/live-store"; import type { Principal } from "./web/auth"; +import { Capabilities } from "./web/roles"; export interface HonoFateEnv { Variables: { @@ -27,12 +37,293 @@ type FateAdapterContext = Context; export const live = createLiveEventBus(); -export const roots = {}; +type UserRecord = HeadscaleUser & Record; +type MachineRecord = Omit & { + user?: UserRecord; +} & Record; + +export const UserDataView = dataView("User")({ + createdAt: true, + displayName: true, + email: true, + id: true, + name: true, + profilePicUrl: true, + provider: true, + providerId: true, +}); + +export const MachineDataView = dataView("Machine")({ + approvedRoutes: true, + availableRoutes: true, + createdAt: true, + discoKey: true, + expiry: true, + givenName: true, + id: true, + ipAddresses: true, + lastSeen: true, + machineKey: true, + name: true, + nodeKey: true, + online: true, + registerMethod: true, + subnetRoutes: true, + tags: true, + user: UserDataView, +}); + +export type User = Entity; +export type Machine = Entity< + typeof MachineDataView, + "Machine", + { + user?: User; + } +>; + +export type FateUser = User; +export type FateMachine = Machine; + +const userSource = { + id: "id", + view: UserDataView, +} satisfies SourceDefinition; + +const machineSource = { + id: "id", + view: MachineDataView, +} satisfies SourceDefinition; + +const machineList = list(MachineDataView, { orderBy: { givenName: "asc" } }); +const userList = list(UserDataView, { orderBy: { name: "asc" } }); + +export const Root = { + machines: machineList, + users: userList, +}; +export const roots = Root; + const queries = {}; const lists = {}; -const mutations = {}; + +function apiFailureToFateError(error: unknown, fallback: string): FateRequestError { + if (error instanceof FateRequestError) { + return error; + } + + if (isDataWithApiError(error)) { + const status = error.data.statusCode; + if (status === 401) { + return new FateRequestError("UNAUTHORIZED", "Headscale rejected the current API key."); + } + if (status === 403) { + return new FateRequestError("FORBIDDEN", "Headscale refused this operation."); + } + if (status === 404) { + return new FateRequestError("NOT_FOUND", "The requested Headscale resource was not found."); + } + + return new FateRequestError( + "INTERNAL_ERROR", + `Headscale API request failed with status ${status}.`, + { status: 502 }, + ); + } + + const data = error && typeof error === "object" && "data" in error ? error.data : undefined; + if (isConnectionError(data)) { + return new FateRequestError( + "INTERNAL_ERROR", + `Unable to reach Headscale: ${data.errorMessage}`, + { status: 502 }, + ); + } + + return new FateRequestError("INTERNAL_ERROR", fallback); +} + +interface RenameMachineInput { + id: string; + name: string; +} + +const renameMachineInput = { + parse(input: unknown): RenameMachineInput { + if (!input || typeof input !== "object") { + throw new FateRequestError("VALIDATION_ERROR", "Machine rename input is required."); + } + + const id = (input as Record).id; + const name = (input as Record).name; + if (typeof id !== "string" || id.trim() === "") { + throw new FateRequestError("VALIDATION_ERROR", "Machine ID is required."); + } + if (typeof name !== "string" || name.trim() === "") { + throw new FateRequestError("VALIDATION_ERROR", "Machine name is required."); + } + + return { + id, + name: name.trim(), + }; + }, +}; + +const mutations = { + "machine.rename": { + input: renameMachineInput, + resolve: async ({ + ctx, + input, + select, + }: { + ctx: FateContext; + input: RenameMachineInput; + select: Array; + }): Promise => { + let node; + try { + node = await ctx.api.getNode(input.id); + } catch (error) { + throw apiFailureToFateError(error, "Unable to load this machine."); + } + + if (!ctx.app.auth.canManageNode(ctx.principal, node)) { + throw new FateRequestError( + "FORBIDDEN", + "You do not have permission to rename this machine.", + ); + } + + try { + await ctx.api.renameNode(input.id, input.name); + } catch (error) { + throw apiFailureToFateError(error, "Unable to rename this machine."); + } + + void ctx.app.hsLive.refresh(nodesResource, ctx.api).catch(() => undefined); + + const eventId = `machine:${input.id}:rename:${Date.now()}`; + live.update("Machine", input.id, { changed: ["givenName", "name"], eventId }); + live.connection("machines").invalidate({ eventId }); + + return (await resolveSourceById({ + ctx, + id: input.id, + input: { select }, + registry, + source: machineSource, + })) as Machine | null; + }, + type: "Machine", + }, +}; + +type SourceByIdsOptions = { + ctx: FateContext; + ids: Array; +}; + +type SourceConnectionOptions = { + ctx: FateContext; + cursor?: string; + direction: "backward" | "forward"; + take: number; +}; + +function requireCapability(ctx: FateContext, capability: Capabilities) { + if (!ctx.app.auth.can(ctx.principal, capability)) { + throw new FateRequestError("FORBIDDEN", "You do not have permission to view this data."); + } +} + +function compareText(a: string | undefined, b: string | undefined) { + return (a ?? "").localeCompare(b ?? "", undefined, { numeric: true, sensitivity: "base" }); +} + +function byRequestedId(items: Array, ids: Array) { + const byId = new Map(items.map((item) => [item.id, item])); + return ids.flatMap((id) => { + const item = byId.get(id); + return item ? [item] : []; + }); +} + +function pageByCursor( + items: Array, + { cursor, direction, take }: Omit, +) { + if (!cursor) { + return direction === "backward" + ? items.slice(Math.max(0, items.length - take)) + : items.slice(0, take); + } + + const cursorIndex = items.findIndex((item) => item.id === cursor); + if (cursorIndex < 0) { + return direction === "backward" + ? items.slice(Math.max(0, items.length - take)) + : items.slice(0, take); + } + + if (direction === "backward") { + return items.slice(Math.max(0, cursorIndex - take), cursorIndex); + } + + return items.slice(cursorIndex + 1, cursorIndex + 1 + take); +} + +async function getMachineRecords(ctx: FateContext): Promise> { + requireCapability(ctx, Capabilities.read_machines); + + try { + return (await ctx.api.getNodes()) + .map((machine) => machine as MachineRecord) + .sort( + (a, b) => + compareText(a.givenName || a.name, b.givenName || b.name) || compareText(a.id, b.id), + ); + } catch (error) { + throw apiFailureToFateError(error, "Unable to load machines from Headscale."); + } +} + +async function getUserRecords(ctx: FateContext): Promise> { + requireCapability(ctx, Capabilities.read_users); + + try { + return (await ctx.api.getUsers()) + .map((user) => user as UserRecord) + .sort((a, b) => compareText(a.name, b.name) || compareText(a.id, b.id)); + } catch (error) { + throw apiFailureToFateError(error, "Unable to load users from Headscale."); + } +} const registry = new Map() as SourceRegistry; +registry.set(machineSource as SourceDefinition, { + byIds: async ({ ctx, ids }: SourceByIdsOptions) => + byRequestedId(await getMachineRecords(ctx), ids), + connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) => + pageByCursor(await getMachineRecords(ctx), { cursor, direction, take }), +}); +registry.set(userSource as SourceDefinition, { + byIds: async ({ ctx, ids }: SourceByIdsOptions) => byRequestedId(await getUserRecords(ctx), ids), + connection: async ({ ctx, cursor, direction, take }: SourceConnectionOptions) => + pageByCursor(await getUserRecords(ctx), { cursor, direction, take }), +}); + +const sourceByView = new Map([ + [MachineDataView, machineSource as SourceDefinition], + [machineList, machineSource as SourceDefinition], + [UserDataView, userSource as SourceDefinition], + [userList, userSource as SourceDefinition], +]); + +function isSourceDefinition(target: unknown): target is SourceDefinition { + return target !== null && typeof target === "object" && "view" in target; +} export const fate = createFateServer< FateContext, @@ -48,7 +339,13 @@ export const fate = createFateServer< } const app = adapterContext.get("appContext"); - const principal = await app.auth.require(request); + let principal; + try { + principal = await app.auth.require(request); + } catch { + throw new FateRequestError("UNAUTHORIZED", "Authentication required."); + } + const api = app.hsApi.getRuntimeClient(app.auth.getHeadscaleApiKey(principal)); return { @@ -59,11 +356,21 @@ export const fate = createFateServer< }; }, live, + mutations, roots, sources: { registry, getSource(target) { - return target as SourceDefinition; + if (isSourceDefinition(target)) { + return target; + } + + const source = sourceByView.get(target); + if (!source) { + throw new Error("No Fate source registered for data view"); + } + + return source; }, }, }); diff --git a/app/spa/app.tsx b/app/spa/app.tsx index 849196f..1b8aba7 100644 --- a/app/spa/app.tsx +++ b/app/spa/app.tsx @@ -1,4 +1,5 @@ import { RouterProvider } from "@tanstack/react-router"; +import { Suspense } from "react"; import { FateClient } from "react-fate"; import { createFateClient } from "react-fate/client"; @@ -12,7 +13,11 @@ const fate = createFateClient({ export function App() { return ( - + Loading…} + > + + ); } diff --git a/app/spa/router.tsx b/app/spa/router.tsx index 42c6ed6..1cdc409 100644 --- a/app/spa/router.tsx +++ b/app/spa/router.tsx @@ -1,4 +1,66 @@ import { Link, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; +import { useState, useTransition } from "react"; +import { + useFateClient, + useLiveListView, + useLiveView, + useRequest, + view, + type ViewRef, +} from "react-fate"; + +import type { FateMachine, FateUser } from "../server/fate"; + +const UserView = view()({ + displayName: true, + email: true, + id: true, + name: true, +}); + +const MachineView = view()({ + expiry: true, + givenName: true, + id: true, + ipAddresses: true, + lastSeen: true, + name: true, + online: true, + tags: true, + user: { + displayName: true, + id: true, + name: true, + }, +}); + +const MachineConnectionView = { + args: { first: 100 }, + items: { + cursor: true, + node: MachineView, + }, + pagination: { + hasNext: true, + hasPrevious: true, + nextCursor: true, + previousCursor: true, + }, +}; + +const UserConnectionView = { + args: { first: 100 }, + items: { + cursor: true, + node: UserView, + }, + pagination: { + hasNext: true, + hasPrevious: true, + nextCursor: true, + previousCursor: true, + }, +}; const rootRoute = createRootRoute({ component: RootLayout, @@ -16,7 +78,13 @@ const machinesRoute = createRoute({ component: MachinesPage, }); -const routeTree = rootRoute.addChildren([indexRoute, machinesRoute]); +const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/users", + component: UsersPage, +}); + +const routeTree = rootRoute.addChildren([indexRoute, machinesRoute, usersRoute]); export const router = createRouter({ basepath: __PREFIX__, @@ -36,6 +104,7 @@ function RootLayout() { @@ -58,12 +127,200 @@ function HomePage() { } function MachinesPage() { + const { machines } = useRequest({ machines: { list: MachineConnectionView } }); + const [machineItems, loadNext] = useLiveListView(MachineConnectionView, machines); + return ( -
+

Machines

-

- First migration target: replace the React Router loader/action/SSE model with raw Fate. -

+ + {machineItems.length === 0 ? ( +

+ No machines returned from Headscale. +

+ ) : ( +
+ + + + + + + + + + + + {machineItems.map(({ node: machine }) => ( + + ))} + +
MachineUserAddressesStatusLast seen
+
+ )} + + {loadNext ? ( + + ) : null}
); } + +function MachineRow({ machine: machineRef }: { machine: ViewRef<"Machine"> }) { + const fate = useFateClient(); + const machine = useLiveView(MachineView, machineRef); + const currentName = machine.givenName || machine.name; + const [name, setName] = useState(currentName); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const rename = () => { + const nextName = name.trim(); + if (!nextName || nextName === currentName) { + return; + } + + startTransition(() => { + void (async () => { + setError(null); + const result = await fate.mutations.machine.rename({ + input: { id: machine.id, name: nextName }, + view: MachineView, + }); + + if (result.error) { + setError(result.error.message); + return; + } + + setName(nextName); + })(); + }); + }; + + return ( + + +
{currentName}
+
{machine.id}
+
{ + event.preventDefault(); + rename(); + }} + > + setName(event.currentTarget.value)} + value={name} + /> + +
+ {error ?
{error}
: null} + {machine.tags.length > 0 ? ( +
+ {machine.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} + + + {machine.user ? machine.user.displayName || machine.user.name : "Tag-owned"} + + +
+ {machine.ipAddresses.map((ip) => ( + {ip} + ))} +
+ + + + {machine.online ? "Online" : "Offline"} + + + {formatDate(machine.lastSeen)} + + ); +} + +function UsersPage() { + const { users } = useRequest({ users: { list: UserConnectionView } }); + const [userItems, loadNext] = useLiveListView(UserConnectionView, users); + + return ( +
+

Users

+ + {userItems.length === 0 ? ( +

+ No users returned from Headscale. +

+ ) : ( +
+ {userItems.map(({ node: user }) => ( + + ))} +
+ )} + + {loadNext ? ( + + ) : null} +
+ ); +} + +function UserCard({ user: userRef }: { user: ViewRef<"User"> }) { + const user = useLiveView(UserView, userRef); + + return ( +
+
{user.displayName || user.name}
+
{user.name}
+ {user.email ?
{user.email}
: null} +
{user.id}
+
+ ); +} + +function formatDate(value: string | null | undefined) { + if (!value) return "Never"; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return date.toLocaleString(); +}