Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple
or Google credential exists, so the pipe is proven before the hard part.
officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and
proxied at /api/notify. It is a sidecar rather than platform code because the producers are
spread across sidecars — the queue, email, the agent — and a platform-owned notifier would
force every one of them to call back into the platform. That is the inversion just removed
from email; this avoids recreating it.
Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather
than replacing anything. Each is awaited with its own error boundary and the dispatcher
always resolves: a job that finished has finished whether or not a banner appeared, so a
channel must never be able to break its producer.
text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to
render a banner, so "send nothing" was never available — what we control is that the string
is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and
the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to
put one.
Device registration lives behind X-Officer-User, trusted because the listener binds loopback.
Platform and environment are validated rather than defaulted: an iOS token from a debug build
fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that
never receives anything and never says why. GET /_officer/devices returns only the last 8
characters of a token — enough to identify a row, not enough to push to it.
Verified end to end against a fake webhook: /_health reports configured channels, a test
notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as
{"content":"3 new emails"}, and every validation path returns its own error.
Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
262 lines
14 KiB
Markdown
262 lines
14 KiB
Markdown
# CLAUDE.md
|
|
|
|
## Project Overview
|
|
|
|
Officer is a self-hosted personal platform for one person: the server owner. It bundles an AI agent,
|
|
a terminal, a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable
|
|
dashboards behind a single web app.
|
|
|
|
**Single-user is a hard invariant, not a stage.** There is exactly one account, created once by
|
|
`POST /auth/bootstrap` while the user table is empty. There are no roles, no invitations, no
|
|
sandboxing of one user from another, and no per-user isolation anywhere in the codebase. If a change
|
|
seems to need "which user is this", the answer is always the owner.
|
|
|
|
## Architecture
|
|
|
|
One Bun process (`src/server.tsx`) serves everything:
|
|
|
|
- the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev)
|
|
- the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`)
|
|
- eight WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, desktop,
|
|
vault — plus a sidecar registration socket. `terminal` is a byte relay onto the pty sidecar's own
|
|
listener, not a translating bridge; `vault` is the same shape onto Vaultwarden's notifications hub.
|
|
- a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792)
|
|
|
|
Long-running and privileged work lives in **sidecars**: separate processes that dial back in over
|
|
`/api/sidecar/register` and are tracked in `src/servers/sidecar-registry.ts`. PM2 runs them
|
|
(`ecosystem.config.cjs`): `officer` (the server), `officer-anthropic-proxy`, `officer-agent`,
|
|
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
|
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`.
|
|
|
|
**`officer-anthropic-proxy` and `officer-agent` are not the same thing.** The proxy holds the Anthropic
|
|
credential and forwards API traffic; the agent is the process that spawns `claude`. They were one entry
|
|
named `officer-claude` until the sidecar-isolation work — which is exactly how the false claim that
|
|
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
|
|
`officer`, so **no sidecar is a child of the server and restarting the server does not kill one.**
|
|
|
|
Agents run **unsandboxed as the server owner**, with `--dangerously-skip-permissions`. This is
|
|
deliberate — it is the owner's own machine. Do not add a jail without being asked.
|
|
|
|
## Repository Layout
|
|
|
|
```
|
|
src/
|
|
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
|
|
├── apps/
|
|
│ ├── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
|
|
│ └── landing/ # marketing landing page
|
|
├── servers/
|
|
│ ├── hono.ts # router composition; everything under /api
|
|
│ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting
|
|
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
|
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
|
│ ├── queue/ # background job engine
|
|
│ └── sidecar/ # sidecar implementations + the wire protocol
|
|
├── databases/officer_db/ # the only database (Postgres + Drizzle)
|
|
├── extensions/browser-relay/
|
|
└── workspaces/ # shared packages, each a bun workspace
|
|
```
|
|
|
|
`src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat,
|
|
Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`.
|
|
`src/apps/officer-web` is only the shell — screens, routing and settings.
|
|
|
|
**Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` →
|
|
`src/workspaces/components`, `@@/*` → `src/servers`, `@/public/*` → `public`. Workspace packages are
|
|
imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helpers`, …).
|
|
|
|
## Tech Stack
|
|
|
|
- **Runtime**: Bun (Node 22 is enforced by a `preinstall` check)
|
|
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
|
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
|
- **Backend**: Hono
|
|
- **Database**: one Postgres database via Drizzle
|
|
- **Agents**: Claude Code and opencode, driven through sidecars; tools exposed over MCP
|
|
|
|
## Data
|
|
|
|
Two stores, and the split matters:
|
|
|
|
**Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards,
|
|
email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in
|
|
`src/queries/`, types inferred from the schema in `src/types.ts`.
|
|
|
|
**The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory
|
|
per item under `skills/`, `tools/`, `tasks/`, `processes/`, `extensions/` — no database rows, no
|
|
scope tiers. `DATA_PATH/<email>/` holds the managed home, attachments and the per-account email SQLite
|
|
stores — those are the **email sidecar's**, and nothing in the platform opens them. Path helpers live in
|
|
`src/servers/data-path.ts`; note `getHomeDir` (the managed home under
|
|
`DATA_PATH`) versus `getOwnerHomeDir` (the owner's real login home when `HOME_DIR` is set, which is
|
|
where terminals, chats and task runs actually execute).
|
|
|
|
### Schema changes use `push`, not migrations
|
|
|
|
This database is kept in sync with `bun db:push`, which diffs the schema code against the live
|
|
database and alters it directly. **`drizzle-kit migrate` has never been run here** — there is no
|
|
`__drizzle_migrations` table. Change the schema, run `bun db:push`, done.
|
|
|
|
`bun db:gen` writes files to `officer_db/migrations/`, but nothing applies them; the old numbered
|
|
history was deleted because it had drifted from the real schema. Treat the schema code, not those
|
|
files, as the source of truth.
|
|
|
|
## Security Model
|
|
|
|
The perimeter is one credential, so the guards matter:
|
|
|
|
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
|
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
|
limiting and password rules all key off it — they fail closed.
|
|
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
|
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
|
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
|
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
|
authenticated request until the server restarts.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
bun dev # the whole app — SPA + API + WebSockets, watched
|
|
bun start # production
|
|
bunx tsgo # typecheck (not tsc)
|
|
bun test # tests
|
|
bun format # prettier over every dirty file — see the note below before running it
|
|
bun db:push # apply the schema to Postgres
|
|
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
|
|
```
|
|
|
|
Sidecar control is PM2, not npm scripts: `pm2 restart officer-<name>`, `pm2 logs officer-<name>`.
|
|
See `docs/working-on-officer.md` for which process a given change needs restarted.
|
|
|
|
**Format your own files, not the whole dirty tree.** `bun format` globs `git diff --name-only HEAD`,
|
|
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
|
|
up as unexplained whitespace churn in someone else's diff. Run `bunx prettier --write <paths>` on the
|
|
files you actually touched. `bun format` is only safe when the tree is otherwise clean.
|
|
|
|
|
|
## Code Style
|
|
|
|
- **Paradigm**: functional — pure functions, immutability, composition
|
|
- **TypeScript**: strict, no `any`. Type-only imports are required (`verbatimModuleSyntax`).
|
|
- **Comments**: minimal, and about *why*. Don't narrate what the code already says.
|
|
- **Async**: always async/await
|
|
- **Exports**: named only, no defaults
|
|
- **Files**: `PascalCase.tsx` for components, `kebab-case.ts` for everything else
|
|
- **Prettier**: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
|
|
|
|
### Imports
|
|
|
|
Order: types → external → workspace → relative.
|
|
|
|
```ts
|
|
import type { User } from 'types';
|
|
import { useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { formatDate } from 'helpers/formatters';
|
|
import { useWebsites } from '../useWebsites';
|
|
```
|
|
|
|
In app code import types from `'types'`, which re-exports the database types. Only server and
|
|
database code imports from `officerdb/types` directly.
|
|
|
|
### Functions
|
|
|
|
Arrow functions for one-liners, regular functions for anything with real logic. No multiline
|
|
parameter lists — extract a params type instead.
|
|
|
|
```ts
|
|
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
|
|
export function processData({ body, query }: ProcessDataParams): Result { ... }
|
|
```
|
|
|
|
### Components and hooks
|
|
|
|
```tsx
|
|
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
|
|
|
|
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
|
|
```
|
|
|
|
Complex hooks return objects; simple state hooks return `as const` tuples. Event parameters are
|
|
always named `ev`, never `e`.
|
|
|
|
### API calls
|
|
|
|
`useClient()` returns typed verbs — `client.get<Foo>('/foo')` really does return `Foo`, so pass the
|
|
type parameter and let inference flow from it.
|
|
|
|
## Working With Me
|
|
|
|
- **Ask first** — confirm the approach before a significant change
|
|
- **Explore thoroughly** — read the related files before editing
|
|
- **Keep it simple** — no over-engineering, no premature abstraction
|
|
- **Be explicit** — no magic, no implicit behaviour
|
|
- **Stay focused** — note unrelated problems, don't fix them uninvited
|
|
- **Report honestly** — say what you verified and what you didn't
|
|
|
|
Commit messages: simple lowercase, no prefixes.
|
|
|
|
## Frontend route conventions (apply to EVERY new dashboard route)
|
|
|
|
Worked examples: `/soulseek`, `/music`, `/chat`.
|
|
|
|
- **Workspace/Panel framework, always — never a standalone single screen.** The screen renders
|
|
`<WorkspaceView workspace={ws} locked />` where
|
|
`ws = useDashboardState<LayoutNode>('screens/<name>', defaultLayout)`, guarded by a `normalizeLayout`
|
|
that pins `appType`s to an allow-list. Panels are windowed apps under
|
|
`src/workspaces/officerdev/src/apps/<Feature>/`, each exporting `appRegistryMetas`
|
|
(`{ key, name, icon, component, availableOnPanel: false }`) and registered in `AppRegistry.tsx`.
|
|
The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels
|
|
live inside the Route element tree, so they can call `useParams`/`useSearchParams` directly.
|
|
- **Page title by route.** Add a rule to `RULES` in `src/apps/officer-web/state/usePageTitle.ts`
|
|
(`{ match: (p) => p.startsWith('/<name>'), title: '<Name>' }`, most-specific first);
|
|
`usePageTitleSync` does the rest. `startsWith` means nested routes are covered.
|
|
|
|
### The URL is the source of truth for selection
|
|
|
|
`docs/navigation-audit.md` is the authority here — read it before building a screen that selects
|
|
things. It names the **"opaque click" anti-pattern**: an element that opens something addressable but
|
|
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
|
|
in `usePanelChannel`/`useGlobal`, and has no anchor semantics (no cmd-click, no middle-click, not
|
|
link-focusable). Half the app still does this; none of the new code should.
|
|
|
|
- **Addressable state goes in the URL** (`useParams` / `useSearchParams`), never in a channel.
|
|
`usePanelChannel` is for genuine signals and refresh buses (`preview:refresh`,
|
|
`files:refresh-signal`, `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`, `FILE_VIEWER_CHANNEL`).
|
|
"Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves.
|
|
- **Rows and nav items are real links.** `<Link>` for rows (exemplar: the `/chat` session list,
|
|
`f35c145`); **react-router's `<NavLink>`** for nav chrome, so active state comes from the router.
|
|
The hand-rolled `isActive` in `Dock`/`Header` is scheduled for replacement (audit Phase 4) — don't
|
|
copy it. A disabled entry renders as a `<span>`; a disabled `<a>` is not a thing. A control that
|
|
*mutates* rather than navigates stays a `<button>`.
|
|
- **Route pairs.** A bare screen route plus a param route rendering the same component: `/chat` +
|
|
`/chat/:sessionId`, `/jobs` + `/jobs/:id`, `/email` + `/email/:emailId`, `/headscale` +
|
|
`/headscale/:section`. One `<Navigate … replace />` guard in the screen, placed after all hooks,
|
|
canonicalises both the bare route and a bogus param.
|
|
- **Master list + live preview uses `?selected=<id>`**, not the detail route — linking rows straight
|
|
to `/x/:id` destroys preview-on-list, because that route is the full page. Rows link to
|
|
`/x?selected=id` on-page and `/x/:id` off-page; list, mobile panel and preview all read the param.
|
|
Action buttons are **siblings** of the anchor, never nested inside it.
|
|
- **`src/workspaces/components/NavLink.tsx` is not react-router's `NavLink`** — it's a `<Link>`
|
|
wrapper that appends `useGlobalQueryString()` and provides no active state. Import react-router's
|
|
when you want `isActive`.
|
|
- Route helpers shared between an `officerdev` panel app and an `officer-web` screen must be
|
|
re-exported from `src/workspaces/officerdev/src/index.ts` (named exports only — the barrel
|
|
deliberately avoids `export *` for app modules to keep `appRegistryMetas` from colliding).
|
|
|
|
## Further Reading
|
|
|
|
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
|
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
|
- `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped
|
|
- `docs/working-on-officer.md` — how to run, restart and check your work on this machine
|
|
- `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet
|
|
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
|
- `src/apps/CLAUDE.md` — shared frontend patterns
|
|
- `src/databases/CLAUDE.md` — database patterns
|
|
- `CONVENTIONS.md` — component organisation, state management and React patterns, with rationale;
|
|
`src/workspaces/officerdev/APP_CONVENTIONS.md` and `HOOK_CONVENTIONS.md` for panel apps and hooks
|
|
|
|
Treat the code as the source of truth where anything here disagrees with it.
|