docs/crud-endpoints.md
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:
server/routers/aiProvider/server/private/routers/alertRule/The two are structurally identical. The only difference is where the files live and which router they get wired into.
server/routers/<entity>/ — ships in the open-source Community Edition.
Anyone running Pangolin gets this.server/private/routers/<entity>/ — Enterprise/SaaS only. Gated behind
verifyValidLicense (and often verifyValidSubscription(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.
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";
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.strictObject for params/body — rejects unknown keys.safeParse + early
next(createHttpError(...)) — never throw raw errors.registry even if nobody
reads the spec directly — it's how /api/v1/docs stays accurate.try/catch at the bottom: logger.error(error) +
generic 500 message. Never leak internal error details to the client.response<T>(res, { data, success, error, message, status }) from
@server/lib/response for every response, success or otherwise (errors go
through next(createHttpError(...)) instead, not through response).req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...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 }.
List<Entities>Response,
Get<Entity>Response, CreateOrEdit<Entity>Response (create and update
commonly share a response shape).toPublic<Entity>()
mapper here — see toPublicAiProvider for the pattern of stripping
apiKey/serialized columns and re-adding decrypted/parsed versions.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.
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):
Number.parseInt/validate it.404 if it doesn't exist.orgId, then check/attach req.userOrg (query
userOrgs if not already on the request), 403 if the user isn't in
that org.checkOrgAccessPolicy if req.orgPolicyAllowed hasn't been resolved
yet.req.userOrgId, req.userOrgRoleIds, and stash the row on the
request (e.g. req.aiProvider = provider) so downstream handlers and
verifyUserHasAction don'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.
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.
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.
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:
PUT = create, POST = update, GET = read, DELETE =
delete. This repo does not use PATCH for entity updates (site
provisioning keys are the one exception, using PATCH).verifyValidLicense is only needed on private/enterprise routes; public
OSS routes skip it.verifyValidSubscription(tierMatrix.someFeature) right after
verifyValidLicense when a feature is gated to specific SaaS tiers (see
tierMatrix usages in server/private/routers/external.ts).verifyLimits goes on create routes for entities that count against a
plan/seat limit./org/:orgId),
use the dedicated verify<Entity>Access middleware from §4 instead of
verifyOrgAccess on the row-scoped routes (see how /ai-provider/:providerId
uses verifyAiProviderAccess, while /org/:orgId/ai-provider create/list
use plain verifyOrgAccess).get, list) skip logActionAudit — only mutations are
audited.internal*.ts routes are for trusted internal callers (gerbil/badger
sidecars) and generally skip user-facing auth entirely, using
verifySessionUserMiddleware / verifyUserFromResourceSessionMiddleware
instead of verifyOrgAccess/verifyUserHasAction. CRUD entities almost
never need internal router entries — only add one if a sidecar process
needs direct access to the resource.#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.
server/db/pg/schema/schema.ts (and sqlite schema if
applicable).ActionsEnum entries in server/auth/actions.ts.server/routers/<entity>/ (or server/private/routers/<entity>/):
types.ts, optional validation.ts, one file per operation, index.ts
barrel.verify<Entity>Access.ts to server/middlewares/ or
server/private/middlewares/, and export it from that directory's
index.ts.external.ts (public or private) following the verb/
middleware table in §6. Add to internal.ts only if a sidecar needs
direct access.server/private/.