Files
headplane/CODEBASE_FINDINGS.md
T
2026-05-16 21:30:16 -04:00

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/users data, 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-97 accepts a posted headscale_user_id and calls context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId).
  • app/server/web/auth.ts:225-237 grants machine ownership based on principal.user.headscaleUserId === node.user?.id.
  • app/routes/machines/machine-actions.ts:57-68 authorizes machine mutations through canManageNode.

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.

Severity: Critical
Area: Auth/session security

Evidence:

  • app/server/web/auth.ts:96-105 manually base64url-encodes JSON and signs it with HMAC.
  • app/server/web/auth.ts:269-283 serializes { sid, api_key } for API-key sessions.
  • app/server/web/auth.ts:144-173 resolves API-key principals from payload.api_key; the DB api_key_hash is not used to retrieve a server-side secret.
  • app/utils/oidc-state.ts:13-19 explicitly sets httpOnly, while the main auth cookie options are not explicit in app/server/context.ts:49-54 / app/server/web/auth.ts:96-105.
  • docs/install/index.md describes server.cookie_secret as 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-39 calls revalidator.revalidate().
  • app/utils/live-data.tsx:63-82 treats every changed event as a global route revalidation.
  • app/routes/util/live.ts:34-37 sends 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-66 mounts LiveDataProvider above all routes.
  • app/routes/auth/login/page.tsx:59-66 calls pause() with no dependency array and no cleanup/resume.
  • app/components/dialog.tsx:21-27 uses the same single boolean pause model for dialogs.
  • app/utils/live-data.tsx:136-141 exposes imperative pause() / 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-101 creates one hsLive for the entire process.
  • app/server/headscale/live-store.ts:69 keeps storedApiClient in shared closure state.
  • app/server/headscale/live-store.ts:113-120 polling uses that shared client.
  • app/server/headscale/live-store.ts:143 and app/server/headscale/live-store.ts:158 overwrite 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-45 polls nodes every 5s and users every 15s.
  • app/server/headscale/live-store.ts:82-104 compares JSON.stringify(data) for the whole resource.
  • app/server/headscale/live-store.ts:113-123 starts intervals lazily but never stops them unless dispose() is called.
  • app/server/context.ts:94 creates the store, but no app shutdown path calls hsLive.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-41 mirrors policy into codePolicy and updates it when loader data changes.
  • app/routes/dns/components/manage-domains.tsx:25-31 mirrors searchDomains into 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-43 uses submittingRef + effects to close/unlock.
  • app/routes/settings/auth-keys/dialogs/add-auth-key.tsx:56-71 uses similar orchestration and directly assigns fetcher.data = undefined.
  • app/routes/machines/dialogs/routes.tsx:49-63 submits 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-27 only authenticates.
  • app/routes/settings/agent.tsx:29-39 only 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-103 wraps the whole loader in try/catch and redirects to /login with destroySession() for any error.
  • app/layout/app.tsx:50-67 handles 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-68 performs resource-specific checks via canManageNode.
  • app/routes/users/user-actions.ts:10-17 checks only broad write_users before operations.
  • app/routes/home.tsx:84-97 lets OIDC users link Headscale identities without revalidating target ownership.
  • app/routes/settings/auth-keys/actions.ts:23-35 has 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-104 casts newRole as Role from form data.
  • app/server/web/auth.ts:467-484 upserts that role into the DB.
  • app/server/web/auth.ts:430-464 transfers ownership with two separate updates.
  • app/server/web/auth.ts:324-342 makes 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-66 pauses live data on every render and never resumes.
  • app/routes/auth/login/page.tsx:68-84 uses window.history.replaceState manually.
  • app/routes/auth/login/page.tsx:78-80 builds newUrl with `{${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-145 converts network errors to React Router data(..., 502).
  • app/server/headscale/api/index.ts:183-203 converts every Headscale >=400 response 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-93 and app/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-250 starts a setInterval inside createHeadscaleInterface.
  • app/server/headscale/api/index.ts:252-272 returns openapiHashes and apiVersion as values captured at return time, while clientHelpers.isAtleast reads 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-127 mutates the YAML document before acquiring writeLock.
  • app/server/headscale/config-loader.ts:120-127 sets writeLock = true without try/finally; a failed write can leave the lock stuck.
  • app/server/headscale/config-dns.ts has a similar write-lock pattern (see writeLock usage).

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-118 pushes nameservers into arrays read from context.hs.c.
  • app/routes/dns/dns-actions.ts:147-164 pushes search domains into arrays read from context.hs.c.
  • app/routes/dns/dns-actions.ts:189-209 writes 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, :179 call context.integration?.onConfigChange(api) without await.
  • app/routes/dns/dns-actions.ts generally 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-101 constructs DB, Headscale API, live store, auth service, OIDC, optional agent manager, and integration.
  • app/server/app.ts:38-40 starts auth pruning.
  • app/server/hp-agent.ts:319-359 starts an interval and child process with dispose().
  • app/server/headscale/live-store.ts:182-191 has dispose().
  • runtime/http.ts:192-200 handles SIGINT/SIGTERM by closing the HTTP server and exiting, but has no app context cleanup.
  • runtime/vite-plugin.ts:44-50 uses ssrLoadModule in 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-20 says all services are closure factories, no classes/globals.
  • app/server/headscale/config-loader.ts:21-213 uses a mutable class for Headscale config.
  • docs/development/architecture.md:162-218 describes server/index.ts, AppRuntime, and context.runtime, while current code uses app/server/app.ts, createAppContext, and direct context.<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-188 creates the WASM/IPN instance and cleanup only sets cancelled = true.
  • app/routes/ssh/wasm.client.ts:27-29 exposes openTunnel only; no dispose().
  • cmd/hp_ssh/hp_ssh.go:16-80 creates JS functions but never releases them with js.Func.Release().
  • internal/hp_ipn/ipnserver.go:107-133 starts 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-63 returns nil from HostKeyCallback.

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:61 indexes peer.TailscaleIPs[0].
  • internal/tsnet/peers.go:119 also indexes peer.TailscaleIPs[0] before checking len(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-56 calls http.Get(testURL) directly.
  • internal/config/preflight.go:47-54 never closes resp.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-72 uses log.Fatal inside NewAgent/Connect rather than returning errors.
  • internal/util/logger.go:25-29 defines encoder and pool, but internal/util/logger.go:59-66 only 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.json has typecheck, lint, and format scripts.
  • .github/workflows/build.yaml:32-37 runs ./build.sh --skip-pnpm-prune, unit tests, and integration tests.
  • build.sh:157-173 runs pnpm run build, not pnpm run typecheck or pnpm 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-154 runs go mod vendor, applies a patch, builds, then removes vendor only 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.5 and 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/Response model 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 / createHTTPTransport while bootstrapping, then generated createFateClient(...) 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, 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 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.

  1. Remove transitional abstractions and make the branch clearly one-way toward SPA + raw Fate.
  2. 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.
  3. 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.
  4. Mount raw Fate endpoints in the current Node request path:
    • /fate for native RPC;
    • /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. 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

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 migrate in 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

  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.