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, 1–100, 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:
| Status | code | Raised by |
|---|---|---|
400 | VALIDATION_FAILED | Body, query or path parameter failed its Zod schema; also Fastify's own 4xx errors |
401 | UNAUTHENTICATED | No bearer token, or a token that does not verify |
403 | FORBIDDEN | Valid token, but the role guard or the restaurant scope forbids the request |
404 | NOT_FOUND | No such record, no such route, or a record outside the caller's scope |
409 | CONFLICT | Uniqueness or state conflict (inactive resource, idempotency-key misuse) |
422 | UNPROCESSABLE | Well-formed but not actionable — the classic case is a line with no supplier mapping |
429 | RATE_LIMITED | Rate limit exceeded |
500 | INTERNAL | Unhandled 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:
| Guard | Accepts | Rejects |
|---|---|---|
app.requireAuth | any verified token | 401 when the token is missing or invalid |
app.requireChef | chef and admin | 403 for any other role |
app.requireAdmin | admin only | 403 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 id —
GET /api/orders/:id,GET /api/orders/:id/dispatches/:dispatchId/pdf— returns404, not403. A403would 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
restaurantIdfilter you are not assigned to —POST /api/orders,GET /api/orders— returns403. 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:
- Catalog. The chef projection
chefCatalogItemSchemahas no supplier field at all, anddomain/catalog.tsselects only those columns rather than selecting the row and trusting a later mapper to drop the rest. - Order detail.
orderLineItemDetailSchema.supplierNameandsupplierDispatchSchema.supplierName/.supplierTypeare omitted entirely for a chef.supplierIdremains 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. - Audit event payloads are redacted for chefs too. This is the part that is easy to get wrong.
audit_events.payloadis free-form JSONB and several event types legitimately record which supplier was involved, which would be a back door around the rule.redactSupplierIdentity()indomain/orders.tstherefore stripssupplierId,supplierType,supplierName,originalSupplierId,alternateSupplierIdandaltSupplierIdfrom 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.
| Header | Direction | Meaning |
|---|---|---|
X-Idempotency-Key | request | Optional, up to 255 characters. A repeat replays the first response |
X-Idempotent-Replay | response | Set 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, is409 CONFLICTrather 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:
| Scope | Limit | Environment variable |
|---|---|---|
Routes under /api/admin | 200 per window | RATE_LIMIT_ADMIN_MAX |
| Everything else | 100 per window | RATE_LIMIT_MAX |
| Window | 1 minute | RATE_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/admin — POST /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.
| Method | Path | Guard | Success |
|---|---|---|---|
GET | /healthz | public | 200 |
GET | /readyz | public | 200/503 |
GET | /metrics | public | 200 |
GET | /api/auth/login | public | 200/302 |
GET | /api/auth/callback | public | 302 |
POST | /api/auth/refresh | public | 200 |
POST | /api/auth/logout | public | 200 |
GET | /api/auth/me | auth | 200 |
GET | /api/catalog | chef | 200 |
GET | /api/catalog/categories | chef | 200 |
POST | /api/orders | chef | 201 |
GET | /api/orders | chef | 200 |
GET | /api/orders/:id | chef | 200 |
GET | /api/orders/:id/dispatches/:dispatchId/pdf | chef | 200 |
POST | /api/orders/:id/backorder | admin | 202 |
POST | /api/orders/:id/line-items/:lineItemId/reroute | admin | 202 |
GET | /api/admin/catalog | admin | 200 |
GET | /api/admin/catalog/:id | admin | 200 |
POST | /api/admin/catalog | admin | 201 |
PUT | /api/admin/catalog/:id | admin | 200 |
DELETE | /api/admin/catalog/:id | admin | 200 |
GET | /api/admin/suppliers | admin | 200 |
GET | /api/admin/suppliers/:id | admin | 200 |
POST | /api/admin/suppliers | admin | 201 |
PUT | /api/admin/suppliers/:id | admin | 200 |
DELETE | /api/admin/suppliers/:id | admin | 200 |
GET | /api/admin/mappings | admin | 200 |
GET | /api/admin/mappings/:id | admin | 200 |
POST | /api/admin/mappings | admin | 201 |
PUT | /api/admin/mappings/:id | admin | 200 |
DELETE | /api/admin/mappings/:id | admin | 200 |
GET | /api/admin/restaurants | admin | 200 |
GET | /api/admin/restaurants/:id | admin | 200 |
POST | /api/admin/restaurants | admin | 201 |
PUT | /api/admin/restaurants/:id | admin | 200 |
POST | /api/admin/restaurants/:id/chefs | admin | 201 |
DELETE | /api/admin/restaurants/:id/chefs/:userId | admin | 200 |
GET | /api/admin/orders | admin | 200 |
GET | /api/admin/orders/audit | admin | 200 |
GET | /api/admin/orders/:id | admin | 200 |
POST | /api/inbound/sysco | admin | 200 |
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 parameter | Type | Required | Notes |
|---|---|---|---|
code | string | yes | Authorization code |
state | string | yes | Must equal the value in the lucille_state cookie |
error | string | — | Present 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).
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_FAILED | code missing |
401 | UNAUTHENTICATED | error 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 parameter | Type | Default | Constraints |
|---|---|---|---|
page | integer | 1 | minimum 1 |
pageSize | integer | 25 | 1–100 |
search | string | — | 1–200 characters; case-insensitive match on name only |
category | string | — | 1–100 characters; exact match |
includeInactive | boolean | false | Honoured 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.
| Header | Required | Notes |
|---|---|---|
X-Idempotency-Key | no | Up 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 }
]
}
| Field | Type | Required | Constraints |
|---|---|---|---|
restaurantId | UUID | yes | Must be one of the caller's assignments (any, for admin) |
notes | string or null | no | up to 2000 characters |
lineItems | array | yes | 1–200 entries; the field is lineItems, not lines |
lineItems[].catalogItemId | UUID | yes | Must be distinct across the array |
lineItems[].quantity | number | yes | greater 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.
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_FAILED | Empty or over-long lineItems, non-positive quantity, duplicate item, malformed UUID |
403 | FORBIDDEN | restaurantId is not in the caller's assignments |
404 | NOT_FOUND | The restaurant does not exist, or details.catalogItemIds lists unknown items |
409 | CONFLICT | The 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 |
422 | UNPROCESSABLE | One 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 parameter | Type | Notes |
|---|---|---|
page, pageSize | integer | Standard pagination |
restaurantId | UUID | Optional filter; 403 if the caller is not assigned to it |
status | enum | pending, dispatching, completed, partial, failed |
from, to | ISO date-time | Inclusive 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 }]
}
| Field | Type | Required | Constraints |
|---|---|---|---|
supplierDispatchId | UUID | no | Disambiguates which dispatch back-ordered the line |
reference | string | no | up to 200 characters; the EDI file name or control number |
lines | array | yes | at least one entry |
lines[].lineItemId | UUID | yes | Must belong to this order |
lines[].ackCode | enum | yes | One of IA IB IR IC IP IQ DR BP SP |
lines[].quantity | number | no | Non-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.
| Status | code | Cause |
|---|---|---|
404 | NOT_FOUND | The order does not exist, or details.lineItemIds lists ids not on this order |
422 | UNPROCESSABLE | The 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" }
| Field | Type | Required | Constraints |
|---|---|---|---|
alternateSupplierId | UUID | no | Falls back to the mapping's alt_supplier_id when omitted |
reason | string | no | up to 500 characters; stored on the BACKORDER_REROUTED audit event |
Response 202:
{ "lineItemId": "b7d1…", "alternateSupplierId": "2222…" }
| Status | code | Cause |
|---|---|---|
404 | NOT_FOUND | No such line item on this order, or the supplied alternateSupplierId does not exist |
422 | UNPROCESSABLE | Neither 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
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | 1–200 characters |
category | string or null | no | up to 100 |
unit | string | yes | 1–50 |
unitSize | string or null | no | up to 50 |
description | string or null | no | up to 2000 |
active | boolean | no | database 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 parameter | Type | Notes |
|---|---|---|
page, pageSize | integer | Standard pagination |
search | string | 1–200 characters |
type | enum | sysco, costco, central_kitchen |
active | boolean | Omitted 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
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–200 characters |
type | sysco | costco | central_kitchen | yes | |
config | object | yes | Validated against the schema chosen by type |
active | boolean | no |
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.
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_FAILED | config does not match the schema for type; issues are rooted at config.<key> |
422 | UNPROCESSABLE | config 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 parameter | Type | Notes |
|---|---|---|
page, pageSize | integer | Standard pagination |
catalogItemId | UUID | Filter to one item |
supplierId | UUID | Matches either primary or alternate |
active | boolean | Omitted 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
| Field | Type | Required | Constraints |
|---|---|---|---|
catalogItemId | UUID | yes | Unique across mappings |
primarySupplierId | UUID | yes | Must exist and be active |
altSupplierId | UUID or null | no | Must differ from the primary |
supplierSku | string | yes | 1–100 characters |
supplierProductCode | string or null | no | up to 100 |
active | boolean | no |
Response 201; appends MAPPING_CREATED.
| Status | code | Cause |
|---|---|---|
400 | VALIDATION_FAILED | altSupplierId equals primarySupplierId — reported at path altSupplierId with the message Alternate supplier must differ from the primary supplier |
409 | CONFLICT | That catalog item already has a mapping (catalog_item_id is UNIQUE) |
422 | UNPROCESSABLE | catalogItemId 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 parameter | Type | Notes |
|---|---|---|
page, pageSize | integer | Standard pagination |
search | string | 1–200 characters |
active | boolean | Omitted 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
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | 1–200 characters |
location | string or null | no | up to 500 |
active | boolean | no | schema 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.
| Status | code | Cause |
|---|---|---|
404 | NOT_FOUND | The restaurant does not exist, or the user has no mirrored row yet |
409 | CONFLICT | The assignment already exists (the composite primary key gives this check for free) |
422 | UNPROCESSABLE | The 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 parameter | Type | Notes |
|---|---|---|
page, pageSize | integer | Standard pagination |
orderId | UUID | Filter to one order |
eventType | enum | One of the 22 auditEventTypeSchema members |
from, to | ISO date-time | Inclusive 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" }
| Field | Type | Required | Constraints |
|---|---|---|---|
content | string | yes | 1–1048576 characters; the raw interchange exactly as received |
filename | string | no | 1–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"
}
| Field | Meaning |
|---|---|
transactionSetId | "855", "997", or null when it could not be read |
parsed | Whether the parser produced usable data |
alreadyProcessed | This reference had already been applied; nothing changed |
matched | Transactions correlated to a supplier_dispatches row |
unmatched | Transactions that matched nothing — persisted with dispatchId: null |
confirmed | Line items moved to confirmed |
backordered | Line items moved to backordered |
rerouted | Reroute jobs enqueued |
rejected | Rejected 855 lines plus rejected 997 interchanges |
warnings | Non-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 andPUBLIC_ROUTESapps/api/src/routes/{health,auth,catalog,orders}.tsapps/api/src/routes/admin/{catalog,suppliers,mappings,restaurants,orders}.tsapps/api/src/routes/inbound/sysco.tsapps/api/src/domain/*.ts— response shapes and the error each case raisesapps/api/src/errors.tsandpackages/types/src/api-errors.ts— the error taxonomyapps/api/src/http/validate.ts— how validation failures are reportedapps/api/src/plugins/auth.tsandapps/api/src/app.ts— guards, rate limiting, error handlingpackages/types/src/*.ts— exact request and response field namesapps/api/tests/— 305 passing integration tests across 13 files, including the route-table walk inrbac.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
- Authentication — how a request is authorised.
- Roles and permissions — the route-to-role summary.
- Queues and jobs — what
POST /api/ordersenqueues. - Data model — the tables behind these payloads.
- Testing — how the contracts above are asserted.