Skip to main content

API reference

The API is a Fastify v4 JSON service. Every application route lives under /api; the operational endpoints (/healthz, /readyz, /metrics) sit at the root because that is where Kubernetes and Prometheus expect them.

Request bodies, query strings and path parameters are validated with the Zod schemas exported from packages/types, not with Fastify/AJV JSON Schema. That is a deliberate deviation from the technical plan (decision D9 in IMPLEMENTATION.md): the frontend compiles against the same schemas, so there is one definition of the wire contract instead of a JSON Schema on the server and a Zod schema in the client that can drift apart. The guarantee is unchanged — nothing reaches business logic unvalidated — and the failure envelope is identical either way. The mechanism lives in apps/api/src/http/validate.ts.

Conventions

Base URL. https://api.<domain> in the cluster, http://localhost:3000 in local development. The SPA is served from a different host (orders.<domain>) and calls the API cross-origin using VITE_API_BASE_URL — see decisions D5 and D10.

Authentication. Authorization: Bearer <access token> on every route except the seven entries on the public allowlist below. See authentication.

Timestamps. Every TIMESTAMPTZ crosses the boundary as an ISO-8601 string, never a Date.

Identifiers. Every id is a UUID string, validated with uuidSchema (z.string().uuid()).

Body limit. 1 MiB (bodyLimit: 1_048_576). A larger body is rejected by Fastify before any handler runs and is reported as 400 VALIDATION_FAILED.

Request correlation. An inbound X-Request-Id is adopted as the Fastify request id; when absent one is generated. It is echoed on the response and appears as requestId on every log line. X-Request-Id is the only response header exposed to the browser by the CORS configuration; Content-Type, Authorization, X-Idempotency-Key and X-Request-Id are the accepted request headers.

Pagination. List endpoints accept page (integer, minimum 1, default 1) and pageSize (integer, 1100, default 25), both coerced from their string query form, and return this envelope:

{
"data": [],
"page": 1,
"pageSize": 25,
"total": 0,
"totalPages": 0
}

Boolean query parameters. Parsed by booleanQueryParamSchema, which understands true/1/yes and false/0/no/empty and rejects anything else with 400. Plain JavaScript truthiness is deliberately not used, so ?includeInactive=false means false.

Error envelope

Every non-2xx response — including validation failures, rate limiting and the not-found handler — uses one shape, produced by AppError.toPayload() in apps/api/src/errors.ts:

{
"error": {
"code": "UNPROCESSABLE",
"message": "One or more catalog items have no active supplier",
"details": { "items": [{ "catalogItemId": "…", "name": "Roma tomatoes" }] }
}
}

details is omitted when there is nothing structured to say. code is one of the eight values in API_ERROR_CODES (packages/types/src/api-errors.ts) and each maps to exactly one status:

StatuscodeRaised by
400VALIDATION_FAILEDBody, query or path parameter failed its Zod schema; also Fastify's own 4xx errors
401UNAUTHENTICATEDNo bearer token, or a token that does not verify
403FORBIDDENValid token, but the role guard or the restaurant scope forbids the request
404NOT_FOUNDNo such record, no such route, or a record outside the caller's scope
409CONFLICTUniqueness or state conflict (inactive resource, idempotency-key misuse)
422UNPROCESSABLEWell-formed but not actionable — the classic case is a line with no supplier mapping
429RATE_LIMITEDRate limit exceeded
500INTERNALUnhandled failure; reported to Sentry with requestId, userId and route

A validation failure carries a field-level details array so the UI can highlight inputs. Each entry is { path, message, code } where path is the Zod issue path joined with dots:

{
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid request body",
"details": [
{
"path": "lineItems.0.quantity",
"message": "Number must be greater than 0",
"code": "too_small"
}
]
}
}

The message names which part of the request failed: Invalid request body, Invalid query parameters or Invalid path parameters.

Prisma errors that escape the domain layer are also mapped: a unique-constraint violation becomes 409, a record-not-found becomes 404, and a foreign-key violation becomes 422.

Role guards

Guards are preHandler hooks declared per route in the route file, never globally, so an unguarded route is visible at the point of definition. apps/api/src/plugins/auth.ts exposes three:

GuardAcceptsRejects
app.requireAuthany verified token401 when the token is missing or invalid
app.requireChefchef and admin403 for any other role
app.requireAdminadmin only403 for chef

A token that verifies cryptographically but carries neither the chef nor the admin project role is a 401, not a 403. In practice that means one of Zitadel's two role-assertion toggles is off, which is a configuration fault rather than an access decision — the token is simply unusable — and the API logs a loud warning naming both toggles. See authentication.

An administrator satisfies the chef guard. requireChef accepts both roles because an admin can do anything a chef can. Only the reverse is restricted. This is why POST /api/orders is not admin-only: an admin may place an order, and their restaurantId is unrestricted because canAccessRestaurant() short-circuits to true for the admin role.

apps/api/tests/rbac.test.ts enumerates the whole route table from Fastify's own onRoute hook and asserts, for every route not on the public allowlist, that it carries one of these three guards by reference equality, that an unauthenticated request gets 401, and that an admin-only route answers a chef token with 403. A route added without a guard fails the build.

Public routes

PUBLIC_ROUTES in apps/api/src/routes/index.ts is the explicit, reviewable allowlist of routes reachable without a bearer token. It is exactly seven entries:

GET /healthz
GET /readyz
GET /metrics
GET /api/auth/login
GET /api/auth/callback
POST /api/auth/refresh
POST /api/auth/logout

Everything else — including GET /api/auth/me — requires a token. POST /api/auth/refresh and POST /api/auth/logout are public in the bearer-token sense only: they authenticate with the signed httpOnly refresh cookie instead.

403 versus 404

The distinction is deliberate and asserted in tests (decision D15):

  • Asking for another restaurant's order by idGET /api/orders/:id, GET /api/orders/:id/dispatches/:dispatchId/pdf — returns 404, not 403. A 403 would confirm that the id exists and let a chef probe the shape of other locations' business. From the chef's point of view the order simply is not there.
  • Passing a restaurantId filter you are not assigned toPOST /api/orders, GET /api/orders — returns 403. Here the caller has named a restaurant explicitly rather than guessed an opaque id, so answering "no orders" would be a lie that hides a permission problem.

Supplier identity is withheld from chefs

A hard product requirement (decision D14): chefs order from one unified catalog and are never told who fulfils a line. Three separate mechanisms enforce it, and all three are asserted against serialised JSON in apps/api/tests:

  1. Catalog. The chef projection chefCatalogItemSchema has no supplier field at all, and domain/catalog.ts selects only those columns rather than selecting the row and trusting a later mapper to drop the rest.
  2. Order detail. orderLineItemDetailSchema.supplierName and supplierDispatchSchema.supplierName / .supplierType are omitted entirely for a chef. supplierId remains present because it is a required field of the shared dispatch contract, but it is an opaque UUID that names nothing a chef can act on.
  3. Audit event payloads are redacted for chefs too. This is the part that is easy to get wrong. audit_events.payload is free-form JSONB and several event types legitimately record which supplier was involved, which would be a back door around the rule. redactSupplierIdentity() in domain/orders.ts therefore strips supplierId, supplierType, supplierName, originalSupplierId, alternateSupplierId and altSupplierId from every audit payload before it reaches a non-admin caller.

The admin-side reads live in a separate module (domain/admin-orders.ts) that deliberately does not import the chef-side one, so a change to the chef payload cannot accidentally leak a supplier name into it.

Idempotency

POST /api/orders is the only endpoint that honours idempotency, and only when the caller opts in.

HeaderDirectionMeaning
X-Idempotency-KeyrequestOptional, up to 255 characters. A repeat replays the first response
X-Idempotent-ReplayresponseSet to true only when the body came from the store and nothing ran

Records live in PostgreSQL (idempotency_keys), not in Redis with a TTL as the plan sketched — decision D11: a replay record must survive a Redis failover, because losing it re-enables exactly the duplicate order the mechanism exists to prevent, and a stored response is auditable evidence of what the API told the client. purgeExpiredIdempotencyKeys() reproduces the plan's 24-hour horizon.

Two details a reader would otherwise get wrong:

  • Only 2xx outcomes are recorded. A rejected request stays retryable under the same key, because the client will usually fix its payload and resubmit. The body is parsed inside the idempotent section precisely so a validation failure is never cached.
  • Key reuse is a 409, not a silent replay. The stored record pins both the user and a SHA-256 hash of the canonically-encoded request body. A key belonging to another user, or the same key with a different payload, is 409 CONFLICT rather than someone else's response. Object keys are sorted at every depth before hashing, so property order does not matter; array order is significant, because reordering a cart's lines is a different request.

Rate limits

Global, in the onRequest phase, with the RATE_LIMITED code:

ScopeLimitEnvironment variable
Routes under /api/admin200 per windowRATE_LIMIT_ADMIN_MAX
Everything else100 per windowRATE_LIMIT_MAX
Window1 minuteRATE_LIMIT_WINDOW

/healthz and /metrics are on the allowlist and are never limited. A 429 carries the standard retry-after, x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset headers plus the usual envelope, with details: { max, window }.

Two caveats worth knowing. First, the admin limit is selected by URL prefix, so the admin-guarded routes that are not under /api/adminPOST /api/orders/:id/backorder, POST /api/orders/:id/line-items/:lineItemId/reroute and POST /api/inbound/sysco — get the standard 100 per minute. Second, the key generator prefers request.ctx.userId and falls back to request.ip, but rate limiting runs in onRequest while the auth guards are preHandler hooks, so ctx is not yet populated when the key is computed: in practice the bucket is per source address, not per user. Treat the per-user wording as the intent rather than the current behaviour.


Route table

Registered in this order by apps/api/src/routes/index.ts; the sections below follow the same order.

MethodPathGuardSuccess
GET/healthzpublic200
GET/readyzpublic200/503
GET/metricspublic200
GET/api/auth/loginpublic200/302
GET/api/auth/callbackpublic302
POST/api/auth/refreshpublic200
POST/api/auth/logoutpublic200
GET/api/auth/meauth200
GET/api/catalogchef200
GET/api/catalog/categorieschef200
POST/api/orderschef201
GET/api/orderschef200
GET/api/orders/:idchef200
GET/api/orders/:id/dispatches/:dispatchId/pdfchef200
POST/api/orders/:id/backorderadmin202
POST/api/orders/:id/line-items/:lineItemId/rerouteadmin202
GET/api/admin/catalogadmin200
GET/api/admin/catalog/:idadmin200
POST/api/admin/catalogadmin201
PUT/api/admin/catalog/:idadmin200
DELETE/api/admin/catalog/:idadmin200
GET/api/admin/suppliersadmin200
GET/api/admin/suppliers/:idadmin200
POST/api/admin/suppliersadmin201
PUT/api/admin/suppliers/:idadmin200
DELETE/api/admin/suppliers/:idadmin200
GET/api/admin/mappingsadmin200
GET/api/admin/mappings/:idadmin200
POST/api/admin/mappingsadmin201
PUT/api/admin/mappings/:idadmin200
DELETE/api/admin/mappings/:idadmin200
GET/api/admin/restaurantsadmin200
GET/api/admin/restaurants/:idadmin200
POST/api/admin/restaurantsadmin201
PUT/api/admin/restaurants/:idadmin200
POST/api/admin/restaurants/:id/chefsadmin201
DELETE/api/admin/restaurants/:id/chefs/:userIdadmin200
GET/api/admin/ordersadmin200
GET/api/admin/orders/auditadmin200
GET/api/admin/orders/:idadmin200
POST/api/inbound/syscoadmin200

Every guarded route can additionally answer 401, 403, 429 and 500, and every route that takes a validated input can answer 400. Those are not repeated per endpoint below.


Health module

apps/api/src/routes/health.ts. Registered without a prefix.

GET /healthz

Liveness. Public. Deliberately does not touch PostgreSQL or Redis: a database blip must not cause Kubernetes to restart every pod.

{ "status": "ok", "service": "lucille-api", "version": "0.1.0", "uptimeSeconds": 4213 }

GET /readyz

Readiness. Public. Runs checkDatabase() and checkRedis() in parallel. 200 when both pass, 503 when either fails — the status field changes with it, so the body is diagnostic on its own.

{ "status": "ready", "checks": { "database": true, "redis": true } }

GET /metrics

Prometheus exposition, registered in app.ts only when METRICS_ENABLED is on. Public and excluded from rate limiting and request logging. See observability.


Auth module — /api/auth

apps/api/src/routes/auth.ts.

GET /api/auth/login

Public. Starts Authorization Code + PKCE. Sets short-lived signed httpOnly cookies lucille_pkce and lucille_state (600 seconds each) and then content-negotiates: a request whose Accept header contains application/json — the SPA — receives 200 with the URL to visit; anything else gets a 302 to the identity provider.

{ "authorizationUrl": "https://auth.example/oauth/v2/authorize?…", "state": "…" }

GET /api/auth/callback

Public. The OIDC redirect target.

Query parameterTypeRequiredNotes
codestringyesAuthorization code
statestringyesMust equal the value in the lucille_state cookie
errorstringPresent when the identity provider refused

On success it exchanges the code, clears the PKCE and state cookies, stores the refresh token in the signed httpOnly cookie named by AUTH_COOKIE_NAME (7 days) plus the id token in lucille_idt, and issues a 302 to {frontendOrigin}/auth/callback#access_token=…&expires_in=…&token_type=…. The access token travels in the URL fragment, which is never sent to a server and never appears in server logs (decision D12).

StatuscodeCause
400VALIDATION_FAILEDcode missing
401UNAUTHENTICATEDerror present, state mismatch, or the PKCE verifier expired

POST /api/auth/refresh

Public in the bearer sense; authenticated by the refresh cookie, which never leaves the cookie jar. Zitadel rotates refresh tokens, so the replacement is written back to the cookie.

{ "accessToken": "eyJhbGciOi…", "expiresIn": 3600, "tokenType": "Bearer" }

401 UNAUTHENTICATED when the cookie is absent, unsigned or rejected upstream.

POST /api/auth/logout

Public. Clears the refresh, PKCE, state and id-token cookies and returns the end-session URL so the SPA can also terminate the Zitadel session. logoutUrl is null when it could not be built — a warning is logged and the local logout still succeeds.

{ "loggedOut": true, "logoutUrl": "https://auth.example/oidc/v1/end_session?…" }

GET /api/auth/me

Guard: requireAuth. The SPA's bootstrap call. For an admin the restaurants array is every active restaurant, because admin access is global and is deliberately not expressed as user_restaurants rows; for a chef it is their active assignments, sorted by name.

{
"id": "6b1f…",
"email": "chef.downtown@lucille.example",
"displayName": "Alex Marchetti",
"role": "chef",
"restaurants": [{ "id": "9c2a…", "name": "Lucille Downtown" }]
}

Catalog module — /api/catalog

apps/api/src/routes/catalog.ts. Both routes are guarded with requireChef, so admins reach them too.

GET /api/catalog

Query parameterTypeDefaultConstraints
pageinteger1minimum 1
pageSizeinteger251100
searchstring1–200 characters; case-insensitive match on name only
categorystring1–100 characters; exact match
includeInactivebooleanfalseHonoured for admin only; ignored for a chef

Only orderable items are returned: the item must have an active catalog_supplier_mappings row whose primary supplier is itself active. An item nobody can fulfil is not orderable, and offering it would produce a 422 at checkout. Results are paginated and sorted by name ascending.

{
"id": "e18c…",
"name": "Roma tomatoes",
"category": "Produce",
"unit": "case",
"unitSize": "6x10oz",
"description": "Grade A, for sauce service"
}

There is no supplier field to filter out — the projection is a column subset declared as chefCatalogItemSchema in packages/types/src/catalog.ts.

GET /api/catalog/categories

The distinct, non-null categories of orderable active items, ascending. Takes no parameters, so there is no way to ask for inactive items here.

{ "categories": ["Dairy", "Dry goods", "Produce"] }

Orders module — /api/orders

apps/api/src/routes/orders.ts, apps/api/src/domain/orders.ts.

POST /api/orders

Guard: requireChef. Success: 201.

HeaderRequiredNotes
X-Idempotency-KeynoUp to 255 characters. A repeat replays the first response verbatim

Request body (placeOrderInputSchema):

{
"restaurantId": "9c2a…",
"notes": "Deliver before 06:00 if possible",
"lineItems": [
{ "catalogItemId": "e18c…", "quantity": 8 },
{ "catalogItemId": "f42b…", "quantity": 2 }
]
}
FieldTypeRequiredConstraints
restaurantIdUUIDyesMust be one of the caller's assignments (any, for admin)
notesstring or nullnoup to 2000 characters
lineItemsarrayyes1–200 entries; the field is lineItems, not lines
lineItems[].catalogItemIdUUIDyesMust be distinct across the array
lineItems[].quantitynumberyesgreater than 0, finite, at most 100000

A duplicate catalogItemId is rejected with 400 and the message Duplicate catalog item in order rather than being summed: the chef UI merges quantities client-side, so a duplicate here is a bug or a double-submit, and silently merging would hide it.

Response 201:

{ "orderId": "af31…", "status": "dispatching" }

status is never pending on the response. The order row is created as pending inside the transaction, but by the time the handler returns, every dispatch has been offered to the queue and the status has been advanced to dispatching, or to failed if no dispatch could be enqueued. If only some fail, the order stays dispatching, the affected supplier_dispatches rows are marked failed with a failureReason, and a SUPPLIER_DISPATCH_FAILED audit event is appended — the order is kept rather than rolled back, because discarding an order the chef believes they placed is strictly worse than a visible dispatch an administrator can retry.

StatuscodeCause
400VALIDATION_FAILEDEmpty or over-long lineItems, non-positive quantity, duplicate item, malformed UUID
403FORBIDDENrestaurantId is not in the caller's assignments
404NOT_FOUNDThe restaurant does not exist, or details.catalogItemIds lists unknown items
409CONFLICTThe restaurant is inactive; a line references an inactive item (details.items); idempotency key reused by another user or with a different payload; key longer than 255 characters
422UNPROCESSABLEOne or more lines have no active supplier mapping; details.items lists them

The three cart failure modes are checked in that order — unknown id, then inactive item, then unusable mapping — and the whole order is rejected if any line fails, because an order is all-or-nothing.

GET /api/orders

Guard: requireChef. A chef sees only their assigned restaurants; an admin sees every restaurant.

Query parameterTypeNotes
page, pageSizeintegerStandard pagination
restaurantIdUUIDOptional filter; 403 if the caller is not assigned to it
statusenumpending, dispatching, completed, partial, failed
from, toISO date-timeInclusive bounds on created_at

Response 200: a paginated envelope of orderListItemSchema — the order header (id, restaurantId, placedBy, status, notes, createdAt, updatedAt) plus the denormalised restaurantName, placedByName (display name falling back to email, or null) and lineItemCount. Newest first.

GET /api/orders/:id

Guard: requireChef. Response 200 is orderDetailSchema: the header, restaurant, placedByUser, lineItems (each with its chef catalog projection inlined), dispatches and auditEvents. Supplier identity is omitted and audit payloads are redacted unless the caller is an admin.

404 NOT_FOUND when the order does not exist or belongs to a restaurant the caller is not assigned to.

GET /api/orders/:id/dispatches/:dispatchId/pdf

Guard: requireChef. Returns a short-lived presigned GET URL for the Central Kitchen purchase-order PDF in DigitalOcean Spaces — generated per request, never a permanent public URL.

{ "url": "https://…?X-Amz-Signature=…", "expiresAt": "2026-08-13T15:04:05.000Z" }

404 NOT_FOUND when the order is out of scope, when the dispatch does not belong to this order, or when the dispatch has no PDF. All three give the same answer, so the response cannot disclose that some other order's dispatch exists.

POST /api/orders/:id/backorder

Guard: requireAdmin. Success: 202, because the reroutes are enqueued, not performed — the work is accepted rather than complete when this returns.

This is the internal hook the Sysco inbound acknowledgment processor uses after parsing an X12 855. It is admin-guarded because it is a system-to-system call made with an administrator credential, never by a chef.

{
"supplierDispatchId": "5c9e…",
"reference": "855_20260813_000412.edi",
"lines": [{ "lineItemId": "b7d1…", "ackCode": "IB", "quantity": 4 }]
}
FieldTypeRequiredConstraints
supplierDispatchIdUUIDnoDisambiguates which dispatch back-ordered the line
referencestringnoup to 200 characters; the EDI file name or control number
linesarrayyesat least one entry
lines[].lineItemIdUUIDyesMust belong to this order
lines[].ackCodeenumyesOne of IA IB IR IC IP IQ DR BP SP
lines[].quantitynumbernoNon-negative and finite; carries ACK02 for BP / IQ

Response 202 is a set of counts, not the order:

{ "accepted": 1, "rerouted": 1, "noAlternate": 0 }

The operation is idempotent by design — an 855 can be redelivered. A line already recorded as back-ordered by the same supplier is skipped entirely: no second audit row, no second job, and it is not counted in accepted.

StatuscodeCause
404NOT_FOUNDThe order does not exist, or details.lineItemIds lists ids not on this order
422UNPROCESSABLEThe supplier that back-ordered a line cannot be determined (details.lineItemId)

POST /api/orders/:id/line-items/:lineItemId/reroute

Guard: requireAdmin. Success: 202 — the dispatch to the alternate supplier is the worker's job; doing it here would put an SFTP or SMTP round trip inside an HTTP request.

{ "alternateSupplierId": "2222…", "reason": "Sysco SFTP unavailable; sending to Central Kitchen" }
FieldTypeRequiredConstraints
alternateSupplierIdUUIDnoFalls back to the mapping's alt_supplier_id when omitted
reasonstringnoup to 500 characters; stored on the BACKORDER_REROUTED audit event

Response 202:

{ "lineItemId": "b7d1…", "alternateSupplierId": "2222…" }
StatuscodeCause
404NOT_FOUNDNo such line item on this order, or the supplied alternateSupplierId does not exist
422UNPROCESSABLENeither the body nor the mapping names an alternate; the named alternate is inactive; the original supplier cannot be determined

Admin catalog module — /api/admin/catalog

Every route: requireAdmin. apps/api/src/domain/admin-catalog.ts.

GET /api/admin/catalog

Same catalogQuerySchema as the chef endpoint, but includeInactive is honoured — managing deactivated items is what this screen is for — and both search and category match case-insensitively, because category is free text and demanding exact casing would be a trap. Unlike the chef view, unmapped items are included; their mapping is null and they cannot be dispatched.

Response 200: a paginated envelope of adminCatalogItemSchema.

{
"id": "e18c…",
"name": "Roma tomatoes",
"category": "Produce",
"unit": "case",
"unitSize": "6x10oz",
"description": "Grade A, for sauce service",
"active": true,
"createdAt": "2026-08-01T09:12:44.000Z",
"updatedAt": "2026-08-11T14:02:10.000Z",
"mapping": {
"id": "77aa…",
"catalogItemId": "e18c…",
"primarySupplierId": "1111…",
"altSupplierId": "2222…",
"supplierSku": "4471102",
"supplierProductCode": null,
"active": true,
"createdAt": "2026-08-01T09:13:02.000Z",
"updatedAt": "2026-08-01T09:13:02.000Z",
"primarySupplier": { "id": "1111…", "name": "Sysco", "type": "sysco" },
"altSupplier": { "id": "2222…", "name": "Central Kitchen", "type": "central_kitchen" }
}
}

GET /api/admin/catalog/:id

One item in the shape above. 404 NOT_FOUND when absent.

POST /api/admin/catalog

FieldTypeRequiredConstraints
namestringyes1–200 characters
categorystring or nullnoup to 100
unitstringyes1–50
unitSizestring or nullnoup to 50
descriptionstring or nullnoup to 2000
activebooleannodatabase default true

Response 201 with the created item; appends CATALOG_ITEM_CREATED.

409 CONFLICT on a duplicate name, compared case-insensitively. There is no unique index on catalog_items.name — "same name ignoring case" is not expressible as one — so this is a check-then-insert, and two simultaneous creates of the same name could in principle both land. On an admin-only, single-digit-QPS surface that is an accepted trade.

PUT /api/admin/catalog/:id

Every create field, all optional. Response 200 with the updated item; appends CATALOG_ITEM_UPDATED carrying a changes diff. A no-op patch appends nothing. 404, and 409 on a name clash with a different item.

DELETE /api/admin/catalog/:id

Soft delete: sets active = false; the row survives because orders reference it. Response 200 returns the deactivated item in full — not { "ok": true } — so the client can re-render the row it just acted on. Appends CATALOG_ITEM_DEACTIVATED only on a real transition, so repeating the call is idempotent and does not pad the audit trail. 404 when absent.


Admin suppliers module — /api/admin/suppliers

Every route: requireAdmin. apps/api/src/domain/suppliers.ts.

GET /api/admin/suppliers

Query parameterTypeNotes
page, pageSizeintegerStandard pagination
searchstring1–200 characters
typeenumsysco, costco, central_kitchen
activebooleanOmitted means both

Response 200: a paginated envelope of the supplier row plus mappedItemCount — how many active mappings name it as primary, i.e. the blast radius of deactivating it.

{
"id": "1111…",
"name": "Sysco",
"type": "sysco",
"config": { "outboundDir": "/outbound", "credentialsSecretName": "lucille-sysco-sftp" },
"active": true,
"createdAt": "2026-07-02T11:00:00.000Z",
"updatedAt": "2026-08-01T09:00:00.000Z",
"mappedItemCount": 42
}

GET /api/admin/suppliers/:id

One supplier in the shape above. 404 when absent.

POST /api/admin/suppliers

FieldTypeRequiredNotes
namestringyes1–200 characters
typesysco | costco | central_kitchenyes
configobjectyesValidated against the schema chosen by type
activebooleanno

config shapes, from packages/types/src/supplier.ts:

// type: 'sysco'
{
sftpHostSecretRef?: string; // Vault secret name
credentialsSecretName: string; // Vault secret name — required
outboundDir: string; // default '/outbound'
inboundDir: string; // default '/inbound'
archiveDir: string; // default '/archive'
isaSenderQualifier: string; // default 'ZZ'
isaSenderId: string; // required — from Sysco onboarding
isaReceiverQualifier: string; // default 'ZZ'
isaReceiverId: string; // required — from Sysco onboarding
ediVersion: string; // default '004010'
productIdQualifier: string; // default 'VN'
}

// type: 'costco'
{ emailAddress: string; ccAddresses: string[] } // ccAddresses defaults to []

// type: 'central_kitchen'
{ emailAddress: string; ccAddresses: string[]; facilityName?: string }

Response 201 with the created supplier (mappedItemCount is 0); appends SUPPLIER_CREATED carrying only the config keys, never the values.

StatuscodeCause
400VALIDATION_FAILEDconfig does not match the schema for type; issues are rooted at config.<key>
422UNPROCESSABLEconfig contains something that looks like a plaintext credential

The credential scan runs on the raw object before the per-type parse strips unknown keys, so a stray password is reported rather than silently dropped. The rule is blunt: any key matching /password|secret|privatekey|token/i is rejected unless its name ends in SecretName or SecretRef and its value looks like a bare Vault secret name. details lists every offending path with an explanation. A false rejection costs one rename; a false acceptance leaks a supplier credential to everyone holding the admin role.

There is no uniqueness constraint on suppliers.name, so a duplicate name is not an error here.

PUT /api/admin/suppliers/:id

All fields optional. config is re-validated against the effective type — the one in the patch if present, otherwise the stored one — so a type-only patch re-checks the stored config and fails with 400 rather than leaving a row the dispatch worker cannot use. Whenever either half is supplied, the canonicalised config with defaults applied is written back. Response 200; appends SUPPLIER_UPDATED. 404, 400 and 422 as above.

DELETE /api/admin/suppliers/:id

Soft delete. Response 200 with the deactivated supplier; appends SUPPLIER_UPDATED with action: 'deactivated' (the audit vocabulary has no SUPPLIER_DEACTIVATED member, and adding one would break the auditQuerySchema allowlist on the read side).

422 UNPROCESSABLE while any active mapping still names it as primary: the dispatch worker resolves a line's supplier through that mapping, so deactivating would turn every affected item into a silent order failure. details.affectedCatalogItems lists them so they can be re-mapped first. Being named only as an alternate is not blocking — losing the fallback degrades a back-order to backorder_no_alternate, which is a modelled outcome rather than a broken order.


Admin mappings module — /api/admin/mappings

Every route: requireAdmin. apps/api/src/domain/mappings.ts.

GET /api/admin/mappings

Query parameterTypeNotes
page, pageSizeintegerStandard pagination
catalogItemIdUUIDFilter to one item
supplierIdUUIDMatches either primary or alternate
activebooleanOmitted means both

Response 200: a paginated envelope of the mapping with its catalogItem and both supplier summaries (primarySupplier, altSupplier) inlined.

GET /api/admin/mappings/:id

One mapping in the shape above. 404 when absent.

POST /api/admin/mappings

FieldTypeRequiredConstraints
catalogItemIdUUIDyesUnique across mappings
primarySupplierIdUUIDyesMust exist and be active
altSupplierIdUUID or nullnoMust differ from the primary
supplierSkustringyes1–100 characters
supplierProductCodestring or nullnoup to 100
activebooleanno

Response 201; appends MAPPING_CREATED.

StatuscodeCause
400VALIDATION_FAILEDaltSupplierId equals primarySupplierId — reported at path altSupplierId with the message Alternate supplier must differ from the primary supplier
409CONFLICTThat catalog item already has a mapping (catalog_item_id is UNIQUE)
422UNPROCESSABLEcatalogItemId does not exist; a named supplier is missing (details.missingSupplierIds) or inactive (details.inactiveSuppliers)

422 rather than 404 for a non-existent catalogItemId is deliberate: the id is in the body, so it is a fact about the payload rather than about the resource the URL addresses. An inactive supplier is 422 for the same reason the order path uses it — the request is syntactically valid and it is the state of the world that makes it unactionable.

PUT /api/admin/mappings/:id

All fields optional. The alternate-versus-primary check is re-run against the stored row, because a patch that moves only altSupplierId has nothing to compare against inside the body. Only the suppliers the patch actually touches are re-validated, so an admin editing a SKU is not blocked because some other supplier was deactivated meanwhile. Response 200; appends MAPPING_UPDATED with a changes diff. 400, 404, 409, 422 as above.

DELETE /api/admin/mappings/:id

Soft delete. Response 200 is { mapping, warning }, not the bare mapping — the warning is the whole point, because without an active mapping the catalog item silently stops being orderable and the next person to find out would otherwise be a chef whose order was rejected.

{
"mapping": { "id": "77aa…", "active": false, "…": "…" },
"warning": "\"Roma tomatoes\" is no longer orderable: it has no active supplier mapping, so any order containing it will be rejected until a new mapping exists."
}

Appends MAPPING_UPDATED with action: 'deactivated'. 404 when absent.


Admin restaurants module — /api/admin/restaurants

Every route: requireAdmin. apps/api/src/domain/restaurants.ts. The assignment routes write user_restaurants, which is the source of truth for multi-tenant scoping — they grant and revoke a chef's access to a location's orders.

GET /api/admin/restaurants

Query parameterTypeNotes
page, pageSizeintegerStandard pagination
searchstring1–200 characters
activebooleanOmitted means both

Response 200: a paginated envelope of the restaurant plus chefCount. There is no include=chefs parameter; the assigned chefs come from the detail endpoint.

GET /api/admin/restaurants/:id

Response 200: the restaurant plus chefs, an array of { id, email, displayName }. 404 when absent.

POST /api/admin/restaurants

FieldTypeRequiredConstraints
namestringyes1–200 characters
locationstring or nullnoup to 500
activebooleannoschema default true

Response 201 with the restaurant and chefCount: 0; appends RESTAURANT_CREATED. restaurants.name carries no uniqueness constraint, so a duplicate name is not an error.

PUT /api/admin/restaurants/:id

All fields optional. Response 200; appends RESTAURANT_UPDATED with the changed fields only. A no-op patch appends nothing. 404 when absent.

POST /api/admin/restaurants/:id/chefs

Body: { "userId": "6b1f…" }. Response 201 with the full restaurant detail including the new chef list, not { "ok": true }, so the caller needs no follow-up GET. Appends CHEF_ASSIGNED.

StatuscodeCause
404NOT_FOUNDThe restaurant does not exist, or the user has no mirrored row yet
409CONFLICTThe assignment already exists (the composite primary key gives this check for free)
422UNPROCESSABLEThe user is an admin — admin access is global and is deliberately not stored as rows

The user-not-found 404 carries a long explanatory message rather than the generic "User not found", because the cause is almost always the same: users are provisioned in Zitadel and are mirrored into the API only on their first authenticated call, so a user who has never signed in cannot be assigned yet.

DELETE /api/admin/restaurants/:id/chefs/:userId

Response 200 with the updated restaurant detail; appends CHEF_UNASSIGNED. 404 NOT_FOUND when there is no such assignment — including when either id does not exist, since "there is no such assignment" is the same answer either way.

The chef loses access on their next request: assignments are read from user_restaurants per request, never from the token.


Admin orders module — /api/admin/orders

Every route: requireAdmin. apps/api/src/domain/admin-orders.ts. No restaurant scoping at all, and supplier identity is populated — on every line item, every dispatch and every audit payload.

GET /api/admin/orders

Same orderQuerySchema as the chef list, except restaurantId is honoured as an ordinary filter rather than being replaced by the caller's assignments. Response 200: the shared list projection plus lineItemStatusCounts, a sparse per-status breakdown — statuses with no line items are absent, since a payload full of zeroes would be noise on every one of 100 rows.

{
"id": "af31…",
"restaurantId": "9c2a…",
"restaurantName": "Lucille Downtown",
"placedBy": "6b1f…",
"placedByName": "Alex Marchetti",
"status": "partial",
"notes": null,
"lineItemCount": 12,
"createdAt": "2026-08-13T04:55:01.000Z",
"updatedAt": "2026-08-13T05:01:44.000Z",
"lineItemStatusCounts": { "confirmed": 10, "backordered": 2 }
}

GET /api/admin/orders/audit

The audit trail across all orders.

Query parameterTypeNotes
page, pageSizeintegerStandard pagination
orderIdUUIDFilter to one order
eventTypeenumOne of the 22 auditEventTypeSchema members
from, toISO date-timeInclusive bounds on created_at

Response 200: a paginated envelope of auditEventSchema with actorEmail joined from users. A null actorId means a background worker rather than a human produced the event.

This route is declared before /:id for readability, but the order does not matter: Fastify's radix-tree router always prefers a static segment over a parametric one, and parseParams would reject audit as a non-UUID anyway.

GET /api/admin/orders/:id

Response 200: the same orderDetailSchema as the chef endpoint, with supplier names and types present on line items and dispatches and audit payloads unredacted. 404 when the order does not exist — there is no scope check to fail, because an admin is global.


Inbound EDI module — /api/inbound/sysco

apps/api/src/routes/inbound/sysco.ts, apps/api/src/domain/inbound-ack.ts.

POST /api/inbound/sysco

Guard: requireAdmin. Success: 200.

The sysco-inbound-poll CronJob does not call this endpoint. The technical plan had it POST the files it downloads here; it does not, for two reasons. There is no machine-to-machine credential — every route is guarded by a Zitadel-issued bearer token and no service account exists in this deployment, so the CronJob would have to carry a human's long-lived token or this route would have to be unguarded, both strictly worse. And it buys nothing: the CronJob runs from the same image with the same database and Redis credentials, and processInboundEdi is exported from @lucille/api/domain/inbound-ack, so going over HTTP would add a network hop, a second failure mode and an auth problem to a call that is already local. The CronJob calls processInboundEdi directly, and this route remains what it is genuinely useful as: the admin-driven reprocess and testing entrypoint for a file that was already archived, arrived out of band, or needs replaying after a bug fix.

Request body — one file per call, not a batch:

{ "content": "ISA*00*…", "filename": "855_20260813_000412.edi" }
FieldTypeRequiredConstraints
contentstringyes1–1048576 characters; the raw interchange exactly as received
filenamestringno1–255 characters; the original SFTP file name

filename becomes vendor_documents.reference and doubles as the idempotency key. When it is omitted a content hash is used instead. Re-posting a file that has already been applied therefore reports alreadyProcessed: true and changes nothing.

Behaviour. The transaction set is identified from ST01 without a full parse. A 997 goes to the functional-acknowledgment path, an 855 to the purchase-order-acknowledgment path, and anything unrecognised is tried as an 855 then as a 997 before finally being recorded as a failed 855 — the far more common inbound document — so the raw bytes and the parse error are kept for manual review rather than discarded. Every file is written to vendor_documents whether or not it parsed.

Response 200:

{
"transactionSetId": "855",
"matched": 1,
"unmatched": 0,
"confirmed": 3,
"backordered": 1,
"rerouted": 1,
"rejected": 0,
"warnings": [],
"parsed": true,
"alreadyProcessed": false,
"reference": "855_20260813_000412.edi"
}
FieldMeaning
transactionSetId"855", "997", or null when it could not be read
parsedWhether the parser produced usable data
alreadyProcessedThis reference had already been applied; nothing changed
matchedTransactions correlated to a supplier_dispatches row
unmatchedTransactions that matched nothing — persisted with dispatchId: null
confirmedLine items moved to confirmed
backorderedLine items moved to backordered
reroutedReroute jobs enqueued
rejectedRejected 855 lines plus rejected 997 interchanges
warningsNon-fatal parser and correlation observations

An unparseable EDI file is not a 400: it is stored with parsed_data = null and a parseError, reported to Sentry, and returned with parsed: false and the reason in warnings, because losing the file is worse than failing the call. A 400 here means the envelope was malformed — missing or over-long content, for instance.

Two conditions surface as 500 INTERNAL: no X12 parser implementation is reachable in the process (the worker image supplies apps/worker/dist/edi, so it must be built), and no active supplier of type sysco exists to attribute the document to. Both are deployment faults rather than client errors.

The parsing rules themselves — segments, ACK code dispositions and 997 rejection semantics — are documented in Sysco EDI integration.


Verification status

Every endpoint, guard, request schema, success status, response shape and error code on this page was read out of the source rather than the plan. The anchors, which are stable across commits, are:

  • apps/api/src/routes/index.ts — the registration order and PUBLIC_ROUTES
  • apps/api/src/routes/{health,auth,catalog,orders}.ts
  • apps/api/src/routes/admin/{catalog,suppliers,mappings,restaurants,orders}.ts
  • apps/api/src/routes/inbound/sysco.ts
  • apps/api/src/domain/*.ts — response shapes and the error each case raises
  • apps/api/src/errors.ts and packages/types/src/api-errors.ts — the error taxonomy
  • apps/api/src/http/validate.ts — how validation failures are reported
  • apps/api/src/plugins/auth.ts and apps/api/src/app.ts — guards, rate limiting, error handling
  • packages/types/src/*.ts — exact request and response field names
  • apps/api/tests/ — 305 passing integration tests across 13 files, including the route-table walk in rbac.test.ts

Two statements on this page describe behaviour that differs from its stated intent rather than a contract: the rate-limit key falls back to the source address because of hook ordering, and the per-endpoint 429/500 rows are omitted from the tables for brevity. Everything else is the observed behaviour of the committed code.

Where to go next