Manages the container equipment catalogue (types and sizes) and the physical inventory of containers. Handles the full lifecycle of a container from depot availability through deployment on a shipment to return.
- Maintain the catalogue of equipment types (20FT, 40FT, 40FT HC, etc.)
- Track the inventory and current status of each container unit
- Expose an API to reserve ("deploy") containers for a booking
- Record container pickup at origin and return at destination
- Provide availability counts per equipment type to Schedules and Quotes
| Code | Description | Nominal Length | Max Payload (kg) |
|---|---|---|---|
| 20FT | Standard 20-foot dry container | 20' | 28 200 |
| 40FT | Standard 40-foot dry container | 40' | 26 500 |
| 40HC | 40-foot High Cube | 40' | 26 460 |
| 20RF | 20-foot Reefer | 20' | 27 400 |
| 40RF | 40-foot Reefer High Cube | 40' | 26 380 |
The catalogue is employee-manageable so new types can be added without code changes.
AVAILABLE → RESERVED → DISPATCHED ──┬──→ RETURNED → AVAILABLE
↓ │
RELEASED └──→ IN_TRANSIT → RETURNED → AVAILABLE
(reservation cancelled (set today via manual status override)
before dispatch)
| Status | Meaning |
|---|---|
| AVAILABLE | In depot, ready to be booked |
| RESERVED | Allocated to a confirmed booking, awaiting pickup |
| DISPATCHED | Picked up by customer at origin; this is the last status reached by the standard pickup flow |
| IN_TRANSIT | On the vessel; today this is reached through the manual status override endpoint rather than a dedicated public transition |
| RETURNED | Delivered and back at destination depot |
| RELEASED | Reservation cancelled; returns to AVAILABLE |
| Method | Path | Description |
|---|---|---|
| GET | /equipment-types | List all equipment types |
| POST | /equipment-types | Add a new equipment type |
| PUT | /equipment-types/{code} | Update an equipment type |
| Method | Path | Description |
|---|---|---|
| POST | /containers | Register a new container unit |
| GET | /containers | List containers (filterable by type/status/depot) |
| GET | /containers/{id} | Get a specific container |
| PATCH | /containers/{id}/status | Manual status override (ops use); currently the supported way to mark a dispatched container as IN_TRANSIT |
| Method | Path | Description |
|---|---|---|
| GET | /availability | Get available counts by equipment type |
| POST | /reservations | Reserve containers for a booking |
| DELETE | /reservations/{bookingReference} | Release reservation (booking cancelled) |
| POST | /containers/{id}/pickup | Record container pickup at origin |
| POST | /containers/{id}/return | Record container return at destination |
{
"availability": [
{ "equipmentType": "20FT", "availableCount": 45, "depotCode": "CNSHA-01" },
{ "equipmentType": "40FT", "availableCount": 18, "depotCode": "CNSHA-01" },
{ "equipmentType": "40HC", "availableCount": 12, "depotCode": "CNSHA-01" }
]
}{
"bookingReference": "BKG-2026-00042",
"originDepot": "CNSHA-01",
"equipment": [
{ "type": "20FT", "quantity": 2 }
]
}{
"reservationId": "RES-uuid",
"bookingReference": "BKG-2026-00042",
"assignedContainers": [
{ "containerId": "CONU1234567", "type": "20FT" },
{ "containerId": "CONU7654321", "type": "20FT" }
],
"status": "RESERVED"
}| Field | Type | Notes |
|---|---|---|
| id | UUID | Internal primary key |
| containerNumber | string | ISO 6346 number (e.g. CONU1234567) |
| equipmentType | string | FK to equipment type code |
| status | enum | See lifecycle above |
| currentDepot | string | Depot code where container is located |
| bookingReference | string | Set when RESERVED or later; null when AVAILABLE |
| lastMovedAt | timestamp | |
| createdAt | timestamp |
| Field | Type | Notes |
|---|---|---|
| id | UUID | |
| bookingReference | string | |
| containers | JSON array | List of assigned container IDs |
| status | enum | ACTIVE, RELEASED |
| createdAt | timestamp |
| Field | Type | Notes |
|---|---|---|
| id | UUID-like string | Stable local user identifier for future record metadata |
| issuer | string | Token issuer or identity namespace |
| subject | string | External caller subject within the issuer |
| createdAt | timestamp | When the local mapping was created |
- Reservations are created atomically — either all requested units are reserved or the request fails
- Released reservations (cancelled bookings) return containers to AVAILABLE immediately
- Container pickup can only be recorded when status is RESERVED
- There is no dedicated API transition from DISPATCHED to IN_TRANSIT; operations use
PATCH /containers/{id}/statuswhen they need to reflect vessel departure - Container return can only be recorded when status is IN_TRANSIT or DISPATCHED
| Event | Action |
|---|---|
booking.cancelled |
Automatically release the reservation for that booking |
booking.completed |
Trigger return flow if not already done |
- Depot-to-depot repositioning logic
- Maintenance / repair tracking
- Reefer temperature monitoring
- ISO 6346 check-digit validation
This repository now includes a runnable TypeScript/Node implementation of the service:
- Runtime: Node 22.5+ with Fastify
- Source:
src/ - Tests:
test/service.test.ts
src/index.ts- server entrypointsrc/server.ts- HTTP routes and error handlingsrc/store.ts- in-memory domain logic, lifecycle transitions, reservation atomics, event handlingsrc/types.ts- domain types and enumssrc/errors.ts- domain error type mapped to HTTP responses
Install dependencies first:
npm installUse Node 22.5 or newer for local development and production builds because the
service imports the built-in node:sqlite module.
Default local development uses the in-memory backend, which needs no migration step:
npm run devService starts on http://0.0.0.0:3000 by default.
GET /health remains unauthenticated. All other routes require Authorization: Bearer <token>.
Machine-readable API documentation is exposed at GET /openapi.json. The browser playground also links to that document directly.
Bearer token configuration is driven by these environment variables:
AUTH_JWT_ISSUERdefaults toplatform-authAUTH_JWT_AUDIENCEdefaults toequipments-serviceAUTH_JWT_SECRETdefaults toequipments-dev-secret
For local development, Users Service POST /auth/token is the source of admin bearer tokens for callers that exercise Equipments protected endpoints. Equipments does not call Users Service to introspect tokens; it validates the JWT locally, so both services must be configured with the same JWT values:
AUTH_JWT_ISSUERmust match the tokenissAUTH_JWT_AUDIENCEmust match the tokenaud; array audiences are accepted when one entry matchesAUTH_JWT_SECRETmust be the same HS256 signing secret used by Users Service
The expected Users Service admin token shape is:
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "<stable users.id>",
"iss": "<AUTH_JWT_ISSUER>",
"aud": "<AUTH_JWT_AUDIENCE>",
"exp": 1770000000,
"scope": "equipments:read equipments:modify",
"role": "admin"
}
}sub must be the stable Users Service users.id value because Equipments records authenticated write metadata against the (iss, sub) identity. role must be exactly admin to authorize both read and write routes without relying on Equipments-specific scopes. Non-admin tokens still need equipments:read for read routes and equipments:modify for write routes.
For local validation, start both services with matching JWT configuration:
export AUTH_JWT_ISSUER=platform-auth
export AUTH_JWT_AUDIENCE=equipments-service
export AUTH_JWT_SECRET=equipments-dev-secretWhen Users Service is running locally, get an admin bearer token from POST /auth/token using that service's configured local admin credential or fixture. The JSON body below represents that local fixture payload; the response token must have the claim shape above:
USERS_SERVICE_URL=http://localhost:3001
TOKEN=$(curl -fsS -X POST "$USERS_SERVICE_URL/auth/token" \
-H "Content-Type: application/json" \
-d '{"role":"admin"}' \
| node -pe 'JSON.parse(require("fs").readFileSync(0, "utf8").toString()).token')If Users Service is not running, you can construct a Users Service-shaped admin token with Node as long as the same AUTH_JWT_* values are used by Equipments:
TOKEN=$(node --input-type=module <<'EOF'
import { createHmac } from "node:crypto";
const secret = process.env.AUTH_JWT_SECRET || "equipments-dev-secret";
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
const payload = Buffer.from(
JSON.stringify({
sub: process.env.USERS_ADMIN_ID || "local-users-admin-id",
iss: process.env.AUTH_JWT_ISSUER || "platform-auth",
aud: process.env.AUTH_JWT_AUDIENCE || "equipments-service",
exp: Math.floor(Date.now() / 1000) + 3600,
scope: "equipments:read equipments:modify",
role: "admin"
})
).toString("base64url");
const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
process.stdout.write(`${header}.${payload}.${signature}`);
EOF
)Then call at least one local read endpoint and one write endpoint with the same bearer token:
EQUIPMENTS_URL=http://localhost:3000
curl -fsS "$EQUIPMENTS_URL/equipment-types" \
-H "Authorization: Bearer $TOKEN"
VALIDATION_CODE="VAL$(date +%s)"
curl -fsS -X POST "$EQUIPMENTS_URL/equipment-types" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"code\":\"$VALIDATION_CODE\",\"description\":\"Local validation dry container\",\"nominalLength\":\"local\",\"maxPayloadKg\":1}"The browser playground stays publicly reachable so developers can inspect the API and paste a bearer token before sending protected requests. The service treats GET /, GET /health, GET /openapi.json, GET /playground, and GET /playground/* as public routes. POST /dev/generate-token is also public at the auth layer, but returns 404 outside development mode. Playground request presets mark GET /health as public; all other service presets are protected and need a bearer token before the request will authorize.
Protected playground requests use the bearer token field exactly like any other API client:
- read-only
GETandHEADservice routes require theequipments:readscope - state-changing
POST,PUT,PATCH, andDELETEservice routes require theequipments:modifyscope - tokens can carry both scopes for full scoped access
- tokens with
roleset exactly toadminauthorize every protected route without requiring Equipments-specific scopes
Admin rights are represented only by the JWT role: "admin" claim. There is no separate Equipments admin-rights store or playground session state. The token still must pass the normal bearer-token checks: HS256 signature, matching issuer, matching audience, future expiry, and non-empty subject. Use a stable Users Service users.id as sub when the token represents a real admin.
In local development (NODE_ENV not set to production), the playground shows a token generator. To test admin routes manually, select role=admin in the Token rights control, enter the subject to use for audit metadata, choose an expiry, and click Generate Token. The playground calls POST /dev/generate-token, which is public but available only in development mode, signs the token with the running service's AUTH_JWT_* configuration, and copies it into the bearer token field. Selecting equipments:read, equipments:modify, or equipments:read + equipments:modify generates scoped non-admin tokens instead.
Production deployments do not expose local token generation. In production mode the playground keeps the bearer token field and scope guide, but the generator is replaced with copy that tells the developer to paste an externally issued token, and POST /dev/generate-token returns 404. For production admin testing, obtain the bearer token from Users Service POST /auth/token or the production identity provider so the token contains either role: "admin" or the specific equipments:* scopes required by the route.
The dev-only data actions are only exposed when NODE_ENV is not production:
POST /dev/reset-all-dataresets in-memory or persisted runtime data back to the seeded baselinePOST /dev/clear-all-dataremoves runtime data without reseeding so the service stays empty until restart or manual re-creation- the playground shows
Reset All DataandClear All Databuttons only in development mode; those controls require a bearer token withequipments:modifyorrole: "admin" - production mode returns
404for both endpoints and hides the controls from the playground
npm run build
npm testFor a full API walkthrough that starts from an empty database and creates all demo data through the service, see DEMO.md.
For Azure Container Apps production deployment guidance with PostgreSQL, see azure/README.md.
The repo exposes explicit migration commands for relational backends:
npm run migrate
npm run migrate:statusThose commands target the compiled dist/ output so the same entrypoints work inside the production runtime image and after a local npm run build.
For source-based local development without a build step, use:
npm run migrate:dev
npm run migrate:status:devRun the migration command with the same backend environment variables you plan to use at startup.
Startup and migration expectations by backend:
memory: no persistence, no migration stepdb: JSON snapshot persistence, no migration stepsqlite: useSTORAGE_SQLITE_PATH(or the fallbackSTORAGE_DB_PATH), then runnpm run migrate:devfor local source-based setup ornpm run migrateafter a build when you want the production-compatible entrypointpostgres: useSTORAGE_POSTGRES_URL, runnpm run migrate:devfor local source-based setup ornpm run migrateafter a build, then start the service against the migrated database
Examples:
# inspect pending SQLite migrations from source during local development
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run migrate:status:dev
# apply SQLite migrations explicitly from source, then start the service
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run migrate:dev
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run dev
# apply PostgreSQL migrations from source, then start the service
STORAGE_BACKEND=postgres \
STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments \
npm run migrate:dev
STORAGE_BACKEND=postgres \
STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments \
npm run dev
# apply migrations through the compiled production-compatible entrypoint
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run build
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run migrateBackend-specific notes:
- SQLite startup still applies missing schema migrations when the service opens the database file, so
npm run migrate:devis mainly for an explicit pre-start workflow andnpm run migrateis the production-compatible equivalent after a build STORAGE_SQLITE_EMPTY_ON_FIRST_BOOT=trueonly affects whether the seeded baseline data is inserted after a new SQLite database is created; it does not skip schema creation- PostgreSQL startup validates the migrated schema before the HTTP server begins listening
Production deployments on Azure Container Apps should run npm run migrate as a separate pre-deploy job before updating the app revision; that command now executes compiled dist/ output, matching the production image layout. See azure/README.md for the runbook and YAML scaffolding.
- Every repo change must have a beads ticket before implementation starts.
- Completed features must bump the service version in
package.jsonbefore merge. - Every service version bump must also update
VERSIONS.mdwith the user-visible changes in that version. - The healthcheck version returned by
GET /healthis expected to reflect that feature-ready version bump. - GitHub board mirroring for beads is documented in
github-board-sync.mdand audited/applied withnpm run sync:github-board.
GET /healthGET /(redirects to/playground)GET /playgroundGET /equipment-typesPOST /equipment-typesPUT /equipment-types/{code}POST /containersGET /containersGET /containers/{id}PATCH /containers/{id}/statusGET /availabilityPOST /reservationsDELETE /reservations/{bookingReference}POST /containers/{id}/pickupPOST /containers/{id}/returnPOST /events(consumesbooking.cancelledandbooking.completed)
POST /dev/reset-all-data- clears service state and restores the seeded baseline for local testingPOST /dev/clear-all-data- clears service state and leaves the service empty for local testing
equipments:readis required forGETandHEADroutes other than/healthequipments:modifyis required for write routes such asPOST,PUT,PATCH, andDELETE- callers can carry both scopes in a single token for full API access
The current Fastify route authorization matrix is persisted as authorization-rule metadata in snapshots and relational stores. Each rule records the method, path pattern, controller/action, resource type, required scope, public-route flag, and whether admin role tokens are accepted.
The service now supports runtime-selectable persistence entirely in TypeScript. The same API and domain rules run on top of one of these backends:
memory(default) keeps state in-process onlydbpersists a JSON snapshot to disksqlitepersists store state in relational SQLite tables on diskpostgrespersists store state in relational PostgreSQL tables- SQLite aliases:
sqlite3,sql,persistent-sqlite,persistent-sqlite3
Environment variables:
STORAGE_BACKENDselects the backend modeSTORAGE_DB_PATHis required whenSTORAGE_BACKEND=dbSTORAGE_SQLITE_PATHis preferred whenSTORAGE_BACKEND=sqliteSTORAGE_DB_PATHis also accepted as a fallback forsqliteSTORAGE_POSTGRES_URLis required whenSTORAGE_BACKEND=postgresSTORAGE_SQLITE_EMPTY_ON_FIRST_BOOT=trueskips seeded baseline data when a SQLite database is created for the first time
Examples:
# in-memory (default)
npm run dev
# JSON file persistence
STORAGE_BACKEND=db STORAGE_DB_PATH=.data/equipments.json npm run dev
# SQLite persistence with an explicit migration step from source
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run migrate:dev
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite npm run dev
# SQLite persistence without seeded baseline data on first boot
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite STORAGE_SQLITE_EMPTY_ON_FIRST_BOOT=true npm run migrate:dev
STORAGE_BACKEND=sqlite STORAGE_SQLITE_PATH=.data/equipments.sqlite STORAGE_SQLITE_EMPTY_ON_FIRST_BOOT=true npm run dev
# PostgreSQL persistence
STORAGE_BACKEND=postgres STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments npm run migrate:dev
STORAGE_BACKEND=postgres STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments npm run dev- Apply the schema before startup in local source-based workflows:
STORAGE_BACKEND=postgres STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments npm run migrate:dev - Apply the schema before startup with the compiled production-compatible entrypoint:
STORAGE_BACKEND=postgres STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments npm run build && STORAGE_BACKEND=postgres STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments npm run migrate - Start the service against PostgreSQL:
STORAGE_BACKEND=postgres STORAGE_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/equipments npm run dev - Run the automated PostgreSQL persistence test when a local PostgreSQL server is available:
TEST_POSTGRES_URL=postgres://equipments:equipments@localhost:5432/postgres npm test