37 KiB
Headplane codebase findings
Date: 2026-05-16
Scope: server-side TypeScript, React Router route modules, client components, live-data/data-fetching paths, auth/session/RBAC, Headscale config/API integration, Go agent/WebSSH code, runtime/build/CI, and docs. This is an architecture and pattern review, not a completed security audit.
Executive summary
Headplane's biggest codebase risks are not isolated style issues; they cluster around ownership boundaries:
- Auth/session boundary: API-key sessions put the raw Headscale API key in the auth cookie, and self-service account linking can claim arbitrary unclaimed Headscale users.
- Live-data boundary: the current live path polls globally, caches globally, and tells React Router to re-run whole active route loaders on any resource change.
- Lifecycle boundary: process-lifetime services have
setInterval, child processes, SSE streams, Undici agents, and WASM/tsnet state, but there is no coordinated shutdown/disposal model. - Mutation boundary: config-file edits and several admin/user mutations rely on UI constraints, mutable process state, non-transactional writes, or inconsistent server-side validation.
- Framework/tooling boundary: React Router loaders/actions currently carry a lot of app state orchestration. Fate could be a good fit for live
nodes/usersdata, but it will not fix auth, authorization, config mutation, WebSSH, or agent lifecycle issues by itself.
Highest-priority findings
1. OIDC self-linking can claim arbitrary unclaimed Headscale users
Severity: Critical
Area: AuthZ / account linking
Evidence:
app/routes/home.tsx:84-97accepts a postedheadscale_user_idand callscontext.auth.linkHeadscaleUser(principal.user.id, headscaleUserId).app/server/web/auth.ts:225-237grants machine ownership based onprincipal.user.headscaleUserId === node.user?.id.app/routes/machines/machine-actions.ts:57-68authorizes machine mutations throughcanManageNode.
Why it matters: a logged-in OIDC user can forge the form POST and link themselves to any unclaimed Headscale user ID. Once linked, they can manage that user's machines through the machine action authorization path.
Suggested direction: recompute allowed self-link targets server-side inside the action and reject anything not in that set. Prefer auto-linking only by verified OIDC subject/email match, and reserve arbitrary linking for admins.
2. API-key sessions put the raw Headscale API key in a signed cookie
Severity: Critical
Area: Auth/session security
Evidence:
app/server/web/auth.ts:96-105manually base64url-encodes JSON and signs it with HMAC.app/server/web/auth.ts:269-283serializes{ sid, api_key }for API-key sessions.app/server/web/auth.ts:144-173resolves API-key principals frompayload.api_key; the DBapi_key_hashis not used to retrieve a server-side secret.app/utils/oidc-state.ts:13-19explicitly setshttpOnly, while the main auth cookie options are not explicit inapp/server/context.ts:49-54/app/server/web/auth.ts:96-105.docs/install/index.mddescribesserver.cookie_secretas encrypting cookies, but the implementation signs rather than encrypts.
Why it matters: cookie compromise becomes direct Headscale API compromise, not just Headplane session compromise. HMAC protects integrity, not confidentiality. The auth cookie should be an opaque session handle, not a transport for upstream credentials.
Suggested direction: store only sid in the cookie; keep the API key encrypted or server-side in the DB/secret store; verify api_key_hash if retaining API-key sessions; explicitly set httpOnly, sameSite, secure, path, and consider key rotation/versioning. Use timing-safe HMAC comparison if manual signatures remain.
Live data and data-fetching findings
3. SSE events trigger whole-route revalidation instead of resource-scoped updates
Severity: High
Area: Client data fetching / live data
Evidence:
app/utils/live-data.tsx:35-39callsrevalidator.revalidate().app/utils/live-data.tsx:63-82treats everychangedevent as a global route revalidation.app/routes/util/live.ts:34-37sends only{ resource, version }, not field/object-level deltas or subscribed views.
Why it matters: any node/user change can re-run every active loader for the current route tree. Pages such as machines/users load multiple resources and agent metadata, so frequent Headscale changes can become over-fetching and UI churn.
Suggested direction: short term, include enough resource metadata to revalidate only affected routes or debounce/coalesce revalidation. Long term, move live data to normalized object/list subscriptions instead of request/route invalidation.
4. Live-data pause state is global, boolean, and can stick disabled
Severity: High
Area: Client state / live data
Evidence:
app/root.tsx:41-66mountsLiveDataProviderabove all routes.app/routes/auth/login/page.tsx:59-66callspause()with no dependency array and no cleanup/resume.app/components/dialog.tsx:21-27uses the same single boolean pause model for dialogs.app/utils/live-data.tsx:136-141exposes imperativepause()/resume()with no ownership token.
Why it matters: visiting /login can leave live updates disabled after navigating into the app. Multiple pause consumers can also fight: closing one dialog can resume live updates while another still expects them paused.
Suggested direction: add immediate cleanup in login (pause(); return resume) and a dependency array. Replace the boolean with a refcount/token model so each consumer releases only its own pause.
5. hsLive is process-global but stores one mutable API client
Severity: High
Area: Server live data / cache scoping
Evidence:
app/server/context.ts:86-101creates onehsLivefor the entire process.app/server/headscale/live-store.ts:69keepsstoredApiClientin shared closure state.app/server/headscale/live-store.ts:113-120polling uses that shared client.app/server/headscale/live-store.ts:143andapp/server/headscale/live-store.ts:158overwrite it from each caller.
Why it matters: the last request/action to touch hsLive controls which API key future background polls use. A bad/expired user-supplied API-key session can poison live refresh for everyone until another request resets it. Snapshots are also not principal/session scoped.
Suggested direction: poll with one stable server credential only, or scope cache/polling by principal/session. If adopting Fate, make this one of the first seams to replace.
6. Live store polling is coarse and lifecycle-unmanaged
Severity: Medium
Area: Server lifecycle / live data
Evidence:
app/server/headscale/live-store.ts:38-45polls nodes every 5s and users every 15s.app/server/headscale/live-store.ts:82-104comparesJSON.stringify(data)for the whole resource.app/server/headscale/live-store.ts:113-123starts intervals lazily but never stops them unlessdispose()is called.app/server/context.ts:94creates the store, but no app shutdown path callshsLive.dispose().
Why it matters: whole-resource JSON comparison is order-sensitive and grows with tailnet size. The polling intervals continue for process lifetime and can leak during dev HMR or repeated context creation.
Suggested direction: introduce app-level lifecycle management and subscription-aware polling, or replace with event/object-scoped live subscriptions.
7. Loader data is mirrored into local state and can overwrite edits during revalidation
Severity: Medium
Area: Client state / React patterns
Evidence:
app/routes/acls/overview.tsx:30-41mirrorspolicyintocodePolicyand updates it when loader data changes.app/routes/dns/components/manage-domains.tsx:25-31mirrorssearchDomainsinto local state.
Why it matters: global SSE revalidation can replace local edit state. The ACL editor is especially risky because an unsaved policy can be overwritten by background loader refresh.
Suggested direction: separate “initial server value” from “dirty draft” state; never overwrite dirty drafts on background revalidation without prompting.
8. Fetcher/dialog orchestration is imperative and duplicated
Severity: Medium
Area: Client forms / mutations
Evidence:
app/routes/machines/dialogs/tags.tsx:34-43usessubmittingRef+ effects to close/unlock.app/routes/settings/auth-keys/dialogs/add-auth-key.tsx:56-71uses similar orchestration and directly assignsfetcher.data = undefined.app/routes/machines/dialogs/routes.tsx:49-63submits on switch changes without optimistic/error state.
Why it matters: each dialog reimplements mutation lifecycle handling. Direct mutation of fetcher.data fights router-owned state and can hide stale or failed submissions.
Suggested direction: centralize fetcher mutation state handling, or move to an action/mutation primitive with explicit pending/error/success states and optimistic updates.
Auth, authorization, and route handling findings
9. Agent settings route/action lacks capability checks
Severity: High
Area: Authorization
Evidence:
app/routes/settings/agent.tsx:13-27only authenticates.app/routes/settings/agent.tsx:29-39only authenticates before triggering sync.
Why it matters: any authenticated user can view agent status and trigger sync work if they know the URL, regardless of settings/admin capability.
Suggested direction: gate read and sync separately, probably using read_feature/write_feature or a dedicated agent capability.
10. App layout catches all loader errors and destroys sessions
Severity: High
Area: Error handling / auth UX
Evidence:
app/layout/app.tsx:32-103wraps the whole loader intry/catchand redirects to/loginwithdestroySession()for any error.app/layout/app.tsx:50-67handles one expected expired-API-key case, but the outer catch covers unrelated failures too.
Why it matters: network errors, programming errors, and transient Headscale failures can become silent logouts. It also hides diagnostics from route error boundaries.
Suggested direction: catch only expected auth/session errors; let operational/programming errors hit the error boundary or a health banner.
11. RBAC is inconsistent across routes/actions
Severity: Medium/High
Area: Authorization
Evidence:
app/routes/machines/machine-actions.ts:64-68performs resource-specific checks viacanManageNode.app/routes/users/user-actions.ts:10-17checks only broadwrite_usersbefore operations.app/routes/home.tsx:84-97lets OIDC users link Headscale identities without revalidating target ownership.app/routes/settings/auth-keys/actions.ts:23-35has more careful self-service ownership checks, showing the pattern exists but is not universal.
Why it matters: permissions are enforced route-by-route with no single policy layer, so new actions can accidentally rely on UI hiding. The self-linking issue is one concrete outcome.
Suggested direction: create server-side action guards for common ownership/role decisions and require every mutation to call one.
12. Runtime role input is not validated and ownership updates are non-transactional
Severity: Medium
Area: Auth/RBAC data integrity
Evidence:
app/routes/users/user-actions.ts:82-104castsnewRole as Rolefrom form data.app/server/web/auth.ts:467-484upserts that role into the DB.app/server/web/auth.ts:430-464transfers ownership with two separate updates.app/server/web/auth.ts:324-342makes the first user owner with a count-after-insert flow.
Why it matters: forged role values can reach storage, and failures/races in ownership transfer or first-login owner selection can produce invalid owner state.
Suggested direction: validate newRole against Roles at the route boundary, use transactions for ownership transfer, and make first-owner assignment atomic.
13. Login page has live-data and URL cleanup bugs
Severity: Medium
Area: Client route behavior
Evidence:
app/routes/auth/login/page.tsx:59-66pauses live data on every render and never resumes.app/routes/auth/login/page.tsx:68-84useswindow.history.replaceStatemanually.app/routes/auth/login/page.tsx:78-80buildsnewUrlwith`{${window.location.pathname}?...`, leaving a literal{in the URL.
Why it matters: this can disable live updates and corrupt/uglify login URLs. Manual history mutation bypasses router state.
Suggested direction: use a loader redirect or router navigation where possible; otherwise fix the string and make the effect one-shot with cleanup.
Headscale API and config mutation findings
14. Headscale API errors are collapsed into 502 Bad Gateway
Severity: Medium/High
Area: API wrapper / error handling
Evidence:
app/server/headscale/api/index.ts:139-145converts network errors to React Routerdata(..., 502).app/server/headscale/api/index.ts:183-203converts every Headscale>=400response to outer status 502 while preserving the real status only inside the body.- Many callers inspect raw strings/statuses manually, e.g.
app/routes/auth/login/action.ts:78-93andapp/routes/acls/acl-action.ts:39-107.
Why it matters: UI and route logic must understand wrapper internals. HTTP semantics are obscured, and handling becomes string-fragile.
Suggested direction: preserve upstream status classes where safe, expose typed domain errors, and make expected Headscale quirks explicit in one adapter layer.
15. OpenAPI polling interval has no disposal and returned interface exposes stale values
Severity: Medium
Area: Server lifecycle / API versioning
Evidence:
app/server/headscale/api/index.ts:241-250starts asetIntervalinsidecreateHeadscaleInterface.app/server/headscale/api/index.ts:252-272returnsopenapiHashesandapiVersionas values captured at return time, whileclientHelpers.isAtleastreads the mutable closure.- No
dispose()exists on the interface.
Why it matters: lifecycle leaks in dev/reload scenarios, and consumers reading context.hsApi.apiVersion can see stale data while helpers see updated data.
Suggested direction: add lifecycle, expose getters for mutable version state, or only detect once at startup.
16. Config patching is not safely serialized
Severity: Medium/High
Area: Config mutation / concurrency
Evidence:
app/server/headscale/config-loader.ts:63-127mutates the YAML document before acquiringwriteLock.app/server/headscale/config-loader.ts:120-127setswriteLock = truewithouttry/finally; a failed write can leave the lock stuck.app/server/headscale/config-dns.tshas a similar write-lock pattern (seewriteLockusage).
Why it matters: concurrent admin actions can interleave mutations, lose updates, or deadlock future writes after an exception.
Suggested direction: replace the boolean lock/spin loop with a promise queue or mutex; acquire before document mutation; release in finally; write atomically via temp file + rename.
17. DNS/config actions mutate shared config arrays and lack input validation
Severity: Medium
Area: Config mutation / validation
Evidence:
app/routes/dns/dns-actions.ts:103-118pushes nameservers into arrays read fromcontext.hs.c.app/routes/dns/dns-actions.ts:147-164pushes search domains into arrays read fromcontext.hs.c.app/routes/dns/dns-actions.ts:189-209writes DNS record type/value from form data with minimal validation.
Why it matters: in-memory config can be mutated before persistence succeeds, and invalid values can be written directly to Headscale config.
Suggested direction: clone before modification, validate domain/IP/record types server-side, and return actionable typed errors.
18. Restrictions config actions fire integration restarts without awaiting them
Severity: Medium
Area: Config mutation / operational consistency
Evidence:
app/routes/settings/restrictions/actions.ts:51,:79,:100,:129,:150,:179callcontext.integration?.onConfigChange(api)withoutawait.app/routes/dns/dns-actions.tsgenerally awaits the same hook.
Why it matters: the response can report success while restart/reload fails in the background, and unhandled rejections can be lost.
Suggested direction: await the hook consistently or queue/retry restarts through an explicit background job with surfaced status.
Server lifecycle and runtime findings
19. Process-lifetime services have no composition-root stop()
Severity: High
Area: Server lifecycle
Evidence:
app/server/context.ts:21-101constructs DB, Headscale API, live store, auth service, OIDC, optional agent manager, and integration.app/server/app.ts:38-40starts auth pruning.app/server/hp-agent.ts:319-359starts an interval and child process withdispose().app/server/headscale/live-store.ts:182-191hasdispose().runtime/http.ts:192-200handles SIGINT/SIGTERM by closing the HTTP server and exiting, but has no app context cleanup.runtime/vite-plugin.ts:44-50usesssrLoadModulein dev; HMR can recreate modules/services without calling old disposers.
Why it matters: intervals, child processes, SSE listeners, and Undici agents can leak in dev and are force-killed in production instead of being drained.
Suggested direction: return an app runtime with stop() from the composition root; call auth.stop(), hsLive.dispose(), agents.dispose(), hsApi.undiciAgent.close(), and integration cleanup from production shutdown and Vite HMR dispose hooks.
20. Architecture docs describe patterns the code no longer fully follows
Severity: Medium
Area: Documentation / maintainability
Evidence:
docs/development/architecture.md:15-20says all services are closure factories, no classes/globals.app/server/headscale/config-loader.ts:21-213uses a mutable class for Headscale config.docs/development/architecture.md:162-218describesserver/index.ts,AppRuntime, andcontext.runtime, while current code usesapp/server/app.ts,createAppContext, and directcontext.<service>.
Why it matters: docs are important to this project. Stale architecture guidance makes future changes less consistent and harder for agents/contributors to follow.
Suggested direction: update the architecture docs to the current composition root and explicitly document exceptions such as config-file wrappers.
Go agent and WebSSH findings
21. WebSSH WASM lacks a top-level disposal model and leaks JS functions
Severity: High
Area: WebSSH lifecycle / browser resources
Evidence:
app/routes/ssh/page.tsx:156-188creates the WASM/IPN instance and cleanup only setscancelled = true.app/routes/ssh/wasm.client.ts:27-29exposesopenTunnelonly; nodispose().cmd/hp_ssh/hp_ssh.go:16-80creates JS functions but never releases them withjs.Func.Release().internal/hp_ipn/ipnserver.go:107-133starts backend/server work but exposes no shutdown API.
Why it matters: repeated SSH sessions can leave in-browser tsnet/backend resources and JS function handles alive until tab refresh.
Suggested direction: add top-level dispose() to the JS API, release Go js.Func handles, cancel contexts, close sessions/backend/server, and call dispose from React cleanup.
22. WebSSH disables SSH host key verification
Severity: Medium/High
Area: Security / WebSSH
Evidence:
internal/hp_ipn/ssh.go:59-63returnsnilfromHostKeyCallback.
Why it matters: this accepts any host key and allows MITM within the network path. Tailscale identity reduces exposure, but SSH host identity is still bypassed.
Suggested direction: document the tradeoff clearly at minimum. Prefer known_hosts-style pinning, Tailscale SSH identity integration, or an explicit trust-on-first-use flow.
23. Go agent can panic on peers without Tailscale IPs
Severity: Medium
Area: Go agent robustness
Evidence:
internal/tsnet/peers.go:61indexespeer.TailscaleIPs[0].internal/tsnet/peers.go:119also indexespeer.TailscaleIPs[0]before checkinglen(ip) == 0.
Why it matters: transient or malformed peer state can crash host-info collection.
Suggested direction: check len(peer.TailscaleIPs) > 0 before indexing in both paths.
24. Go agent preflight uses http.Get without timeout and does not close response bodies
Severity: Medium
Area: Go agent robustness
Evidence:
internal/config/preflight.go:40-56callshttp.Get(testURL)directly.internal/config/preflight.go:47-54never closesresp.Body.
Why it matters: startup can hang indefinitely on network issues, and response bodies leak.
Suggested direction: use http.Client{Timeout: ...} and defer resp.Body.Close().
25. Go library-like code exits the process and logging is hand-rolled
Severity: Medium
Area: Go maintainability
Evidence:
internal/tsnet/server.go:22-72useslog.FatalinsideNewAgent/Connectrather than returning errors.internal/util/logger.go:25-29definesencoderandpool, butinternal/util/logger.go:59-66only writes plain stderr lines and exits on fatal.
Why it matters: callers cannot recover or return structured errors, and the logger has unused complexity while still lacking structured output.
Suggested direction: return errors from NewAgent/Connect, let cmd/hp_agent decide process exit, and simplify or replace the logger.
Build, CI, and tooling findings
26. CI does not explicitly run typecheck or lint
Severity: Medium
Area: Tooling / quality gates
Evidence:
package.jsonhastypecheck,lint, andformatscripts..github/workflows/build.yaml:32-37runs./build.sh --skip-pnpm-prune, unit tests, and integration tests.build.sh:157-173runspnpm run build, notpnpm run typecheckorpnpm run lint.
Why it matters: Vite/React Router builds transpile TypeScript but are not a substitute for tsgo typechecking. Lint-only issues can land despite local scripts existing.
Suggested direction: add pnpm run typecheck and pnpm run lint to CI. Consider pnpm run format --check if supported by oxfmt.
27. Build script leaves temporary vendor/ behind if WASM build fails
Severity: Low/Medium
Area: Build hygiene
Evidence:
build.sh:143-154runsgo mod vendor, applies a patch, builds, then removesvendoronly after success.
Why it matters: failed local builds can leave a large generated directory in the worktree, which is easy to accidentally inspect or commit around.
Suggested direction: add a trap around the vendoring step to remove vendor on failure.
Fate/SPA/Vite+ migration assessment
Updated direction: SPA first, not Void first
After comparing the shape of Headplane with Void's current platform/runtime direction, the better target is not a Void app. Headplane is a self-hosted local control-plane UI that needs a predictable Node process, local filesystem/config access, SQLite, child process/agent management, long-lived SSE, and a packaging story that can eventually compile into a Node static SEA.
The preferred target is now a one-way SPA cutover:
Hono Node server + Vite SPA + TanStack Router + raw Fate
The important architectural choice is that Fate becomes the data framework directly while the app shell stays thin. The shell should own static assets, cookies/session plumbing, routing, and lifecycle. Fate should own reads, mutations/actions, normalized cache updates, and live object/list subscriptions. Do not build a Headplane-specific abstraction layer over Fate's live bus, view resolution, actions, or native HTTP handlers unless a concrete repeated problem appears after using the raw APIs.
Void may still be a useful reference implementation for Fate integration, but it should not drive Headplane's runtime architecture unless it later proves a first-class self-hosted Node mode that fits Headplane's install model.
Current Fate fit
Fate is directly aimed at the pain Headplane is showing: declarative views, normalized cache, data masking, Async React, optimistic actions/mutations, and live views over SSE. Fate 1.0 says it now includes production-ready live views, Drizzle support, garbage collection, and native HTTP transport. The docs also still contain an alpha warning in the getting-started page, so I would treat the ecosystem as promising but still worth piloting behind a branch.
Headplane is already close on prerequisites:
- React is
19.2.5and Fate requires React 19.2+. - The app already uses Vite 8-era tooling, Vitest, Oxlint, Oxfmt, tsgo, and pnpm.
- Headplane already has Drizzle, but only for Headplane-local data (
users, sessions, host info), while core tailnet data comes from the remote Headscale API.
What Fate would improve
- Replace
LiveDataProvider+useRevalidator()with object/list-level subscriptions (useLiveView/useLiveListView). - Normalize
nodes,users, pre-auth keys, and agent host info instead of passing large loader payloads around. - Let components declare data needs near rendering rather than building large page-level loader DTOs.
- Make mutations return selected updated data and update dependent views without manual
context.hsLive.refresh(...)calls.
What Fate will not fix
- API-key-in-cookie session design.
- Self-linking authorization.
- Missing route/action capability checks.
- Headscale config-file write races.
- WebSSH/WASM lifecycle and SSH host-key verification.
- Go agent robustness issues.
These should be fixed before or alongside any framework migration.
Headplane-specific adoption challenges
- Remote API source: Fate's Drizzle adapter helps for local DB rows, but
Machine,User,PreAuthKey, ACL policy, and DNS/config data mostly come from Headscale REST/config files. A Headscale Fate source/adapter or custom native HTTP query layer would be needed. - Authorization: Fate views must be scoped by the authenticated principal. Do not reproduce the current process-global cache or mutable API client.
- Live events: Headscale does not appear to push the exact object-level events Headplane needs, so Headplane may still need polling or mutation-triggered
live.update(...)calls. The win is to send object/list updates to subscribed views, not to revalidate whole routes. - Server framework: the desired end state is no longer another heavyweight metaframework. Hono is a good fit for the server shell because it uses the Fetch
Request/Responsemodel Fate already targets, while still running as a normal self-hosted Node process.
Target runtime shape
╭────────────────────────────────────────────╮
│ Node static SEA / Docker image │
│ - bundled server JS │
│ - embedded or adjacent Vite client assets │
╰───────────────────┬────────────────────────╯
│
▼
╭────────────────────────────────────────────╮
│ Hono Node server │
│ - process lifecycle start/stop │
│ - static asset + SPA fallback serving │
│ - cookie/session middleware │
│ - /fate and /fate/live │
│ - /api, /events during transition │
╰───────────────────┬────────────────────────╯
│
▼
╭────────────────────────────────────────────╮
│ Framework-neutral Headplane server core │
│ - auth/session/authorization │
│ - Headscale API/config adapters │
│ - agent manager │
│ - Fate live bus used directly │
╰───────────────────┬────────────────────────╯
│
▼
╭────────────────────────────────────────────╮
│ Vite SPA │
│ - TanStack Router for navigation/search │
│ - raw Fate for all app data and mutations │
│ - no route loaders/actions/fetchers │
╰────────────────────────────────────────────╯
Node SEA packaging implications
The SPA direction is a better fit for a Node static SEA than SSR framework mode because the runtime can become one server entry plus a finite static asset set. The server should be structured so that production can first serve assets from build/client, then later swap that out for an embedded asset manifest without changing application routing.
Practical constraints for the SEA target:
- keep one explicit production server entry instead of framework-generated adapter code;
- avoid dynamic runtime imports for route modules in production;
- make client assets addressable by a generated manifest rather than filesystem discovery;
- keep mutable data outside the SEA (
data_path, Headscale config paths, SQLite, logs); - preserve direct file serving for large WASM artifacts until we decide whether they should be embedded or adjacent assets.
Raw Fate rule
Use Fate's public APIs directly:
createFateServer(...)and the native HTTP handler for/fate;- Fate's live bus directly for
live.update(...),live.delete(...), and connection/list invalidations; - Fate's context callback directly for request auth and Headscale API access;
FateClient,createClient/createHTTPTransportwhile bootstrapping, then generatedcreateFateClient(...)once the Fate Vite plugin has a real server module;useRequest,useView,useLiveView,useLiveListView, and Fate actions directly in React.
Do not add project-level wrappers such as HeadplaneLivePublisher, HeadplaneDataContext, or a custom Fate transport abstraction at the beginning. If raw Fate usage becomes repetitive, extract only the smallest local helper at the repetition site.
Current scaffold on tale/fate-spa
- Removed the earlier
HeadplaneRuntime,HeadplaneDataContext, andHeadplaneLivePublisherscaffolding. - Added direct dependencies:
react-fate,@nkzw/fate,@tanstack/react-router,@tanstack/router-plugin, and@vitejs/plugin-react. - Added a plain Vite SPA
index.htmlandapp/spaentry with TanStack Router and the generated raw Fate client fromreact-fate/client. - Moved the old React Router Vite config to
vite-old.config.tsand replacedvite.config.tswith a clean SPA config. - Added Hono and
@hono/node-server, plus a minimal Hono server shell inapp/server/hono-app.ts,app/server/hono-dev.ts, andapp/server/hono-main.ts. - Added
app/server/fate.ts, which exports a raw Fate server and live bus. Hono mounts Fate'screateHonoFateHandler(fate)at/admin/fateand/admin/fate/*, passing the existing app context through Hono variables. - Wired the official
react-fate/viteplugin toapp/server/fate.ts, ignored generated.fate/output, and madepnpm run typecheckrunfate generatebeforetsgoso generatedreact-fate/clienttypings exist from a clean checkout. - Added the first real Fate read roots:
machinesandusers. They use FatedataView(...),list(...), and source executors directly, call the existing principal-scoped Headscale runtime API client, and enforce existingread_machines/read_userscapabilities. - Added minimal SPA
/machinesand/usersroutes that fetch with rawuseRequest(...), render records withuseLiveView(...), and subscribe to root list connections withuseLiveListView(...). These routes intentionally do not port filters, actions, or optimistic updates yet. - Bridged existing
hsLiveresource changes directly to Fate connection invalidations:nodesinvalidates themachinesroot connection andusersinvalidates theusersroot 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 existingcanManageNodeauthorization, calls the Headscale API, refreshes the transitionalhsLivenodes resource, emits direct Fate entity/list live events, and returns the client-selectedMachineview. pnpm devnow runs the Hono/Vite middleware shell with local.datastorage for the example config;pnpm buildnow runsvite buildfor 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
principalandrequestavailable for authorization and future audit/session needs; - keep
appavailable 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, orauthKeysmodule 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
- Remove transitional abstractions and make the branch clearly one-way toward SPA + raw Fate.
- Install the direct dependencies:
react-fate,@nkzw/fate,@tanstack/react-router,@tanstack/router-plugin, and the plain Vite React plugin if the React Router plugin is removed. - Replace the build/dev entry shape:
- add a Vite SPA entry and TanStack route tree;
- stop generating new React Router route types;
- keep the existing Node server entry as the self-hosted process.
- Mount raw Fate endpoints in the current Node request path:
/fatefor native RPC;/fate/livefor SSE and subscription control;- request context resolves auth using the existing auth service and calls
context.hsApi.getRuntimeClient(...)directly.
- Convert the machines page first using raw Fate views and live list/view hooks.
- 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.
- 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.
- Delete the old React Router loader/action/SSE path for converted data, rather than running duplicate data models side-by-side.
- Fix critical auth/session issues early: self-linking, raw API-key cookie, agent-route authz.
- 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
Vite+ is a unified CLI (vp) for Vite, Vitest, Oxlint, Oxfmt, Rolldown, tsdown, type checking, package-manager/runtime management, and task caching. Headplane already uses most of these tools separately, so Vite+ would mostly consolidate tooling and improve task ergonomics; it will not solve the data-layer problems by itself.
Recommended Vite+ approach:
- Try
vp migratein a separate branch only after adding CI typecheck/lint gates. - Expect manual work around the custom React Router SSR entry and
runtime/vite-plugin.ts. - Do not combine Vite+ migration with Fate/Void/router migration in the same PR.
Suggested immediate backlog
- Replace the temporary
hsLivebridge with direct Fate events from converted mutations and, if needed, a principal-safe polling source. - Move the machine rename UI out of the throwaway table row controls once the permanent SPA machines page layout exists.
- Port the next machine mutations: expire/delete/tags/routes, one at a time, each returning selected data or deleting/updating the normalized cache explicitly.
- Fix OIDC self-linking authorization.
- Make API-key sessions opaque/server-side and set explicit auth cookie flags.
- Add capability checks to
/settings/agentloader/action. - Fix live-data pause cleanup/refcounting for unconverted React Router routes.
- Make
hsLiveuse a stable server credential or principal-scoped cache while it still exists. - Serialize config patches with a real mutex/queue and clone config arrays before editing.
- Add CI
pnpm run typecheckandpnpm run lint. - Add WebSSH top-level dispose and release Go
js.Funcvalues.