Working on core
We expect new core code to follow these conventions. If a PR hand-rolls something a shared utility already does, we’ll ask you to switch.
Module shape
Section titled “Module shape”modules/<name>/<name>.routes → .controller → .service (plus .validation, .types,
.test). Services can use Prisma directly. Pull a query out into a .repository only
when several services share it or it’s genuinely complex. It’s not a required layer.
Controllers are plain async functions
Section titled “Controllers are plain async functions”Express 5 passes rejected promises to the error middleware, so there’s no wrap()
helper, and you don’t cast req. req.userId, req.sessionId and
req.file.detectedExt are typed in apps/api/src/types/express.d.ts. If you catch
yourself writing (req as any) or a local AuthedRequest, the typing is already there.
// A complete controller. Anything it throws (XentiumError included) reaches errorMiddleware.export async function getThing(req: Request, res: Response) { const { id } = paramsSchema.parse(req.params); // Zod on EVERY input res.json(await thingService.get(id, req.userId));}Shared utilities (use these, don’t rewrite them)
Section titled “Shared utilities (use these, don’t rewrite them)”Utility (apps/api/src/shared/utils/) |
Use it for | Rules |
|---|---|---|
settings.ts: getSetting / getSettings / getFreshSetting / bustSettingsCache |
every settings read | Reads are cached for 15 s; read several keys at once with getSettings({key: default}). Anything that writes a setting must call bustSettingsCache(key). Use getFreshSetting only where a stale value isn’t acceptable (e.g. appearance tokens). |
paginate.ts: paginate() (meta via buildPaginationMeta from @xentium/contracts) |
every list endpoint | Only buildPaginationMeta builds {page, limit, total, totalPages, hasPrev, hasNext}. Don’t build that object by hand. |
userRef.ts: USER_REF_SELECT / toUserRef |
naming a user in a response | The one way a response refers to a user (id, username, badge colour). The UserRef type is in @xentium/contracts. |
memoTtl.ts: memoTtl / TtlCache |
caching anything hot in memory | It backs the settings cache and the /appearance/css memo. Use it before you invent a cache. |
On the web side the equivalents are lib/toast.ts (toast.success/info/warning/error,
our own toasts: they stack, merge duplicates as ×N, pause on hover and take their
colours from the Style Editor’s Feedback tokens) and lib/dialog.ts (openDialog, a
promise-based confirm/alert). lib/swal.ts is an old name that wraps both (sweetalert2
is gone); new code imports toast and openDialog directly.
Errors, validation, data
Section titled “Errors, validation, data”- Every endpoint validates body, params and query with Zod before doing anything. A
ZodErrorbecomesXEC-API-4001centrally. Errors areXentiumErrors with anXEC-<DOMAIN>-<CODE>code, and a stack trace never reaches a client. - Core tables are named
xcf_<snake_case>. We soft-delete withdeletedAt, and queries leave deleted rows out by default. TipTap content is stored as ProseMirror JSON, never raw HTML. - The API doesn’t do languages (it returns machine keys and XEC codes). Only emails and notifications are translated on the server.
Security detection
Section titled “Security detection”Abuse detection is part of core. Four baseline patterns run on the security-analysis
job every 30 minutes and open review cases for a human (ACP → Moderation → Review Cases,
and in ModCanvas → Cases). Detection never acts on its own. Plugins can add scans
with registerSecurityScan or take over a baseline with claimSecurityPatterns.
Two processes, one codebase
Section titled “Two processes, one codebase”The HTTP server (server.ts) and the worker (worker.ts) are separate processes, and
both load plugins and read the same registries. If you change job code or anything a
plugin registers into, restart the worker too. In dev, ./xentium.sh runs both.