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 dedicatedmint-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: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 instralos-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 fromapi-rates, work items fromapi-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 theapi-global-mint:mint-for-documents-workerLambda alias via IAM — no shared secret. In local development, it posts to the loopback mint route usingSERVICE_MINT_SECRET. Neither transport has a default; missing configuration throwsServiceMintNotConfiguredErrorbefore 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 againstapi-globalto verify the estate responds, and makes one probe read againststralos-admin-api(GET /api/admin/service/probe) to verify the admin surface is reachable. Theadmin:probe-readscope 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 againststralos-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 fromapi-globalfor a single tenant org per unit of work. Audience and scope are intentionally minimal:stralos-api-globalandprojects:readonly.estimate-reprice:api-estimate’sProjectRatesChangedconsumer, 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 againstapi-globaland throughapi-rates, which forwards the bearer. Onapi-globalthe 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, andGET /api/global/parties/:id. Any other route returns403 reprice_read_onlybefore 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 inapi-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 tostralos-api-ratesand never reachesapi-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 onapi-global, each needing its own scope: read one member’s email address and name (GET /api/global/service/organisations/:orgId/users/:userId/contactand the matchingHEAD,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 tostralos-api-global; any other route returns403 estimate_notify_onlybefore a handler runs. See Service-only endpoints and Notifications.estimate-name-sync:api-estimate’sPartyChanged/LocationChangedevent consumer and daily name reconcile. Both have no user context, so their api-global reads — the organisation’s project ids andPOST /api/global/rate-references/resolve-batchfor current supplier and location names — carry a token minted for this caller.api-globalconfines the token to exactly those two routes; any other route returns403 name_sync_read_only. The daily reconcile also uses anestimate-repricetoken (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 fromapi-global: the project’s office and the other projects run from that office. The token is audience-clamped tostralos-api-globaland confined toGET /api/global/service/organisations/:orgId/projects/:projectId/office-projects(and the matchingHEAD). Any other route returns403 office_history_read_onlybefore a handler runs. See Service-only endpoints and Tender check.*-offboardingcallers (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 callGET /api/global/service/organisation-status; any other route returns403 org_status_read_onlybefore 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 ownorg, never a parameter, so a token minted for one organisation cannot ask about another.
- Required scope:
global:org-status-read(the*-offboardingcallers). Any other caller receives403. - 200:
{ "organisationId": "<uuid>", "status": "<status>" } - 404: the organisation has no row in
organisations.
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(theestimate-notifycaller). Any other caller receives403 forbidden. - Path constraint:
:orgIdmust be the token’s own organisation. A mismatch receives403 organisation_mismatch, so the caller cannot ask about another organisation’s members. - 200:
{ "email": "<address>", "name": "<name>" }, withCache-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.
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 typeddetails; api-global writes the title and body.
- Required scope:
global:notifications-write(theestimate-notifycaller). Any other caller receives403 forbidden; an:orgIdother than the token’s own receives403 organisation_mismatch. - The project-access kinds are raised by api-global itself and are refused with
400 kind_not_allowed. 404 recipient_not_foundorproject_not_found;409 recipient_cannot_open_projectwhen 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.
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(theestimate-notifycaller). Any other caller receives403 forbidden; an:orgIdother than the token’s own receives403 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_foundfor 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(theestimate-office-historycaller). Any other caller receives403 forbidden. - Path constraint:
:orgIdmust be the token’s own organisation. A mismatch receives403 organisation_mismatch. - 200:
{ "organisationId", "projectId", "office": { "locationId", "name" } | null, "projects": [{ "projectId", "name", "jobNumber", "status", "archivedFrom" }], "truncated" }, withCache-Control: no-store.projectslists the office’s other projects, newest first, up to 2,000, withtruncatedset when there were more. It excludes the requested project andleadprojects, and includes archived projects. Entries carry names, job numbers and statuses only: no client and no address. office: null: the project has no office, andprojectsis empty.- 404
project_not_found: the project is not one of the organisation’s.
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:
AUTH_SIGNING_KEY_JWK— an inline JWK or ordered JWK array.AUTH_SIGNING_KEY_SSM_PARAMETER— an encrypted AWS SSM parameter.AUTH_SIGNING_KEY_FILE— a local generate-or-load file, defaulting to.auth-signing-keys.json.

