Repository navigation
design service layer
Issue #53. Status: SHIPPED 2026-10-14 (17 slices — the plan below's 11 grew to 17 when pats/workspace/status-style small surfaces got their own honest treatment). Every module in
modules/*.tsis now a thin authenticate → parse → service → reply adapter, except documented realtime residue (io emits, dispatch, ctl round-trips — each marked#53 residue:with its reason, several with ordering facts pinned by tests) andstatus.ts/health.ts/serialize.ts(kept whole, reasons in-file). Quartet green on the Node 22.19 floor: 826 tests across the workspace, every pre-existing route test byte-identical — the regression net held. AGENTS.md architecture rule 7 now states the contract. Blocks #52 (the platform assistant calls these services directly). Historical context: the assistant's first design routed tools throughapp.inject()self-REST with an internal credential — superseded by this layer.
All business logic lives inside route handlers today (modules/*.ts): the
#36 personal-scope decisions, visibility filters, credential gates, error
mapping. That made the REST surface the only door — fine for a browser, but
any in-process consumer (the assistant's tools) would have to self-call HTTP
or duplicate the authorization. The settled shape is the standard layered
one: services are the single authorization enforcement point, HTTP routes
thin down to transport adapters, and internal callers call services with an
explicit principal. No self-REST, no internal credential, nothing forgeable.
-
Location & shape:
packages/server/src/services/<domain>.ts. Pure TS, Fastify-free. Dependencies arrive per call (svcResources.list({ uow }, principal, query)) — no classes, no singletons, no module-level state (therealtime/chat.tsclass stays as-is; it is a stateful realtime component, not a domain service). -
Principal:
Principal = { userId: string; role: 'admin' | 'user' }, an explicit parameter on every operation that carries authority. Anonymous flows (login/register) take no principal. Type lives inservices/principal.ts. -
Authz truth moves into services: ownership/scope decisions (#36
owner-only + 404 existence-hiding,
canManage, visibility filters) are enforced by the service, thrown asAppError. Routes keeprequireAuth/requireAdminpreHandlers (401/403 transport mapping) but re-decide NOTHING — a route passing a principal never re-checks scope. -
Routes thin to: authenticate (the root hook) → parse (
sharedzod — still the single wire source) → call service → shape reply. Handlers keep no business branches beyond reply shaping. -
Errors: services throw
AppError; the Fastify error map is untouched. -
Forbidden imports: services never import
fastifytypes, the socket layer, orprocess.env(config stays inconfig.ts, passed by callers). Realtime side effects (job dispatch, ctl emits) stay at their existing orchestrators (jobs/realtime modules) — services return facts, callers act. - Testing: services are unit-testable against the memory driver with no HTTP. The existing route tests (app.inject-based) keep passing UNCHANGED through every slice — they are the regression net; new service tests assert the scope rules directly.
The refactor closes only when every module is extracted — full consistency is the point; there is deliberately no permanent mixed state. Assistant- relevant modules go first so #52 can build on the earliest slices:
| order | module (modules/*.ts) |
notes |
|---|---|---|
| 1 | resources | the assistant's killer tool surface |
| 2 | skills (hub search) | multi-source search, outbound surface #1 |
| 3 | machines + inventory/agent-instances reads | read-heavy |
| 4 | settings/system (+ system info) | trivial, sets the pattern |
| 5 | profiles | reference bundles |
| 6 | llm-providers | #51 registry queries |
| 7 | credentials | scope shadowing, encryption stays in infra |
| 8 | mcp-servers | registry interplay (mcp/registry.ts stays) |
| 9 | users (+ auth flows) | last-admin rails move with it |
| 10 | jobs / runtime-config / client-config | realtime-coupled; services return facts, dispatch stays at routes |
| 11 | marketplace residue | 3.5 emitter surfaces |
Each slice: extract → routes thin → service unit tests added → route tests green → quartet-tier verify before merge (per-change tier is enough per slice; the push gate covers the whole).
- Behavior drift while moving scope checks — mitigated by the unchanged route tests (they assert 404-hiding and scope outcomes end-to-end).
- Two-door drift AFTER the refactor (a new route bypassing its service) — convention + review; the wiki index row states the rule in one line.
- Realtime-coupled routes (config view round-trips, ctl emits) keep their orchestration at the route; only the pure decision logic moves. Slice 10 is sized accordingly.