Skip to main content
stralos-api-rates owns the rate catalogue, pricing rules, day-rate schedules, project rate context, and import jobs. This page covers the receiving-side backend contract test suite introduced to pin the wire format that backend callers depend on. For the import-specific internal contract, see Rate import service. For the full public API reference, see Rates API.

Safe error handler

All unhandled errors from route handlers in stralos-api-rates are intercepted before they reach the HTTP response. Fastify’s default behaviour — echoing error.message verbatim — is replaced by fixed copy with a support reference. Two response shapes apply: The reference value equals the x-request-id response header. Provide it when raising a support incident so the originating error can be correlated in CloudWatch. The Retry-After: 5 on a saturated database signals that the caller should wait five seconds before retrying. A Lambda container creates three connection pools of at most three connections each (nine total), with a five-second connection timeout. A container that cannot acquire a connection within that window answers 503 rather than blocking until API Gateway’s 29-second limit. These responses are distinct from the ErrorEnvelope ({ message, issues?, code? }) used by application-level validation and business-rule errors.

Response compression

The REST API Gateway sets minimum_compression_size to 1024 bytes in terraform/main.tf. When the caller sends Accept-Encoding with gzip or deflate, responses of 1 KB and over are compressed; smaller responses are not. CloudFront’s /api/* behaviour forwards Accept-Encoding and passes Content-Encoding back, so browser callers receive the compressed body. Binary responses such as XLSX exports are compressed too and decode to the same bytes. API Gateway leaves alone any response that already carries Content-Encoding, so the Lambda does not compress its own output. Changing the setting needs a redeploy, so the value is part of the deployment’s redeployment trigger hash. terraform/CONTRACT_OK carries a standing entry for aws_api_gateway_deployment.api, which is replaced whenever that hash changes.

Library history and transactional restoration

stralos-api-rates records every write to rate items and rate options as an audit event. Two new endpoints expose library-wide history and allow previously saved states to be restored atomically.

GET /history

Returns all recorded changes across every library rate item and rate option for the organisation, newest first. The list includes events for soft-deleted items. Project-only rate items are excluded — they have their own write surface and are never mixed into catalogue history. Query parameters: standard start and length pagination; optional search (matches item name); optional actor (see Actor filter). Response shape: { items: RateAuditEvent[], totalCount, start, length }. Each event carries:

GET /rate-items/:id/history

Returns all recorded changes for a single rate item and all of its rate options, newest first. A project-only item is a 404 unless ?projectId names its project. Query parameters: standard start and length pagination; optional projectId; optional actor. Response shape: same { items, totalCount, start, length } envelope as the library-wide endpoint.

Actor filter

historyActorFilter in src/lib/rate-audit.ts backs the rate history’s Changed by filter on both history endpoints: Any other value returns 400. The actor on a normal write comes from the token’s via claim (auditActor), so nothing is looked up at read time.

POST /history/:id/restore

Restores a deleted rate item or rate option, or rolls back the previous values of an update event, atomically. The restoration is transactional: if any part of it fails (for example, because a slot is already occupied by a live rate), the entire operation is rolled back and the rate library is unchanged. Permissions:
  • Every restore requires a signed-in member or admin (requireOrgMember).
  • Bringing back a deleted rate item also requires an admin, because only admins delete items.
  • "created" events cannot be restored (there is no previous value to revert to) — returns 422.
Restoration behaviour by event type: Conflict responses:
  • 409 — the item or rate is already active (was restored elsewhere), or a live rate already occupies the slot the deleted option would restore into.
  • 422 — the event is a creation, the event has no restorable values, a deleted item has a retired supplier rate that is no longer valid, or the status field would be set to "deleted" by the restore.
Audit trail: A successful restore records a new audit event on the item. The original history is never overwritten.

Tracked fields

The audit log captures only the fields in the tracked field lists, not every column: Rate items: name, categoryId, category, rateGroup, status, bulkingPercent, compactionPercent, maxTonnes, maxCubicMetres. Rate options: scope, supplierId, locationId, sourceItemCode, sourceItemName, notes, currency, condition, basis, basisMultiplier, unit, conversionFactor, costRate, provenance, validFrom, validTo, status, needsReview. Options created before migration 0045 may carry rateSourceId and rateSourceLocationId keys in their changes object. These reference tables from the previous global service that no longer exist. The API serves those keys unchanged; restoration refuses to recreate a deleted option that carries only these legacy keys (no scope value) because the supplier identity cannot be resolved.

Contractor copy

A contractor company can copy its Rates Library into a company it works in, and later refresh those rows one way, from the contractor company to the company. api-global’s orchestrator drives the job; src/lib/contractor-copy.ts implements the api-rates side.
Only the contractor-copy service login may call them: scope contractor-copy:export with the contractor company as the token’s organisation, or contractor-copy:import with the receiving company. An export token can never write and an import token can never read. Any other route answers that login with 403 contractor_copy_only. Each call runs under one organisation’s RLS; rows travel through the orchestrator. What travels: Rates Library items (active and inactive, nothing deleted), their current options (active, open validTo) and the pricing rules those options own. Retired options, project rates, day-rate schedules and history never travel.

Provenance (migration 0062)

Copied items, options and pricing rules carry copiedFromOrgId, copiedFromId and copiedAt. Rows the company created itself have none. An item is found again by provenance, and an option by its slot (item, scope, supplier, location, condition), because a price change at home usually supersedes the option with a new id. A retried copy therefore never inserts twice, and a refresh updates in place.

Export

The body takes optional supplierIds (keep only those suppliers’ prices), rateCategoryIds (keep only items in those categories), locationIds (keep only the contractor company’s own prices at those locations, plus its all-locations prices), cursor and limit (default 500, maximum 1,000 items). The response is { items, options, pricingRules, nextCursor }, paged by item id. Each page carries every option and rule for its items.

Import

The body carries jobId, step (rates.items_options or rates.pricing_rules), mode (copy or refresh), optional dryRun, sourceOrgId, optional sourceOrgName and requestedByUserId, idMaps (rateCategory, party, location, rateItem, rateOption), optional locationMap (copy only: which home location’s prices each copied location takes) and rows. A sourceOrgId equal to the receiving company returns 422 contractor_copy_same_company.
  • An item is matched by provenance, then adopted by name (the company’s own item is kept and the contractor’s prices attach to it), then created.
  • Options land by slot, with suppliers and locations remapped through idMaps. A slot the company priced itself is never overwritten.
  • mode: refresh updates copied rows whose tracked fields differ. dryRun: true runs the same code in a transaction that is rolled back, so the preview matches what applying it would do.
The response is { idMap, counts, skipped, diff, dryRun }. idMap holds rateItem, rateOption and pricingRule. counts reports created, matched, updated, unchanged and skipped. diff lists changed and new rows and is always filled. A must-pick option (priced by steps or by a rate table) has no cost, and its rules travel with it on copy. When a refresh finds that the home option has stopped being must-pick and has a cost again, it deletes the copied option’s stepped or matrix base rule in the same transaction, because a costed option that kept one would fail rates_rate_options_stepped_cost_check at commit. A refresh that brings steps over an option that had a minimum or a loading clears those rules first, because a stepped base cannot carry them. Every write records rate history with source copy or refresh, the contractor company as actorOrgId and actorOrgName, and requestedByUserId as changedBy.

Backend contract tests

Backend callers — api-assistant, api-estimate, and api-global — read rate items by ID and by search filter. The contract test suite pins the exact request validation and response JSON those callers rely on. Tests live in test/requests.backendContract.unit.test.ts and use the *.backendContract.unit.test.ts suffix. Run them with:
CI runs the named Backend API contracts (receiving requests and responses) step. A failure fails the repository’s CI job. The tests also match the normal unit-test discovery pattern so they run with the standard test suite. The dedicated configuration runs one worker and no database setup. It does not skip when a database is unavailable. Locally:
Run one repository at a time.

Test boundaries

Route tests register production handlers and execute actual validation, error mapping, and JSON serialisation. Only the persistence and external service ports are simulated. Fixtures are not prebuilt HTTP responses. Request-schema tests pin validation rules only and do not claim to exercise successful writes. SQL filtering, RLS, JWT signature verification, and distributed workflows remain the responsibility of the existing integration and security suites. The suite does not validate a sibling repository’s current client implementation or automatically trigger its CI.

Covered contracts

Rate item response shape

The GET /api/rates/rate-items/:id response omits organisationId. This is a storage-only field that backend callers must not read from the wire. The pinned response shape is:
assumptions is always an array. An empty array means the item has no assumptions.

Create request schema

POST /api/rates/rate-items validates the request body through the exported rateItemCreateBodySchema. Key rules:
  • name is required and must contain non-whitespace characters. It is trimmed before storage.
  • categoryId is required and must be a UUID.
  • status defaults to active when omitted. Accepted values are active, inactive, and deleted.
  • maxTonnes, maxCubicMetres, bulkingPercent, and compactionPercent must be numbers when present; string representations are rejected.

Receiving-side contract pairs

The named contracts below link the receiving tests in this service to the sending tests in each caller. The existence of a sending test does not imply that every endpoint in the pair is covered by the receiving suite.

BC-01: stralos-api-assistant → stralos-api-rates

Requests: GET /api/rates/rate-items/:id, GET /api/rates/rate-items/:id/options, GET /api/rates/rate-summaries, POST /api/rates/rate-items. Sender tests: stralos-api-assistant/test/readTools.test.ts, stralos-api-assistant/test/drafts.test.ts. Dedicated mocked-fetch unit tests check rate-option paging, nullable locations, and rate-summary mapping. Draft integration tests assert the POST URL, body, and forwarded bearer token. Coverage limits: The receiver suite covers the item GET and create-body schema only. Option and summary responses and successful POST responses are outside that suite. Sender rateItemBody fixtures still use non-UUID IDs, so those mocks do not prove that the receiver will accept the request.

BC-08: stralos-api-estimate → stralos-api-rates

Requests: GET /api/rates/rate-items, GET /api/rates/rate-items/:id, option and companion reads, project override and settings reads. Sender tests: stralos-api-estimate/test/reResolvePrices.test.ts, stralos-api-estimate/test/helpers/reResolveStub.ts. DB-backed sender tests use a simulated HTTP peer and check bearer forwarding, numeric and metadata validation, pagination failure, and 502 handling. Coverage limits: These tests require Postgres and a test fixture listener. Receiver contracts cover rate-item reads only, not every option, companion, override, or settings response this fan-out consumes.

BC-10: stralos-api-global → stralos-api-rates

Requests: GET /api/rates/rate-items?categoryId&includeDeleted=true&start&length. Sender tests: stralos-api-global/test/rateItemUsageClient.unit.test.ts. Dedicated mocked-fetch unit tests assert exhaustive pagination, query parameters, bearer forwarding, live and deleted classification, and failure and response validation. Coverage limits: Receiver tests pin the list envelope and accepted parameters. SQL filtering is not proved by the receiver unit fixture; retain database integration coverage. Both suites remain independently maintained.

Sending-side contract pairs

The named contracts below link the outbound HTTP calls this service makes to their corresponding receiver-side documentation.

BC-05: stralos-api-rates → stralos-api-global

Requests: GET /api/global/projects/:id, GET /api/global/projects/:id/location-routes?locationIds, POST /api/global/projects/:id/location-routes/resolve, delegated category, location, party, and company reads, and batch resolution via POST /api/global/rate-references/resolve-batch. Sending implementation: src/lib/project-rate-context-client.ts. Sender tests: test/projectRateContext.test.ts. DB-backed sender route tests exercise real peer clients through URL-dispatched fetch stubs, including locationIds query and body handling and malformed and unavailable peer responses. Coverage limits: These tests can skip locally without Postgres and are not a standalone client unit suite. Receiver contracts cover project detail and cached routes; resolve has invalid-input tests only. Catalogue and source-pair contracts need separate matching receiver coverage.

BC-06: stralos-api-rates → stralos-api-activities

Requests: GET /api/activities/activities/:id. Sending implementation: src/lib/day-rate-activity-client.ts. URL resolution: resolveActivitiesApiUrl reads ACTIVITIES_API_URL first. If that is unset, it reads the SSM parameter named by ACTIVITIES_API_URL_SSM_PARAMETER. If neither is set, it throws a configuration error at call time:
There is no localhost fallback. stralos-local sets ACTIVITIES_API_URL directly. Every deployed Lambda has ACTIVITIES_API_URL_SSM_PARAMETER set by Terraform. A missing URL surfaces as a configuration error, not as “Activities service is unreachable”. Sender tests: test/dayRateScheduleLines.test.ts, test/helpers/harness.ts. DB-backed mapping tests exercise the sender against activity fixtures, including unavailable and foreign activities, malformed success responses, unit mismatch, and excluding optionalCost from included costing. The test harness sets ACTIVITIES_API_URL explicitly. Coverage limits: The receiving suite checks real activity DTO construction and separate included and optional numeric costs. Sender tests are integration tests with peer fixtures, not dedicated no-DB client contract tests.

BC-07: stralos-api-rates → stralos-api-rate-extract

Requests: POST /rate-extract/uploads, POST /rate-extract/ingest. Sending implementation: src/lib/rate-extract-client.ts. URL resolution: see the api-rate-extract URL resolver under Peer URL configuration. Sender tests: test/rateImportParses.test.ts, test/rateImportAnalyses.test.ts, test/selectionReuse.test.ts. DB-backed import tests mock ingest responses and exercise downstream success, refusal, and retry handling through the real ingestion client. Coverage limits: No dedicated requestUpload or ingestUpload unit suite exists. The ingest stub routes by URL but does not comprehensively assert the outgoing body, shared-secret header, or timeout budgets. Receiver tests cover upload and ingest validation and JSON responses in both HTTP and Lambda adapters.

Service login confinement

stralos-api-rates gates every incoming service token with an explicit confinement layer before route handlers run (security review 2026-10-03). Three kinds of confinement apply.

Read-only service logins (B1)

Three service principals hold a rates:read scope and were previously able to call any route, including mutating ones. Each is now confined to the specific reads it is known to make: Any request from one of these subjects outside its allowed route, or with the wrong method, or without a valid org, returns 403 service_read_only before the handler runs. Before this confinement, a mutating request from one of these principals would fail only by accident — the service subjects are not UUIDs, so the changed_by column stamp would throw. The confinement makes the boundary structural and explicit.

Service write gate (B1)

No service principal may call a mutating method on a route unless that route has explicitly opted it in. Today only the estimate-rates login’s writes are opted in, plus the contractor-copy login on its two routes (see Contractor copy). Any other service principal attempting a non-GET/HEAD/OPTIONS request is refused with 403 service_write_forbidden.

JWKS resolver hardening (B2)

The JWKS resolver now enforces a 30-second global cooldown on all refetch attempts, including failed ones. Before this change the per-kid negative cache was keyed by the token’s own kid, so requests carrying many distinct fabricated kid values could each trigger a separate unauthenticated fetch through the shared NAT gateway. The new resolver gates all kids on a single clock, coalesces in-flight refreshes, and keeps serving the last successfully fetched key set when a refresh fails rather than returning 401 to every request until a refresh succeeds. See JWKS caching in consumers.

Per-organisation import queue fair share (B3)

The rates import queue is shared across all organisations and drained by a worker capped at a small number of concurrent invocations. A new import request (parse, survey, analysis, or extraction job start) is refused with 429 rate_import_busy when the requesting organisation already has two or more jobs in the queued or running state. Rows that have been in one of those states for more than one hour stop counting, so a crashed worker cannot lock an organisation out of importing indefinitely. The limit is two concurrent in-flight imports, which allows two people in the same company to import at the same time without any one organisation filling the whole queue. See Bounded input for the existing per-route rate limits and the cap table.

Required startup configuration

buildApp refuses to start without AUTH_JWKS_URL, in every environment; see Authentication deployment requirements. Terraform passes var.auth_jwks_url to both the API Lambda and the rate-import worker through local.lambda_environment.

Peer URL configuration

stralos-api-rates resolves its peer base URLs from environment variables or SSM parameters. No resolver has a localhost default: a missing configuration throws a configuration error rather than calling a hard-coded port. The api-activities resolver is described under BC-06 above; the others follow.

api-global URL (src/config/global-api-url.ts)

resolveGlobalApiUrl resolves in order:
  1. STRALOS_GLOBAL_URL — set by stralos-local for local development.
  2. STRALOS_GLOBAL_URL_SSM_PARAMETER — the SSM parameter name this repo’s terraform sets on every deployed function that calls api-global.
If neither is set, it throws: "api-global URL is not configured: set STRALOS_GLOBAL_URL or STRALOS_GLOBAL_URL_SSM_PARAMETER". The five global clients (project authorisation, day-rate identity, project-rate context, rate references, suppliers) resolve the URL before their first fetch, so a missing configuration surfaces as a configuration error rather than "Global service is unreachable".

api-estimate URL (src/config/estimate-api-url.ts)

resolveEstimateApiUrl resolves in order:
  1. ESTIMATE_API_URL — set by stralos-local for local development.
  2. ESTIMATE_API_URL_SSM_PARAMETER — the SSM parameter name this repo’s terraform sets on every deployed function that calls api-estimate.
If neither is set, it throws: "estimate API URL is not configured: set ESTIMATE_API_URL or ESTIMATE_API_URL_SSM_PARAMETER". The delivery variant resolveEstimateApiUrlForDelivery returns a typed result rather than throwing, so the re-price outbox can record a not_configured skip. It returns kind: "not_configured" in all environments when neither variable is set, when the SSM parameter does not exist, or when the parameter holds no usable value. It returns kind: "unavailable" when the SSM read fails or times out. See Re-price outbox for how callers handle each outcome.

api-rate-extract URL (src/lib/rate-extract-client.ts)

The rate-extract client resolves in order:
  1. RATE_EXTRACT_URL — set by stralos-local for local development and by the test harness.
  2. The SSM parameter named by RATE_EXTRACT_URL_SSM_PARAMETER — Terraform sets this on the API Lambda and the rate-import worker, the two functions that reach this client.
If neither is set, it throws "rate-extract URL is not configured: set RATE_EXTRACT_URL or RATE_EXTRACT_URL_SSM_PARAMETER". The URL is resolved before the fetch’s try block, so a missing configuration surfaces as a configuration error rather than "the file service is unreachable".

Rate category projection

stralos-api-rates keeps a local projection of api-global’s rate categories in rates_rate_category_projection (migration 0050). Rate reads and writes resolve categories from this table only. GET /api/rates/rate-items, GET /api/rates/rate-items/:id and GET /api/rates/rate-summaries make no peer call for category data, and item and option writes validate categoryId, allowed units and physics licensing against the projection. The former per-request classification reconcile has been removed.

Event consumer

api-global publishes a RateCategoryChanged v1 event on every category create, update and soft delete, carrying the category’s full state after the write. The consumer in src/events/rateCategoryChanged.ts reads the rates-rate-category SQS subscription queue and applies each state through applyCategoryStates. For an active category it also copies name and parentGroup onto the organisation’s rate items whose copy has drifted. A deleted category leaves its items unchanged.
  • Newer versions only. Delivery is at-least-once and unordered, so an event applies only when its categoryVersion is greater than the stored one. An older or equal event is acknowledged without change.
  • Dedupe and effect in one transaction. handleEvents inserts the (consumer, eventId) row into processed_events in the handler’s transaction. A duplicate is acknowledged without running the handler; a handler error rolls both back so the event is retried.
  • Owner pool, tenant-scoped. The consumer uses the owner database pool under withTenant for the event’s organisation. The runtime role’s restrictive project_membership policy would hide project-only items, and a category rename must reach them too. The owner is still subject to every tenant-isolation policy, and the handler refuses an event whose organisation differs from its transaction’s.

Backfill and read-through

The projection starts empty and api-global publishes only on writes. The first request that needs an organisation’s catalogue therefore reads it once from api-global with the caller’s bearer, seeds the projection, and records the organisation in rates_rate_category_projection_seeds. The same read-through runs when a write names a categoryId the projection does not hold as active, so a category created moments earlier is not refused while its event is in flight. A service caller, such as the estimate-reprice login, never seeds. Its reads need no category data, and a missing category label reads “Unavailable category”.

Retention and offboarding

A dedicated retention Lambda (retention-lambda.handler, deployed as stralos-<env>-au-nz-api-rates-retention) runs weekly on cron(0 15 ? * SUN *). It hard-deletes every api-rates row for an organisation that api-global has reported as disabled for ORG_OFFBOARDING_PURGE_AFTER_DAYS (default 90) days. The clock is kept in rates_org_offboarding (migration 0052), which is not purged. The purge runs as the platform role, re-checks the organisation’s status immediately before deleting, and deletes in one transaction with an explicit organisation predicate on every statement. Day-rate tables stay closed to the platform role and are deleted through the owner function rates_org_purge_day_rates(org) in the same transaction. A run with any failed organisation throws after trying the rest, which raises the Lambda’s Errors alarm. The same Lambda answers the support fan-out’s { "kind": "erase-user", "userId", "requestId" } event. api-rates stores only opaque actor ids, so it always reports empty counts. See Data retention for the shared offboarding clock, status guard, per-user erasure and operations.

Adding new contracts

Add a new [backend-contract]-tagged test for each endpoint that gains a backend caller. For each endpoint, cover:
  • A valid request with a representative response.
  • Required and optional parameters.
  • Wrong types and out-of-bounds values.
  • Status codes.
  • Non-empty response data.
  • Null and omitted fields.
  • Error response shapes.
Keep expected values explicit. Do not derive expectations from the serialiser being tested. A contract change should require deliberate review of callers — not a blanket snapshot update. For a change to a listed method, path, parameter, required header, status code, or response field: use the same contract ID in the sender and receiver notes; update receiver tests to pin accepted and rejected inputs and actual response JSON; update sender tests to assert the real outgoing request and its handling of valid, missing, null, malformed, and error responses. Review and run both repositories’ relevant suites.