Skip to content

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.

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.

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.

  • Every endpoint validates body, params and query with Zod before doing anything. A ZodError becomes XEC-API-4001 centrally. Errors are XentiumErrors with an XEC-<DOMAIN>-<CODE> code, and a stack trace never reaches a client.
  • Core tables are named xcf_<snake_case>. We soft-delete with deletedAt, 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.

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.

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.