13 KiB
How to build a CRUD endpoint in this repo
Reference for adding a new CRUD entity to the server. Based on two real examples already in the codebase — read them side by side with this doc:
- Public / open-source (Community Edition) pattern:
server/routers/aiProvider/ - Enterprise-only pattern:
server/private/routers/alertRule/
The two are structurally identical. The only difference is where the files live and which router they get wired into.
1. Decide: public or private?
server/routers/<entity>/— ships in the open-source Community Edition. Anyone running Pangolin gets this.server/private/routers/<entity>/— Enterprise/SaaS only. Gated behindverifyValidLicense(and oftenverifyValidSubscription(tierMatrix.x)). Every file here starts with the Fossorial Commercial License header block (copy it verbatim from an existing private file).
Everything below applies to both — swap @server/... for #private/...
import paths and add license headers when building the private version.
2. Directory layout
One folder per entity, one file per operation, a barrel index.ts:
server/routers/<entity>/
index.ts # export * from each operation file + ./types
types.ts # response payload types + row->public mapper
validation.ts # zod schemas/refinements shared by create + update (optional)
create<Entity>.ts
list<Entities>.ts
get<Entity>.ts
update<Entity>.ts
delete<Entity>.ts
index.ts is a flat barrel:
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./types";
3. Anatomy of a single handler
Every handler file (create<Entity>.ts, etc.) follows the same shape:
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { <table>, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
import type { GetXResponse } from "@server/routers/<entity>/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({ /* ... */ }); // create/update only
registry.registerPath({
method: "get", // put | post | delete
path: "/org/{orgId}/x",
description: "...",
tags: [OpenAPITags.<Entity>],
request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ },
responses: { 200: { description: "Successful response" } }
});
export async function getX(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
}
// parse body too, if present, same pattern
// ...business logic against db...
if (!row) {
return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`));
}
return response<GetXResponse>(res, {
data: { /* ... */ },
success: true,
error: false,
message: "X retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred"));
}
}
Rules to keep consistent with the rest of the codebase:
z.strictObjectfor params/body — rejects unknown keys.- Params parsed first, then body; each on its own
safeParse+ earlynext(createHttpError(...))— never throw raw errors. - Every handler registers itself with the OpenAPI
registryeven if nobody reads the spec directly — it's how/api/v1/docsstays accurate. - Catch-all
try/catchat the bottom:logger.error(error)+ generic500message. Never leak internal error details to the client. - Use
response<T>(res, { data, success, error, message, status })from@server/lib/responsefor every response, success or otherwise (errors go throughnext(createHttpError(...))instead, not throughresponse). - If the route already ran an access-control middleware that fetched the row
(see §5), reuse it instead of re-querying:
req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...
List handler specifics
Pagination is a fixed shape (page, pageSize, optional query for
search). See listAiProviders.ts:
const listSchema = z.object({
pageSize: z.coerce.number<string>().int().positive().optional().catch(20).default(20),
page: z.coerce.number<string>().int().min(0).optional().catch(1).default(1),
query: z.string().optional()
});
Run the count query and the page query in Promise.all, and return
PaginatedResponse<{ items: T[] }> (@server/types/Pagination) with
{ total, pageSize, page }.
types.ts specifics
- Define one response type per operation:
List<Entities>Response,Get<Entity>Response,CreateOrEdit<Entity>Response(create and update commonly share a response shape). - If the raw DB row needs to be shaped for clients (decrypting secrets,
parsing a serialized column, hiding a column), put a
toPublic<Entity>()mapper here — seetoPublicAiProviderfor the pattern of strippingapiKey/serialized columns and re-adding decrypted/parsed versions.
validation.ts specifics
Only needed when create and update share non-trivial zod pieces (enums,
superRefine cross-field rules). Export the raw schemas (z.enum([...]))
and refinement functions, and import them into both createX.ts and
updateX.ts — see aiProvider/validation.ts's
refineProviderUpstreamFields.
4. Wire up an access-control middleware (for id-scoped routes)
For routes scoped to a single row (/x/:xId, as opposed to
/org/:orgId/x create/list), add a verify<Entity>Access middleware in
server/middlewares/ (or server/private/middlewares/ for enterprise-only
entities) and export it from that directory's index.ts.
Pattern (verifyAiProviderAccess.ts):
- Read the id param,
Number.parseInt/validate it. - Load the row by id.
404if it doesn't exist.- Resolve the row's
orgId, then check/attachreq.userOrg(queryuserOrgsif not already on the request),403if the user isn't in that org. - Run
checkOrgAccessPolicyifreq.orgPolicyAllowedhasn't been resolved yet. - Set
req.userOrgId,req.userOrgRoleIds, and stash the row on the request (e.g.req.aiProvider = provider) so downstream handlers andverifyUserHasActiondon't have to refetch it.
Org-scoped create/list routes (/org/:orgId/x) don't need a bespoke
middleware — they use the existing generic verifyOrgAccess from
@server/middlewares.
5. Register an action + permission check
Add one ActionsEnum entry per operation in server/auth/actions.ts,
grouped near the entity's other actions, named create<Entity>,
get<Entity>, update<Entity>, delete<Entity>, list<Entities>:
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
Every route uses verifyUserHasAction(ActionsEnum.x) to check the caller's
role/permissions for that action, and mutating routes (create/update/delete)
follow it with logActionAudit(ActionsEnum.x) to record the action in the
audit log.
6. Register the routes
There are four router files; which one(s) you touch depends on public vs. private and user-facing vs. service-to-service:
| File | Purpose |
|---|---|
server/routers/external.ts |
Public, user-facing API. Exports authenticated, unauthenticated, authRouter Express routers. |
server/routers/internal.ts |
Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports internalRouter. |
server/private/routers/external.ts |
Enterprise-only, user-facing. Imports authenticated/unauthenticated/authRouter from the public external.ts and re-exports them, then adds more routes on top. |
server/private/routers/internal.ts |
Enterprise-only, service-to-service. Same re-export trick with internalRouter. |
Private router files always start:
import {
unauthenticated as ua,
authenticated as a,
authRouter as aa
} from "@server/routers/external";
export const authenticated = a;
export const unauthenticated = ua;
export const authRouter = aa;
...and then call authenticated.get/put/post/delete(...) to bolt on
additional, enterprise-only routes on the same router instances the public
build uses. This is why the private build has strictly more routes than the
public build, not a divergent copy.
Route registration order (mutating vs read)
Standard middleware chain per verb, using alertRule's registrations as the
template:
// Create — org-scoped, no row exists yet
authenticated.put(
"/org/:orgId/x",
verifyValidLicense, // private/enterprise routes only
verifyOrgAccess,
verifyLimits, // if the entity counts against a plan limit
verifyUserHasAction(ActionsEnum.createX),
logActionAudit(ActionsEnum.createX),
x.createX
);
// Update — row-scoped
authenticated.post(
"/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique
verifyValidLicense,
verifyOrgAccess, // or verifyXAccess if globally-keyed
verifyUserHasAction(ActionsEnum.updateX),
logActionAudit(ActionsEnum.updateX),
x.updateX
);
// Delete — row-scoped
authenticated.delete(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteX),
logActionAudit(ActionsEnum.deleteX),
x.deleteX
);
// List — org-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/xs",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listXs),
x.listXs
);
// Get one — row-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getX),
x.getX
);
Notes:
- HTTP verbs:
PUT= create,POST= update,GET= read,DELETE= delete. This repo does not usePATCHfor entity updates (site provisioning keys are the one exception, usingPATCH). verifyValidLicenseis only needed on private/enterprise routes; public OSS routes skip it.- Use
verifyValidSubscription(tierMatrix.someFeature)right afterverifyValidLicensewhen a feature is gated to specific SaaS tiers (seetierMatrixusages inserver/private/routers/external.ts). verifyLimitsgoes on create routes for entities that count against a plan/seat limit.- For entities keyed by a globally-unique id (not nested under
/org/:orgId), use the dedicatedverify<Entity>Accessmiddleware from §4 instead ofverifyOrgAccesson the row-scoped routes (see how/ai-provider/:providerIdusesverifyAiProviderAccess, while/org/:orgId/ai-providercreate/list use plainverifyOrgAccess). - Read-only routes (
get,list) skiplogActionAudit— only mutations are audited. internal*.tsroutes are for trusted internal callers (gerbil/badger sidecars) and generally skip user-facing auth entirely, usingverifySessionUserMiddleware/verifyUserFromResourceSessionMiddlewareinstead ofverifyOrgAccess/verifyUserHasAction. CRUD entities almost never need internal router entries — only add one if a sidecar process needs direct access to the resource.
7. The #dynamic alias (advanced — most CRUD work can ignore this)
Some middleware (e.g. logActionAudit) needs a real implementation in the
enterprise/SaaS build but a no-op stub in the open-source build, while
being imported by identical code in server/routers/external.ts in both
builds. That's done via the #dynamic/* import alias, which
tsconfig.oss.json points at ./server/* and tsconfig.enterprise.json /
tsconfig.saas.json point at ./server/private/*. You only need this
pattern if you're adding a genuinely dual-implementation hook; a normal
private-only CRUD entity (like alertRule) never touches #dynamic — it
just lives entirely under server/private/ and is imported with #private/*
directly from server/private/routers/external.ts.
8. Checklist for a new entity
- Add the DB table to
server/db/pg/schema/schema.ts(and sqlite schema if applicable). - Add
ActionsEnumentries inserver/auth/actions.ts. - Create
server/routers/<entity>/(orserver/private/routers/<entity>/):types.ts, optionalvalidation.ts, one file per operation,index.tsbarrel. - If routes are row-scoped by a global id, add
verify<Entity>Access.tstoserver/middlewares/orserver/private/middlewares/, and export it from that directory'sindex.ts. - Wire routes into
external.ts(public or private) following the verb/ middleware table in §6. Add tointernal.tsonly if a sidecar needs direct access. - Add license header block to every new file if it's under
server/private/.