Skip to content

design service layer

sinrimin edited this page Oct 14, 2026 · 3 revisions

Design: service layer — principal-carrying services extracted from the route handlers

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/*.ts is 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) and status.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 through app.inject() self-REST with an internal credential — superseded by this layer.

1. Why

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.

2. Conventions (the contract every slice follows)

  • 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 (the realtime/chat.ts class 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 in services/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 as AppError. Routes keep requireAuth/requireAdmin preHandlers (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 (shared zod — 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 fastify types, the socket layer, or process.env (config stays in config.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.

3. Slicing (one module per slice, assistant-relevant first)

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).

4. Risks

  • 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.

Clone this wiki locally