Skip to main content
stralos-api-global is the sole issuer of service tokens. It signs short-lived EdDSA tokens whose audiences, scopes, and organisation context are limited by a static caller allowlist. Browser clients never receive service tokens.

Mint transports

Deployed environments

The deployed mint is a distinct Lambda handler that is not attached to the public API Gateway. Each authorised caller has a dedicated mint-for-<caller> Lambda alias and an IAM policy scoped to that alias. The handler maps the alias qualifier to the caller identity before applying the allowlist.

Local development

Local callers use:
The request body can contain org, aud, and scope. api-global maps the presented secret to a caller using SERVICE_MINT_SECRETS and compares secrets in constant time. The route is mounted only when local signing keys and at least one caller secret are configured.

Caller allowlist

A tenant caller must provide a valid organisation UUID and cannot request the platform scope. A platform caller must omit org and include the platform scope. A caller may request a subset of its allowed audiences and scopes; omitting either uses its full allowance. Unknown callers receive 401. An authenticated caller requesting authority outside its allowlist receives 403.

Caller notes

  • documents-worker — the area-jobs worker in stralos-api-documents. Mints a token per unit of work scoped to the document’s organisation for the context reads that precede AI processing (rate items from api-rates, work items from api-estimate). The token is cached per org and re-minted near expiry. Minting uses the estate’s dual transport: in deployed environments (SERVICE_TOKEN_TRANSPORT=lambda), the worker invokes the api-global-mint:mint-for-documents-worker Lambda alias via IAM — no shared secret. In local development, it posts to the loopback mint route using SERVICE_MINT_SECRET. Neither transport has a default; missing configuration throws ServiceMintNotConfiguredError before any context read attempt, so a misconfigured worker fails with the configuration error rather than reading its peers unauthenticated. A configured mint that fails degrades: the context read proceeds without a token, the peer rejects it, and the area run gets an empty context — the pipeline does not stop because a sibling is down.
  • ci-journey — the release journey gate. Makes authenticated reads against api-global to verify the estate responds, and makes one probe read against stralos-admin-api (GET /api/admin/service/probe) to verify the admin surface is reachable. The admin:probe-read scope is held by this caller alone; no other allowlist entry can reach the admin API. The probe returns only deploy-time configuration and touches no tenant, operator, or catalogue data. The caller makes no reads against stralos-api-rates; its only former api-rates read (GET /api/rates/project-rate-overrides) and that route are both retired. The caller is read-only and audience-clamped.
  • global-lifecycle — api-global’s lifecycle orchestrator. Drives project-lifecycle state transitions (accept, reject, reopen, archive, unarchive, admin set status) on the estimate side for one tenant org per unit of work.
  • estimate-lifecycle — api-estimate’s lifecycle orchestrator. Drives the project-side transitions (submit tender, revise tender) on the global side for one tenant org per unit of work.
  • api-scheduler — the crew-scheduling service’s sole outbound S2S read (crew-scheduling M0). Reads project statuses from api-global for a single tenant org per unit of work. Audience and scope are intentionally minimal: stralos-api-global and projects:read only.
  • estimate-reprice: api-estimate’s ProjectRatesChanged consumer, which re-prices a project when no user is present. It mints this token for the event’s organisation and makes the same reads a synchronous reprice makes, directly against api-global and through api-rates, which forwards the bearer. On api-global the token is confined to five read routes: GET /api/global/organisation-settings, GET /api/global/projects/:id/location-routes, POST /api/global/rate-references/resolve-batch (a batch read carried as a POST), GET /api/global/locations/:id, and GET /api/global/parties/:id. Any other route returns 403 reprice_read_only before a handler runs. The location-routes read sets the project-work GUC to the path’s project ID, so the token can see that one project without a project membership.
  • estimate-rates: api-estimate’s caller for estimate-scoped project rates (PRD 210 §9.2). It mints this token with no user present for the service-only estimate-rates routes in api-rates: stamping a project’s rate rows with its estimate at creation and backfill (POST /api/rates/estimate-rates/backfill), moving the project’s default estimate when the primary changes (PUT /api/rates/estimate-rates/defaults), copying one estimate’s project rates to another at duplication or saved-version creation (POST /api/rates/estimate-rates/copy), restoring a saved version’s rates (POST /api/rates/estimate-rates/restore), and discarding an abandoned copy’s rates (POST /api/rates/estimate-rates/discard). The token is audience-clamped to stralos-api-rates and never reaches api-global.
  • estimate-notify: api-estimate’s notification caller, used where no user is present (the variation email outbox and the notification flusher) and for tender approvals. It mints this token for the organisation and may make three calls on api-global, each needing its own scope: read one member’s email address and name (GET /api/global/service/organisations/:orgId/users/:userId/contact and the matching HEAD, global:user-contact-read), write one in-app notification (POST /api/global/service/organisations/:orgId/notifications, global:notifications-write), and add a tender’s chosen approver to the project (POST /api/global/service/organisations/:orgId/projects/:projectId/approvers, global:project-approver-add). The token is audience-clamped to stralos-api-global; any other route returns 403 estimate_notify_only before a handler runs. See Service-only endpoints and Notifications.
  • estimate-name-sync: api-estimate’s PartyChanged/LocationChanged event consumer and daily name reconcile. Both have no user context, so their api-global reads — the organisation’s project ids and POST /api/global/rate-references/resolve-batch for current supplier and location names — carry a token minted for this caller. api-global confines the token to exactly those two routes; any other route returns 403 name_sync_read_only. The daily reconcile also uses an estimate-reprice token (separately minted) for the reference fill’s api-rates read.
  • estimate-office-history: api-estimate’s Tender check office price comparison. The comparison reads every tender from the checked project’s office, whatever the running person can open, so it cannot use their session token. It mints this token for the estimate’s organisation and reads one thing from api-global: the project’s office and the other projects run from that office. The token is audience-clamped to stralos-api-global and confined to GET /api/global/service/organisations/:orgId/projects/:projectId/office-projects (and the matching HEAD). Any other route returns 403 office_history_read_only before a handler runs. See Service-only endpoints and Tender check.
  • *-offboarding callers (assistant-offboarding, estimate-offboarding, rates-offboarding, activities-offboarding, scheduler-offboarding, documents-offboarding, billing-offboarding): the weekly retention Lambda in each of those services. Before it hard-deletes an organisation’s data, it mints a token for that one organisation and reads the organisation’s status. The token may only call GET /api/global/service/organisation-status; any other route returns 403 org_status_read_only before a handler runs. See Data retention.

Service-only endpoints

These routes are not part of the product API. Each is gated by a specific service-token scope.

GET /api/global/service/organisation-status

Returns the status of the organisation the service token was minted for. The organisation is always the token’s own org, never a parameter, so a token minted for one organisation cannot ask about another.
  • Required scope: global:org-status-read (the *-offboarding callers). Any other caller receives 403.
  • 200: { "organisationId": "<uuid>", "status": "<status>" }
  • 404: the organisation has no row in organisations.
The route answers for a disabled organisation exactly as for an active one, because that is its purpose: the mint does not check organisation status, the billing access guard skips service tokens, and the query does not filter on status. Callers purge only on an explicit disabled; any other status, a 404, or an error keeps the data.

GET /api/global/service/organisations/:orgId/users/:userId/contact

Returns the email address and name of one member of the token’s organisation. api-estimate’s variation email outbox uses it to address a notice when no user session is present.
  • Required scope: global:user-contact-read (the estimate-notify caller). Any other caller receives 403 forbidden.
  • Path constraint: :orgId must be the token’s own organisation. A mismatch receives 403 organisation_mismatch, so the caller cannot ask about another organisation’s members.
  • 200: { "email": "<address>", "name": "<name>" }, with Cache-Control: no-store.
  • 404 not_found: the user ID is not a UUID, the person is not an invited or active member of the organisation (including a member of another organisation or a person who has left), or the user is disabled. Every case returns the same body, so the response reveals nothing about who exists outside the organisation.
A user ID that is not a UUID, such as the local placeholder api-estimate can record as a creator in local development, returns 404 without a database read.

POST /api/global/service/organisations/:orgId/notifications

Writes one in-app notification, which api-global also emails. api-estimate sends the kind and typed details; api-global writes the title and body.
  • Required scope: global:notifications-write (the estimate-notify caller). Any other caller receives 403 forbidden; an :orgId other than the token’s own receives 403 organisation_mismatch.
  • The project-access kinds are raised by api-global itself and are refused with 400 kind_not_allowed.
  • 404 recipient_not_found or project_not_found; 409 recipient_cannot_open_project when the recipient is not on the project team.
  • Idempotent on the recipient and sourceKey: 201 { id, created: true } the first time, 200 { id, created: false } after.
See Notifications.

POST /api/global/service/organisations/:orgId/projects/:projectId/approvers

Puts a tender’s chosen approver on the project team as a member when they are not on it, and answers { userId, added }.
  • Required scope: global:project-approver-add (the estimate-notify caller). Any other caller receives 403 forbidden; an :orgId other than the token’s own receives 403 organisation_mismatch.
  • The estimator’s own token must ride x-forwarded-user (403 forwarded_user_required), and the estimator must be on the project (403 project_access_required).
  • Business people only, never contractors (404 person_not_found). 404 project_not_found for a project outside the business.

GET /api/global/service/organisations/:orgId/projects/:projectId/office-projects

Returns a project’s office and the other projects run from that office. api-estimate’s Tender check uses it to compare an estimate with the office’s recent tenders.
  • Required scope: global:office-history-read (the estimate-office-history caller). Any other caller receives 403 forbidden.
  • Path constraint: :orgId must be the token’s own organisation. A mismatch receives 403 organisation_mismatch.
  • 200: { "organisationId", "projectId", "office": { "locationId", "name" } | null, "projects": [{ "projectId", "name", "jobNumber", "status", "archivedFrom" }], "truncated" }, with Cache-Control: no-store. projects lists the office’s other projects, newest first, up to 2,000, with truncated set when there were more. It excludes the requested project and lead projects, and includes archived projects. Entries carry names, job numbers and statuses only: no client and no address.
  • office: null: the project has no office, and projects is empty.
  • 404 project_not_found: the project is not one of the organisation’s.
The route reads through the office_peer_projects(p_org, p_project) SECURITY DEFINER function (migration 0093). It refuses (42501) a p_org that does not match the transaction’s tenant GUC, filters on its arguments on both sides of the office join, and is still bound by tenant_isolation under FORCE RLS, so it cannot return another organisation’s projects.

Claims and verification

Service tokens contain: Receiving services verify the EdDSA signature and issuer, require the token’s audience to include their configured service name, and validate the claims. Service tokens must not contain role. A null organisation is valid only with the platform scope. Successful verification exposes the service identity, organisation context, and scopes on the request auth context. What a verified service token may then do is confined per receiving service: stralos-api-documents uses a per-route allowlist and stralos-api-estimate a mutation guard, both described below, and stralos-api-rates has its own service login confinement. Elsewhere, route handlers remain responsible for enforcing the scope appropriate to an operation.

Per-route scope enforcement

Passing the audience check is not sufficient for a service token to reach a route. stralos-api-documents declares a list of serviceRoutes — the exact set of route patterns and HTTP methods that a service token may use, each paired with the exact scope required for that route. A service token is refused with 403 and code service_scope_required on any route that is not in that list, or where the token’s scope does not include the declared scope for that route. This means the audience check alone does not determine what a service token may do. A token minted for a service with multiple scopes (such as documents:portal-read) cannot reach routes that require a different scope, even though the audience admits it to the service. The stralos-api-documents service declares these service routes: All other routes in the Documents service return 403 for any service token, regardless of audience or scope.

Service token mutation guard

Service tokens are denied every mutating HTTP method (POST, PUT, PATCH, DELETE) by default. A route that a service legitimately drives must opt in by declaring config.serviceMutationScope with the single exact scope required. The auth plugin then checks that the incoming service token’s scope array includes that value. A service token that does not carry the required scope, or that reaches a mutating route with no config.serviceMutationScope set, receives 403 with code: "forbidden" before the route handler runs. Read requests (GET, HEAD, OPTIONS) are unchanged. Existing RLS and project-scope gates continue to confine what a service token can read. The only service-driven writes into stralos-api-estimate are the lifecycle transitions driven by global-lifecycle. Those routes declare config.serviceMutationScope: "lifecycle:transition", and the global-lifecycle caller’s token carries scope: ["lifecycle:transition"]. All other callers that target stralos-api-estimate (documents-worker, pricing-materialise, estimate-reprice, estimate-rates, estimate-name-sync) make read-only calls and carry no mutation scope.

Signing keys and rotation

api-global resolves private signing key material in this order:
  1. AUTH_SIGNING_KEY_JWK — an inline JWK or ordered JWK array.
  2. AUTH_SIGNING_KEY_SSM_PARAMETER — an encrypted AWS SSM parameter.
  3. AUTH_SIGNING_KEY_FILE — a local generate-or-load file, defaulting to .auth-signing-keys.json.
The first key signs new tokens. Public keys are published through the JWKS endpoint. See JWKS caching in consumers for the full resolver behaviour, including the global refetch cooldown and keep-last-good policy.