stralos-admin-api is the management-plane API for organisations, users,
memberships, platform configuration, and new-organisation seed templates. It is a
standalone Fastify service with its own Postgres store.
Capabilities
Worker schedules
The Terraform stack deploys separate Lambda entry points for the API, database
migration, event worker, and operator operations. API Gateway
exposes only the operator API. The operator function requires explicit
IAM invocation and has no public route or recurring trigger.
The Cognito and invite schedules are separate EventBridge rules rather than modes
inside the flush Lambda. This keeps the outbox flusher from being blocked by a
Cognito outage, and prevents the pool reconciler from running at flush frequency.
OpenAPI contract
The admin API publishes a machine-readable OpenAPI 3.1 document atGET /api/admin/openapi.json.
This endpoint is unauthenticated (no operator session required), built once at startup, and returns
byte-stable bytes per deploy. It is a documentation contract only — it is explicitly excluded from
the copilot tool substrate because the described operator surface accepts operator sessions, while
the copilot acts as a tenant user. The separate organisation self-service routes are not part of
this document.
The document covers 39 operations across nine resource groups:
Success response bodies are currently phased (
x-stralos-response-status: undocumented); the
operation IDs and request schemas are stable and machine-readable. The security scheme is
operatorAuth (HTTP bearer JWT); every protected operation returns a 401 when a valid operator
session token is absent. There is no per-route 403 — any active operator may call any operation.
The document version (info.version) is 1.0.0. This is the descriptor-contract version, managed
independently of the service or deploy version; it advances only on an intended contract change.
Authentication boundary
The admin API separates these authenticated channels:- Operators — Stralos employees. Sign-in uses Google OAuth2 (authorisation code + PKCE, server-side redemption) restricted to the
stralos.co.nzGoogle Workspace domain. The service verifies the JWKS signature, RS256 only, the allow-listed issuer, the exact audience, the ceremony nonce,email_verified, and thehdWorkspace claim alongside the address domain. A successful ceremony returns a short-lived operator session JWT signed by the admin service’s own keypair (audiencestralos-admin-<env>); a Google ID token is never accepted as a session. This preserves logout, immediate operator disable, and revocation — none of which a foreign token can express. MFA is now an organisational control: the service delegates second-factor enforcement to Google Workspace 2SV policy and cannot verify which factors were used. In dev, test, and prod,google_auto_provision_operatorsis enabled: any verifiedstralos.co.nzWorkspace account is seeded as an operator row on first sign-in, audited asoperator.provisioned. The Workspace domain gate is the bounding control. The bootstrap CLI remains available for environments where auto-provisioning is off. While the console remains open, the browser renews its bearer token viaPOST /api/admin/auth/refreshwithout repeating the Google ceremony. Each renewal extends the session by 30 minutes, clamped to 12 hours from the original sign-in. - Runtime report flusher — a machine principal. Reports use a dedicated JWT type (
stralos-report+jwt) verified against a per-region JWKS. - Organisation members and administrators — tenant end-users. These callers present an ordinary api-global access token (
typ: "user", audiencestralos-api) verified byauth/orgAdminToken.ts. The user-management routes (/api/admin/org/users) additionally re-derive administrator authority from the admin store viaauth/orgAdmin.ts. The organisation switcher route (GET /api/admin/org/organisations) makes no role check beyond a valid token; any active member may call it. - Release journey gate — a machine principal used by the promotion pipeline (PRD 03). It presents a service token issued by api-global (
typ: "service", audiencestralos-admin-api, scopeadmin:probe-read) and is verified against a per-region JWKS configured byADMIN_SERVICE_TOKEN_SOURCES. This principal can reach only the service probe route. It reads no tenant row, no operator row, and no catalogue item. - Login relays — api-login sends signed, body-bound commands for self-service signup completion, membership acceptance, and session revocation. The three command families share a signing key but use distinct token types and audiences; no family’s captured command is valid as another.
GET /api/admin/openapi.json are
unauthenticated. Operator routes require a live operator session. Organisation self-service routes
(/api/admin/org/*) are exempt from the operator hook and require a valid org user token instead. The service probe routes (/api/admin/service/*) are exempt from the operator hook and require a valid service token instead.
The org self-service surface is not covered by the openapi.json descriptor contract; it uses a separate authentication path and is not part of the copilot tool substrate.
POST /api/admin/memberships/accept uses the relay-authenticated signed-command channel. It rejects an operator session token and is excluded from the OpenAPI descriptor contract. See Relay-authenticated invitation acceptance below.
Operator session renewal
POST /api/admin/auth/refresh extends a live operator session and returns a fresh bearer token without another Google sign-in. An operator who keeps the console open is renewed automatically rather than being redirected to Google every 30 minutes.
How it works
The route requires a valid operator bearer token. The deny-list, expiry, and operator-active checks all run in the auth pre-handler before the route body executes. Inside the route body, the service:- Re-reads the operator row and confirms the operator is still
active. A disable that races this route does not result in a freshly extended session. - Calls
renewSession, which extendsoperator_sessions.expires_atin a conditionalUPDATE. TheWHEREclause repeats the unrevoked and unexpired predicates, so a logout that races the renewal wins — the revoked row is not resurrected. - Mints a new bearer token with a TTL equal to the time remaining on the renewed session.
Session lifetime ceiling
The token TTL is 30 minutes. A session’s maximum lifetime from the original Google sign-in is 12 hours (OPERATOR_SESSION_MAX_LIFETIME_MS). Each renewal is clamped to that ceiling rather than extended past it. Once the ceiling is reached, the next renewal attempt returns 401 and the operator must sign in again via Google.
Renewal request and response
The route is not auth-exempt. It is excluded from the OpenAPI descriptor contract, which covers only protected operator operations that accept a live session.
Managed records
The admin store currently owns:- organisations and their home region (including
signup_discount_codeif a discount code was submitted at signup, andcountry_codeif a country was supplied at signup or updated via the org profile endpoint); - global user identities (including Cognito provisioning state and invite tracking);
- organisation memberships and roles (including invitation generation);
- the admin-side platform configuration contract;
- platform list definitions and legacy new-organisation seed vocabulary;
- rate-category seed templates (keyed by business type, with ordered allowed units);
- disposal substance seed templates (keyed by business type; seeded with 21 workbook defaults per type by migration
0040, replaced with 12 revised defaults by migration0046where unedited); - per-organisation seed snapshots (immutable, created at org-create time);
- operator credentials and sessions;
- audit records;
- provisioning-event outbox and aggregate-version records;
- Cognito operation queue (
cognito_operations); and - legal acceptance records (
legal_acceptances) — an append-only history of each user’s Terms and Privacy acceptances, keyed by user and cascaded on user erasure.
disabled; it does not delete the
row. Disabling a user or changing an email updates the admin store and enqueues the
corresponding event. Enable event synchronisation and its recurring workers
separately for each environment that should deliver these changes.
default_markup_percent and pricing_average_speed_kmh are retired admin
configuration keys. Attempts to set them return 422 with code
config_key_removed. Default markup is organisation-owned in the Global API.
Platform catalogue
The adminglobal_lists store separates code-coupled platform values from
legacy new-organisation seed vocabulary and signup configuration:
Migration
0005_seed_platform_lists_retire_markup.sql seeds the canonical
project statuses, activity statuses and basis values, and work-item statuses
with stable IDs. Existing rows are preserved by ON CONFLICT DO NOTHING.
Delete behaviour
DELETE /global-lists/:id soft-deletes the item. The row is stamped with deleted_at and deleted_by and kept for history. It is hidden from list results and from the signup business-type check. A repeat DELETE on an already-deleted item returns 204 with no change and no second audit record. Any other write (PUT) to a soft-deleted item returns 409 with code deleted.
Unique constraints are enforced among live rows only. A value that was previously deleted can be re-created under the same listKey and scope.
Global list item fields
The runtime Global API owns the immutable
unit_catalogue. It is deliberately
outside the admin settings and event-projection contracts; do not add unit
catalogue rows to admin list or seed-template payloads.
There are currently no writable app_config keys. Unknown keys return
422 unknown_config_key; the two retired pricing keys return
422 config_key_removed.
Rate category templates
Rate category templates define the default rate categories a new organisation starts with. Each template belongs to a business type and carries the units each category may be priced in. Names are unique per business type — a global unique would let exactly one type own a name such as “Internal labour”.Template fields
parentGroup classifies the template within six taxonomy nodes:
The create body is strict — unknown fields are refused with
400. This prevents silent acceptance of misspelled field names such as allowedUnits or unitCodes.
parentGroup is required on create; omitting it returns 400. On update it is optional (omit means keep the current value).
allowedUnitCodes must contain at least one code. The API validates the array for shape (non-empty string, unique, minimum one entry) but not for membership against the unit catalogue — the catalogue lives in api-global’s tenant database and is not reachable from the admin service. The admin console picker reads its options from UNIT_CATALOGUE in @stralos-co-nz/ui (27 units); a code not in that list but valid on the wire will be accepted and passed through to the tenant ingest, which silently drops codes it does not recognise.
isCartageVehicle is invalid on any parent group other than plant_vehicles. The API returns 422 cartage_requires_plant_vehicles when the combination is submitted. The admin console enforces this client-side to provide an immediate error rather than waiting for a server rejection.
Delete behaviour
DELETE /rate-category-templates/:id soft-deletes the template. The row is stamped with deleted_at and deleted_by and kept for history. It is hidden from list results and excluded from the new-organisation seed snapshot. A repeat DELETE on an already-deleted template returns 204 with no change and no second audit record. Any other write (PUT) to a soft-deleted template returns 409 with code deleted.
Unique constraints are enforced among live rows only. A name that was previously deleted can be re-created under the same businessType.
Organisations already seeded before a template was deleted are unaffected. Their categories came from a frozen org_seed_snapshots copy taken at create time.
Sub-category templates (retired)
The authored sub-category level (rate_sub_category_templates) is retired. The tenant flattened its category tree (api-global 0036), so the authored third level existed only to be promoted back to one. Existing frozen org_seed_snapshots rows can still carry subCategories entries, and tenant ingest continues to promote them. The routes, the General sub-category auto-creation, and the cascade from category reparenting are no longer in effect.
Seeding behaviour
Rate category templates do not emit standalone provisioning events. They are captured in the seed snapshot at organisation-create time, while new snapshots emit an emptyvocab array. Immutable older snapshots may still replay vocabulary. Changes do not affect organisations created before the edit.
Migration 0029_disposal_units_each_lot.sql changes every disposal template whose stored array is still exactly ["cubic_metre", "tonne"] to ["cubic_metre", "tonne", "each", "lot"]. Templates with any operator-edited unit list are preserved, and cubic metre remains the first/default unit. Migration 0030_landscaping_aggregate.sql adds Aggregate to the landscaping starter set under quarry_materials with cubic metre and tonne. Because snapshots are immutable, both changes affect future organisations only.
Disposal substance templates
Disposal substance templates define the default disposal substances a new organisation starts with. Each template belongs to a business type (the same key used by rate-category templates) and carries the density and compaction values used in disposal calculations. Migration0040 seeded 21 workbook defaults for every live business type. Migration 0046 replaces them with 12 revised defaults, but only for a business type whose live rows are still exactly the 21 that 0040 seeded (same names, values, reinstatement flag and sort order, nothing added or removed). Any operator-edited set is kept. The old rows are soft-deleted (deleted_by null) before the new ones are inserted, and the migration is re-runnable. Existing organisations and frozen seed snapshots are not changed.
When a new business type is added via POST /global-lists, the 12 current defaults from src/lib/disposalSubstanceDefaults.ts are inserted in the same transaction so no type is ever left without a starter set.
Template fields
The create body is strict — unknown fields are refused with
400.
Delete behaviour
DELETE /disposal-substance-templates/:id soft-deletes the template. The row is stamped with deleted_at and deleted_by and kept for history. It is hidden from list results and excluded from new-organisation seed snapshots. A repeat DELETE on an already-deleted template returns 204 with no change and no second audit record. Any other write (PUT) to a soft-deleted template returns 409 with code deleted.
Unique name constraints are enforced among live rows only.
Seeding behaviour
Disposal substance templates do not emit standalone provisioning events. They are frozen into the seed snapshot at organisation-create time alongside rate categories. Changes affect future organisations only — organisations already created own their substances in the tenant and are never modified from here. Existing organisations created before migration0040 received their disposal substances from the tenant’s own built-in defaults. A null disposalSubstances field in a seed snapshot has the same effect: the tenant falls back to its own defaults.
Deploy-time tenant bootstrap (db:init)
The db:init bootstrap provisions the founding tenant for a brand-new environment: one organisation, one administrator, one membership, and the Cognito intent that will give that administrator an account. You can run it locally with pnpm db:init; deployments invoke the same operation through the migration Lambda. It is idempotent, so a second run is a no-op that exits 0.
Configuration
All three naming variables must be set together or omitted together. Omitting them is a valid state (the command skips gracefully and exits 0). Setting only some of them is a hard error, because that is the shape a templating mistake produces.
Terraform maps these environment variables from
db_init_organisation_name, db_init_admin_email, db_init_admin_name, db_init_organisation_timezone, db_init_home_region_key, and db_init_business_type. The first three must be set together or all omitted. Plan-time guards also reject empty values, malformed email addresses, and a home-region key that is absent from the configured region registry. Only the migration Lambda receives the resulting ADMIN_INIT_* environment variables.
There is no password variable and none should be added. The administrator’s credential is the temporary password delivered by the Cognito invite email after the sweeper creates the account. A plaintext password in configuration would sit at rest in SSM and be silently ignored wherever Cognito owns the credential.
What it creates
In a single transaction with a transaction-scoped advisory lock (key5150120002):
- Checks whether a user with
ADMIN_INIT_ADMIN_EMAILalready exists. If one is found, the run returnsalready_initialisedand exits 0 without writing anything. - Calls
createTenantInTx— the same function used by self-service signup — to insert the organisation, the admin user (provisioning='pending'), an active administrator membership, the seed snapshot, audit records, and outbox events in one atomic write. - Enqueues a
createrow incognito_operationsin the same transaction.
Local versus deployed behaviour
The Postgres writes are identical in both environments. The difference is who else is running:- Deployed — the events worker’s Cognito sweeper picks up the
createoperation, callsAdminCreateUser, marks the userprovisioned, and only then is the invite eligible to be released. - Local — there is no sweeper. The operation sits
pendingindefinitely and nothing calls Cognito. The regional runtime’s OTP sign-in authenticates from Postgres, so the administrator can log in regardless.
if (cognito) branch exists in the bootstrap, deliberately: two bootstrap paths could drift.
Exit codes and output
Deployment rail
After every Terraform apply,scripts/finalize-deploy.sh checks db_init_configured. When it is true, the script invokes the immutable migration Lambda version with {"mode":"db-init"}. Both the normal migration mode and db-init run migrations first, so the bootstrap cannot reach an unmigrated schema.
The invocation occurs before writers are restored. The finaliser requires a structured created or already_initialised response; a Lambda error, an older artifact that does not support the mode, or an unexpected skipped response fails closed and leaves writers disabled. Because the schema has already changed at this point, recovery resumes the finaliser and safely repeats the idempotent operation.
Idempotency is keyed on the administrator’s email address, not the organisation name. A name is configuration and gets edited; keying on it would create a second tenant on the next deploy instead of recognising the first.
Lambda alias release
API Gateway integrations for the main admin API Lambda targetaws_lambda_alias.live, not $LATEST. The optional dedicated signup method targets aws_lambda_function.signup instead. Publishing a new main API artifact with update-function-code --publish cuts a numbered version but does not move the alias, so the main API edge keeps serving the previous deployment’s bytes until the alias is explicitly updated.
scripts/promote-alias.sh moves the live alias onto the version the finaliser just published and verifies the move via two independent read-backs: the alias must resolve to that version, and get-function-configuration --qualifier live must carry this run’s CodeSha256. The finaliser calls promote-alias.sh immediately after promoting the API function code and before recording the artifacts_promoted progress marker. A failure here fails the deployment closed.
Terraform cannot perform this step. aws_lambda_function.api carries ignore_changes = [source_code_hash], so any version Terraform publishes snapshots the code already on the function, not the newly promoted artifact. Only the finaliser, which owns the promotion, knows the correct target version.
scripts/promote-alias.sh refuses $LATEST as a target and uses the alias revision ID for optimistic concurrency, so a concurrent release-alias.yml rollback cannot be silently overwritten by a deploy. assert_promoted_writers, which runs after writers are restored, verifies the alias qualifier as well as $LATEST to confirm the edge is serving the promoted artifact.
Organisation lifecycle
An organisation is created with statusprovisioning. It is not usable until the
provisioning saga (see below) confirms that both required
runtime projections have landed and flips the org to active.
The full lifecycle is:
Status is not freely settable via the API. Activation and failure transitions are
owned exclusively by the provisioning saga.
Organisation slug
Every organisation has aslug: a globally unique, URL-safe identifier used as the tenant key in login.<env>/t/<slug>. The slug is set at create time and cannot be changed afterwards.
POST /organisations accepts an optional slug field. When the caller omits it, the API derives a slug from the organisation name. If derivation fails (the name reduces to a reserved word or fewer than two characters), the API returns 422 org_slug_underivable and the operator must supply an explicit slug. A slug that is already taken returns 409 org_slug_taken.
The slug is carried on every organisation.* event payload so the runtime can resolve a tenant to its home region. See Organisation slug for the full format rules, reserved words, and event-delivery behaviour.
Self-service signup
The login site exposes/signup. The browser first submits only the prospective administrator’s email address to stralos-api-login. The start response is deliberately uniform: it does not reveal whether the address is already registered or whether a message was sent.
The signup form also fetches the available business types via GET /signup/business-types. This endpoint is unauthenticated and read-only. It returns only values from global_lists(scope='signup_vocab', listKey='business_types') — operator-editable entries — and returns 503 when signup is not configured. Values in this list are public by construction; do not add anything here that an anonymous caller should not see.
After the user supplies the emailed code, their name, the organisation name, their type of business, and a password, api-login relays the completion request to admin-api. Admin verifies the code and creates these records in one transaction:
- the organisation in
provisioningstatus, with the requiredbusinessTyperecorded,signupDiscountCodeset when adiscountCodewas submitted, andcountryCodeset when acountryCodewas supplied; - an active, provisioned user;
- an active administrator membership;
- a
legal_acceptancesrow recording the accepted Terms and Privacy versions; - the seed snapshot (categories selected by
businessType), audit records, and required outbox events; and - the directory replacement for the user’s region set.
These fields are required on every completion request — they cannot be omitted or defaulted. The
legal_acceptances table is append-only: a later re-acceptance of a newer version adds a new row, so the table is the full history of what each user agreed to and when. The acceptance row is written in the same transaction as the user; there is no user without an acceptance record, and no acceptance record without a user. The ON DELETE CASCADE constraint on user_id ensures that user erasure (the retention Lambda) removes both the user row and the acceptance history together.
Apply migration 0039_legal_acceptances.sql before promoting code that writes the new columns.
The optional discountCode field on the completion request accepts a code in the format [A-Za-z0-9-]+ (1–100 characters). Admin stores it on the organisation as signup_discount_code but does not validate it against the billing service at signup time. The billing triage read returns it as signupDiscountCode so operators can see what code the organisation signed up with.
The completion request requires a live businessType. A missing type returns 422 business_type_required; an unknown type returns 422 unknown_business_type.
The signup page presents its completion state after this transaction and directs the administrator to sign in; signup itself does not issue a browser session. Independently, the normal provisioning saga activates the organisation after its runtime projections are confirmed.
Password requirements
The completion request enforces the Cognito user pool’s full password policy before the one-time code is consumed or a Cognito account is created. A password that does not satisfy every rule returns400 with per-rule field errors.
The special-character set is the 32 printable ASCII punctuation characters Cognito’s pool policy declares as satisfying
RequireSymbols. Space is deliberately excluded: the pool permits a space inside a password but does not document it as satisfying the symbols requirement. The same rule is duplicated in stralos-api-login and stralos-login-web; change all three together.
Email alias recovery
The Cognito pool declaresAliasAttributes: ["email"], making the email address a second unique namespace alongside the UUID username. A signup attempt that fails after creating a Cognito account but before completing the database transaction leaves an orphaned account holding the email alias. Previously, retries received AliasExistsException from AdminCreateUser and returned 503 permanently.
The completion path now resolves this: when AliasExistsException is raised, admin-api calls AdminGetUser to locate the alias holder and adopts it if all four conditions hold:
- The email attribute on the Cognito account matches the address being signed up.
- There is no
usersrow for the address or for the holder’s username. - A
signup_intentsrow records that this flow originally minted that username for this address, with a status ofcompleting,expired, orfailed. - No enabled tenant was ever created from that intent.
409. A claimed alias cannot be cleared by retrying — the adoption path recovers only orphans this flow demonstrably created, and nothing is ever deleted.
See Get started with Stralos for the user-facing steps and Admin provisioning — signup channel for the server-to-server boundary.
Seed template snapshot
When an organisation is created, the API atomically:- Validates that a live
businessTypewas supplied. A missing value returns422 business_type_required; an unknown value returns422 unknown_business_type. This applies toPOST /organisations, self-service signup completion, anddb:init. - Inserts the organisation row with status
provisioning. - Reads the
rate_category_templatesrows and thedisposal_substance_templatesrows for the organisation’sbusinessTypeand freezes them into an immutableorg_seed_snapshotsrow (thetemplateSnapshotId). - Enqueues both
organisation.createdandseed_template.provisionedoutbox rows in the same transaction.
seed_template.provisioned payload carries:
Permanent backwards compatibility. Every
org_seed_snapshots row already written is immutable and may carry subCategories and vocab. The payload type, the emission schema, and the tenant consumer must all keep accepting both fields forever. What changed is only what a new snapshot contains. parentGroup, subCategories, canHaveTravel, and canHaveHaulage are permanently optional in the schema — a field added to a contract over immutable stored payloads is optional forever.
parentGroup on each legacy sub-category item is likewise permanently optional. Snapshots that carry no sub-category parentGroup remain valid; the consumer falls back to the enclosing category’s parentGroup.
The seed_template aggregate uses the org id as its aggregateId, giving the
runtime a clean per-org high-water key. The seed_template aggregate maps onto the
organisation advisory lock so delivery and reconciliation for both aggregates
serialise on a single lock.
Provisioning saga
The provisioning saga is a scheduled sweep (not a request-path operation) that drives orgs throughprovisioning → active (or provisioning → failed). It is invoked as:
- EventBridge: every minute.
- Lambda: pass
{ mode: "saga" }to the flush Lambda handler. - CLI:
pnpm flush -- --saga.
provisioning or failed status, the saga evaluates two gates
inside a serialised transaction:
Gate 1 — outbox delivery: both required outbox rows (organisation.created and
seed_template.provisioned) are processed (the flusher’s terminal state, meaning
the runtime ingest returned 2xx).
Gate 2 — replica confirmation: the runtime replica read independently confirms
that both projections are applied at or above the required aggregate version.
When both gates pass, the saga:
- Updates the org status to
active. - Writes an audit record (
organisation.activated). - Enqueues
organisation.updated { status: active }via the normal allocator path (version-ordered afterorganisation.created, so the flusher delivers them in order).
conflicted required row (definitive projection failure), the
saga marks the org failed, writes an audit record, enqueues
organisation.updated { status: failed } for replica consistency, and fires an
operator alert once (on the provisioning → failed edge only).
A failed org is recoverable. Reconciliation re-arms the conflicted row; once
redelivery converges and the replica confirms, the saga activates the org. Recovery
is always forward — no revert.
Concurrency
Each org evaluation holds aFOR UPDATE row lock with a status re-check, so two
concurrent saga runs cannot double-flip. The saga uses the normal allocator path and
does not hold the flusher/reconciler per-aggregate advisory lock.
Event delivery
Applicable provisioning-aggregate mutations enqueue versioned events in the same transaction as the admin-store change. Rate-category templates do not emit standalone events; they are captured in the seed snapshot at organisation-create time. New snapshots carry noorg_seed_template vocabulary, although immutable older snapshots may still
replay it (see Rate category templates). Authentication,
session, and operator changes do not enqueue provisioning events.
The flusher signs event envelopes with the event keypair, delivers pending rows, and
records retry, processed, or conflict outcomes. The dev EventBridge rule invokes it
every minute.
Each outbox row carries a target_region_key. The flusher, reconciler, and predecessor-blocking query all filter on this column, so every region has its own independent delivery stream for the same aggregate. The delivery address for each event type is:
The flusher enforces two delivery-ordering dependencies:
- Per-aggregate predecessor blocking: a row is not delivered while any lower-version
row for the same aggregate and target region is non-
processed. - Cross-aggregate seed dependency: a
seed_template.provisionedrow is not delivered until its org’sorganisation.createdrow isprocessed.
Every user event (
user.created, user.updated, user.disabled, user.email_changed) carries a
sessionGeneration field (M4c): a non-negative integer that the runtime applies as a
monotone greatest(existing, incoming). The session generation is the retained regional
sign-out-everywhere counter; incrementing it invalidates regional Global API sessions in every
region the event reaches. It does not invalidate the separate central login-service session store.
Legacy outbox rows may carry no sessionGeneration; the runtime treats an absent value as a
no-op for the generation column.
Project lead departure
When an operator disables a user or revokes a membership, the admin API contacts the affected runtime region to verify that the user is not the last project lead on any active project before the disable or revoke takes effect. This check prevents a project from being left without a lead. The check runs inside the same database transaction as the admin-store write. Before the transaction commits, the API:- Enqueues the outbox event (
user.disabledormembership.revoked) as normal. - Records a durable
project_lead_departure_intentsrow for every affected region. This row is written independently of the identity transaction so that recovery can proceed even if the canonical transaction is rolled back. - Sends a signed
projectLeadReservation=preparerequest to the runtime ingest URL for each target region. The runtime checks whether the user holds any last-lead position and returns409if so.
409, the route returns 409 with code project_last_lead and the message “Appoint a replacement before deactivating the last project lead.” The admin-store transaction is rolled back and no event is enqueued.
Transport configuration
The departure check uses the sameRUNTIME_INGEST_URLS map as ordinary event delivery. It does not use RUNTIME_REPLICA_URLS. A deployment that configures only RUNTIME_INGEST_URLS (the API ingest map) without RUNTIME_REPLICA_URLS (the worker replica map) will have departure checks work correctly. Using the replica URL for this call was a bug; the runtime ingest endpoint is the correct target for the reservation protocol.
The RUNTIME_INGEST_URLS variable must be set for departure checks to function. A missing or empty map causes the route to return 503 with “Project leadership authority is not configured.”
Durable recovery
Theproject_lead_departure_intents table provides crash recovery for the two-phase departure protocol:
The recovery pass (
recoverProjectLeadDepartures) runs under the same per-user derivation lock as the canonical outbox writers. When the paired outbox row has reached processed, the intent is considered complete and deleted. When the outbox row is absent (the canonical transaction rolled back), the recovery sends a projectLeadReservation=cancel request to release any runtime reservation and then deletes the intent.
The recovery pass is invoked by the event flusher Lambda on every scheduled tick. No separate EventBridge rule is required.
Capabilities table entry
Runtime report channel
The admin API accepts signed runtime→admin reports atPOST /api/admin/reports/runtime. Reports are
sent by stralos-api-global to relay state transitions that originated in a region’s runtime
rather than in an operator action. They use a dedicated JWT type (stralos-report+jwt) and are
verified against a per-region JWKS distinct from the admin signing key.
The route requires IAM authorisation and SigV4 plus an EdDSA JWS signed by the runtime keypair.
A distinct issuer and audience, the protected-header type, the environment claim, and
signing-key separation prevent a user access token from being accepted as a report.
The channel requires report_flusher_role_arn and a per-region report_source_map containing
{ issuer, jwksUrl }. The route is unavailable when the channel is not configured.
The
user.session_revocation_requested report carries only { userId }. It holds no ordering
value and no generation: two distinct requests both increment the counter (the counter is
monotone, so two increments are correct and safe), and redeliveries are caught by the
runtime_report_seen primary key, which replays the recorded outcome rather than incrementing
again.
A report is a command rather than a direct regional write. The admin API validates it against
current admin state and applies accepted changes through its normal versioned outbox path.
Report outcomes and rejection codes
Membership-acceptance reports returnapplied, noop, or rejected. A rejection includes one
of the following codes:
Membership payload fields
All membership events carryinvitationGeneration in addition to the core fields. This is the current incarnation counter for the membership’s invited state: it starts at 1 and increments each time a membership transitions back to invited (for example, a revoke followed by a re-grant). The membership.status_changed event emitted on an acceptance carries the generation that was current when the user consented. A report whose generation does not exactly match the current value is rejected as stale_invitation.
Relay-authenticated invitation acceptance
POST /api/admin/memberships/accept is the IDP-M7 acceptance path. The central login service (stralos-api-login) captures the user’s consent during sign-in and relays it here as a signed JWS command. Admin performs the invited → active transition in a transaction and delivers membership.status_changed through the normal outbox path.
This path exists because the login service can write nothing to the admin database under M7: its memberships grant is SELECT on enumerated columns only. The consent therefore travels as a relay command, using the same pattern as self-service signup.
Transport
The route uses a dedicated relay command model, separate from both the operator session and the runtime report channel:
A separate
typ and audience from signup’s command family are deliberate. Both families share a signing key, so audience and typ together are the entire separation: a captured signup command must not function as an acceptance command, and vice versa.
The request body is bound by hash in the JWS for the same reason signup’s is. Without hash binding, a captured token would authenticate the relay but not the specific request body — it could be replayed against a different (userId, organisationId) pair, accepting an invitation the user never clicked.
Invitation acceptance request and response
Relationship to the runtime report path
The pre-M7 acceptance path ismembership.acceptance_reported on the runtime report channel: the runtime flips its own replica, reports the fact, and admin applies a CAS. The M7 path is the reverse: consent arrives centrally first and the region learns through the normal admin → region projection.
The two paths must not both be live for one membership. A regional accept_membership_invite CAS would conflict with the projection that the central acceptance already authorised. The store module records this ownership constraint.
The membership.status_changed event emitted on a successful M7 acceptance carries the same MembershipPayload shape as other membership events, including invitationGeneration.
Audit
The acceptance is audited with actoruser-consent:<userId>@<organisationId> and operator_id = NULL. The subject of this action is a person, not an operator.
Relay-authenticated session revocation (PRD 05 §8.4)
POST /api/admin/sessions/revoke-all is the sign-out-everywhere path for central browser sessions. The central login service cannot write to the admin database under D41’s grant — its role has SELECT on users.session_generation and nothing more — so a user’s sign-out-everywhere request travels here as a signed JWS command. This is the third relay family, after signup and membership acceptance.
Why a relay rather than a report
stralos-api-global reaches the session-revocation write through its runtime-report channel: it enqueues a user.session_revocation_requested report into its regional runtime database, and the flusher delivers it to POST /api/admin/reports/runtime. stralos-api-login has no regional runtime database — it is the login host — so the report channel is not available to it.
Transport and command authentication
The
typ, audience, and command-type enum are the entire separation between the three relay families: all are signed with the same key. A captured command of any family must not be usable as another. The test suite proves a command from one family is refused by every other.
The request body is bound by SHA-256 hash inside the JWS. Without this binding, a captured token would authenticate the relay but not the specific request body. An attacker holding their own relay command could replay it against a different userId and sign out anyone in the estate. Body-hash binding is what makes the PRD 05 D14 guarantee (“can revoke none but the CALLER’s own sessions”) hold across the relay hop.
Session revocation request and response
userId field is the only field. The login service resolves it from a validated refresh token; it is not a caller-supplied target. There is deliberately no operator-override field: an operator who needs to end a user’s sessions uses POST /users/:id/disable, which is audited against the operator’s own identity.
The response is
200 rather than 202 because the write completes before the response. After that success, the login service returns 202 {} to the browser because regional revocation still converges asynchronously. An unavailable or refused relay returns a dependency error instead.
Shared transaction
The revocation transaction isstore/sessionRevocation.ts — the same implementation the runtime-report path uses via applySessionRevocation. Both paths increment the same monotone counter, write the same audit record, and enqueue user.updated to the same sticky set of regions. Two implementations would risk one forgetting the user.updated enqueue, which is the only mechanism by which regions learn the new generation.
On the relay path the user.updated event carries no additional region in the fan-out set, because the login host has no regional session state. The sticky set (the union of every region the user has ever been projected into) is sufficient: a user who holds a live central session has completed the sign-in ceremony and therefore has at least one active membership region.
Idempotency
The revocation command has acommandId (jti) and a 120-second expiry but no ledger. A replay is harmless because the counter is monotone — a second increment is a strictly stricter session cutoff and can never resurrect a session. This reasoning is the same one applied to two distinct simultaneous POST /api/login/auth/sessions/revoke-all requests, which the report channel also counts twice intentionally.
Audit
The revocation is audited with actorrelay:<relayKey> and operator_id = NULL. The audit action is user.sessions_revoked, which is distinct from the report channel’s report.applied.user.session_revocation_requested, so the audit log unambiguously identifies which channel performed each write.
Organisation billing triage
The billing triage surface lets operators inspect and refresh an organisation’s billing state from Stripe through the regional billing service.Routes
Both routes require an active operator session. They return
503 with code: "billing_unavailable" when BILLING_ADMIN_URLS is not configured for the environment.
Billing triage response fields
The
provider.mode field distinguishes a Stripe test-mode environment from a live one. A "test" value means the organisation’s billing data lives in Stripe test mode; credentials and events from that environment do not affect real payment instruments.
Refresh
POST /api/admin/organisations/:id/billing/refresh forces a re-read from Stripe via the regional billing service. Admin audits intent before the request and the outcome after. A failed request is audited as organisation.billing.refresh_failed. The read and refresh paths share the same signed command channel (stralos-billing-admin-command+jwt, audience stralos-billing-admin-<env>, expiry 30 seconds).
Configuration
Setbilling_admin_urls in Terraform (type map(string), region key → HTTPS endpoint). The value is encoded as BILLING_ADMIN_URLS on the API Lambda. An empty map disables both billing triage routes.
Discount codes
The discount code surface lets operators manage customer discount codes through the regional billing service.Routes
All routes require an active operator session. They return
503 with code: "discounts_unavailable" when BILLING_ADMIN_URLS is not configured.
List
GET /api/admin/discount-codes returns a paginated list of discount codes. Use the optional startingAfter query parameter (format promo_[A-Za-z0-9]+) for cursor-based pagination.
Create
POST /api/admin/discount-codes creates a new discount code. The request body is validated before the billing service is contacted, so a malformed request returns 400 without incurring a provider round-trip.
Either
percentOff or amountOff must be set, but not both. A "repeating" duration requires durationInMonths; other durations must omit it. A fixed amount requires currency; a percentage must omit it.
Activate or deactivate
POST /api/admin/discount-codes/:id/active activates or deactivates a code without affecting existing redemptions. The path parameter must match promo_[A-Za-z0-9]+.
Audit trail
Every create and set-active operation is audited twice: once asdiscount.<operation>.requested before the billing service call, and once as either discount.<operation>.completed or discount.<operation>.failed after. List operations are not audited. This two-phase record ensures a process failure between the request and the provider call leaves a traceable outcome in the audit log.
A provider error returns 503 with code: "discount_request_failed". A provider validation error (for example, a duplicate code) returns 400 with the same code. The signed command uses typ: stralos-billing-discount-command+jwt, audience stralos-billing-discounts-<env>, and a 30-second expiry.
Contractor companies
A contractor company works inside other companies, its client companies, through a company link. A contractor company on the contractor plan can create a company for a client and place its own people there. Any company can send a contractor company a link by email.stralos-admin-api is the system of record for links and contractor memberships. The tenant-facing routes are reached through the Global API relay; see Contractor companies in the Global API reference.
Who can act
Each route re-derives authority from the admin store:- Contractor admin: an admin of the token’s company in their own right (the membership has no
via_link_id), whose company is on the contractor plan. Manages client companies and the contractor’s people in them. - Company admin: an admin of the token’s company in their own right. Manages the contractors linked to that company.
403 company_admin_required. A missing contractor plan returns 403 contractor_plan_required.
Links
There is at most one live link between two companies. Links never cross regions: a contractor can only be linked to companies homed in its own region.
Creating a company for a client
POST /api/admin/org/client-companies takes the company’s name, country, business type, time zone, optional numbering prefix and starting number (still named jobNumberPrefix and startingJobNumber on the wire; api-global applies them to the new company’s quote numbering), and the contractor’s people to place in it with their roles. The creator is always included as an admin. The new company is homed in the contractor’s region and records createdByOrganisationId. Its link is created active, with origin: created and managedBy: contractor.
A created company’s name only has to be unique among the same contractor’s companies; a clash returns 409 client_company_name_taken. After the transaction commits, admin sends api-billing a cover command, so the company is covered by the contractor’s billing, and a link_state command. The response reports billingSync: "pending" if either command could not be sent; adding people later re-sends the cover.
Inviting a contractor
POST /api/admin/org/contractors/invites takes an email address. The address must belong to a user who is not disabled and whose default company is an active contractor company in the same region; otherwise the route returns 422 contractor_not_found or 422 contractor_region_mismatch. A live link between the two companies returns 409 contractor_already_linked, unless it is an expired invitation, which is ended and replaced. The new link is pending, invited and company-managed, and the invitation email is sent to that address. It expires after 14 days.
Anyone signed in can read an invitation with GET /api/admin/org/contractor-invites/:token, which reports whether they can accept it and, if not, why (not_admin, not_contractor, wrong_company or expired). POST /api/admin/org/contractor-invites/:token/accept requires an admin, in their own right, of the invited contractor company while it is on the contractor plan. Accepting activates the link, gives the accepting admin an admin membership in the client company through the link, and sends a link_state command.
Contractor people and seats
Contractor people are memberships withvia_link_id set to their link. A contractor admin adds one of their own active people to a client company with POST /api/admin/org/client-companies/:linkId/people and removes them with POST /api/admin/org/client-companies/:linkId/people/:userId/remove. People are added as active members without an invitation email; they receive an “added” notification instead. Contractor people never take one of the client company’s seats.
stralos-api-login mints a contractor person’s token only while their link is active, and stamps it with the via claim (org, link, name). Migration 0042 grants the login role column-scoped SELECT on the link columns it needs, and nothing else on that table.
Ending, closing and taking over
Either side can end a live link: the contractor withPOST /api/admin/org/client-companies/:linkId/end, the company with POST /api/admin/org/contractors/:linkId/end. Ending a link disables every membership that came through it and sends a link_state command. Project memberships are kept.
A free company the contractor created and still manages cannot be unlinked (409 link_managed_by_contractor_close_instead). The contractor closes it instead with POST /api/admin/org/client-companies/:linkId/close, which disables the company and ends the link. Closing any other link returns 409 link_not_closable.
POST /api/admin/org/client-companies/end-all ends every live link the contractor has, one transaction per link, and is used when a contractor company changes back to standard. A link that cannot be ended, for example because removing a person would leave a project without a lead, is reported in the response and does not stop the others.
A company’s own admin takes over a created company with POST /api/admin/org/contractors/:linkId/take-over. The company must already have its own billing (409 company_needs_billing_first otherwise). The link becomes company-managed and records handedOverAt; the contractor’s people stay until the link is ended.
Billing commands
Both are signed commands (
typ: stralos-billing-contractor-command+jwt, audience stralos-billing-admin-<env>, 30-second expiry) sent to the company’s home region through the BILLING_ADMIN_URLS endpoints.
Organisation payload
organisation.created and organisation.updated carry createdByOrganisationId: the contractor company that created the company, or null. The field is optional on parse, because outbox rows written before migration 0041 do not carry it.
Routes
All routes are under/api/admin/org and use the org self-service authentication (auth/orgAdminToken.ts and auth/orgAdmin.ts, or auth/orgUser.ts for the invitation routes).
Operators can read a company’s links in both directions with
GET /api/admin/organisations/:id/contractor-links; the admin console shows them in the billing triage panel.
Reconciliation
Reconciliation re-arms outbox rows the runtime missed and detects state drift between the admin store and the runtime replica. It runs as a scheduled pass (Lambda{ mode: "reconcile" } or pnpm flush -- --reconcile) and only re-arms rows to
pending; the flush schedule delivers them.
M4 adds a conflicted-settle step: a conflicted row whose aggregate version is at or
below the replica’s applied high-water mark is settled to processed rather than
re-armed, preventing it from blocking successors and from stranding the provisioning
saga in failed indefinitely.
User identity provisioning
IDP-M2b adds a Cognito provisioning layer between the admin-store user write and the invite email. Every user-mutating route enqueues an intent row incognito_operations
on the same database transaction as the users row change. The sweeper Lambda
({ mode: "cognito" }) executes those intents out of band — never on the request path.
User provisioning states
The
provisioning column is set to provisioned only after AdminCreateUser succeeds.
Invite dispatch gates on provisioning = 'provisioned', so no invite can point at a
login that does not yet exist.
Cognito operation queue
Each operation reads the live
users row at execution time (except link_provider,
which has its payload fixed at enqueue). A crash between the Cognito call and the
post-commit write leaves the operation pending for the next tick; UsernameExistsException
on re-drive is treated as success.
Operations move from pending to applied. Only create and link_provider can enter a
terminal failed state; disable, enable, delete, and email_change retry until they
converge or an operator resolves the underlying problem.
Cognito pool reconciliation
The dailycognito-reconcile pass ({ mode: "cognito-reconcile" }) diffs the Cognito
pool against the admin store and repairs discrepancies by enqueueing sweeper operations.
It owns the cognito_operations table and the users.provisioning column only — it
never touches users.status, memberships.status, or the admin outbox.
Pool accounts whose Username is not a UUID and is not a linked provider shadow are
reported as foreign accounts and never touched.
Invite email dispatch
IDP-M2b delivers invite emails through Amazon SES v2 from a dedicated mail subdomain (id.<envdomain>), keeping mail identity ownership separate from the application apex.
IAM requirements
When invite mail is enabled, three roles needses:SendEmail:
signupsends verification codes during self-service signup;event_flushersends the scheduled first invitation;- the shared
lambdarole servesPOST /api/admin/users/:id/resend-invite.
ses:FromAddress fixes the sender mailbox, while the resource list contains the sender identity and configuration set.
SES sandbox accounts also authorise sends against the recipient identity. The Terraform source therefore includes both domain and address-pattern identity ARNs for stralos.co.nz in the dev and test environment allowlist. staging and prod receive the sender identity only. This is an environment allowlist in ses.tf, not a .tfvars setting.
SES production access is granted per account. After an environment leaves the sandbox, remove that environment from ses_sandbox_recipient_environments; do not remove another environment until its own account has production access.
The first-send path records its claim before calling SES. If delivery fails after that point, the dispatcher raises an alert and leaves the row claimed, and an operator must use the resend action. Keep the shared send policy attached to all three roles so verification, first-send, and recovery paths remain aligned.
Dispatch behaviour
The scheduled dispatcher ({ mode: "invites" }) runs two passes in sequence on every tick.
Account invite pass — sends each eligible user’s first invite exactly once (at-most-once guarantee). Eligibility requires:
provisioning = 'provisioned'— the Cognito account exists;statusis notdisabled; andusers.invite_sent_atis null — not yet attempted.
invite_sent_at set with a null invite_message_id (attempted, outcome unknown). The
dispatcher logs an error and raises an alert; an operator resolves it via the resend endpoint.
Membership notification pass — sends a separate notification email to users who are invited into an additional organisation. An existing user invited into a further organisation previously received no signal: the Invitations section appears only in the login chooser during a sign-in ceremony, and sessions live 30 days, so an invitation could sit invisible for weeks.
This pass is distinct from the account invite pass. Eligibility requires:
memberships.status = 'invited';memberships.notified_atis null — not yet attempted;- the user is not
disabled; and - the organisation is
active— mail only when acceptance can succeed.
memberships.notified_at is set before the SES call and never cleared automatically. A post-claim SES failure logs an error and leaves the row claimed (notified_at set, notified_message_id null) for an operator to resolve. It is never re-sent automatically. One exception: if eligibility fails after the claim but before sending (for example, the organisation was disabled mid-tick), the claim is released — the outcome is definitively known (nothing was sent), so releasing is safe.
Existing invited membership rows without a notified_at value become eligible for one notification each on the first post-deploy tick. This is the intended behaviour: they are invitations that nobody has been told about.
The email tells the recipient that a named organisation has invited them and directs them to sign in and accept via the organisation chooser.
Provisioning and invite fields
users table
memberships table (membership notification pass)
Operator resend
POST /api/admin/users/:id/resend-invite allows an operator to re-send an invite via SES.
The route requires provisioning = 'provisioned'; it returns 409 with code
user_not_provisioned when the Cognito account has not yet been created. Returns
503 when invite email is not configured (mail_hosted_zone_id not set).
This path runs on the api Lambda, which does not hold Cognito admin grants and cannot mint a
temporary password. The SES email therefore carries no credential and states this plainly. An
operator who needs the recipient to receive a working temporary password should use the
organisation-admin resend path instead.
Organisation-admin resend
POST /api/admin/org/users/:userId/resend-invite is the organisation-admin resend path. It
enqueues a resend_invite Cognito operation in the same transaction as the request. The sweeper
executes that operation by calling AdminCreateUser with MessageAction: RESEND, which mints a
fresh temporary password and lets Cognito send the invitation email. The sweeper records
invite_sent_at inside the same transaction. inviteMessageId is always null on this path
because Cognito does not return a message ID; the audit row carries channel: "cognito-resend" to
distinguish it from an SES send with an unknown outcome.
Previously, the sweeper’s resend_invite execution also triggered a second SES email from the
admin API, resulting in two invitation emails per resend — the first being Cognito’s stock template
naming the internal UUID as the username. The Cognito pool now carries an InviteMessageTemplate
(configured in stralos-core) that names the email address and includes the temporary password
token; the admin API no longer sends a second email on this path.
Sends with an unknown outcome
There is no operator worklist query.findUnconfirmedInvites and findUnconfirmedMembershipNotifications had no route, CLI or console view calling them, and were removed in stralos-admin-api PR 186. An operator finds these rows by their state instead:
- Account invites:
usersrows withinvite_sent_atset andinvite_message_idnull,provisioning = 'provisioned', astatusother thandisabled, and nosignup_intent_id(self-service signups never get an invite). A Cognito resend also leaves the message id null, so check the audit row’schannelas described under theuserstable above. Resolve each one with the operator resend endpoint. - Membership notifications:
membershipsrows withnotified_atset,notified_message_idnull andstatus = 'invited'. There is no automated retry.
Invite link structure
Invite links deep-link to the regional shell login URL for the user’s organisation. The?invite=<membershipId> parameter is a selector only — it names which invitation
the user clicked, but grants no authority on its own. The exchange re-validates the
membership against the authenticated user before accepting it.
Federated identity links
For federated sign-in (Google, Microsoft Entra), the pre-signup Lambda reads a DynamoDB lookup table to match an inbound provider identity to a native user UUID. IDP-M2b transfers the writer responsibility from elsewhere to thelink_provider Cognito
operation.
The admin-api writes the lookup row as part of the link_provider sweeper operation.
A queued link_provider with no IDENTITY_LINKS_TABLE configured fails loudly rather
than silently skipping, because a missing row means a federated user cannot sign in at all.
Identity keys are built from immutable provider identifiers only:
A
link_provider operation is idempotent: if the same user already holds the key, the
write returns already-linked. An attempt to claim a key held by a different user is a
definitive fault that alerts an operator and does not retry.
Invitation generation
Thememberships.invitation_generation column (a bigint, default 1) is a
stale-acceptance guard. When the runtime reports a membership acceptance, the report
envelope carries the invitationGeneration value the runtime projected. The report
channel rejects the acceptance with stale_invitation if the value does not match the
current admin store value.
The generation advances only when a revoked membership is re-invited (status transitions
back to invited). It does not advance on acceptance, revocation, or role change. The
migration 0007_idp_m2a_report_channel.sql backfills invitationGeneration = 1 into
legacy outbox rows so delivery can parse the field.
Directory backfill
M3b introduces the directory backfill: a one-shot CLI mode that emits adirectory.regions_replaced event for every user who does not yet have one, giving
the runtime a directory anchor for each account. Run it before relying on directory
routing for accounts created before M3b.
Run it with:
runId; pass that id on the next invocation to continue from the
cursor rather than restarting. A clean run requires failed = 0 for the whole
runId, the cursor to have reached the end of the users table, and every emitted
replacement to be confirmed at the directory anchor via the runtime replica.
The DIRECTORY_WRITER_REGION_KEY environment variable must be set to the region
key of the runtime that holds the directory write path. It must name a region
present in both RUNTIME_INGEST_URLS and RUNTIME_REPLICA_URLS. There is no
default; omitting it causes the process to fail at startup.
SSM parameter management
The seven admin SSM parameters are Terraform-managed resources defined interraform/ssm-parameters.tf. Terraform owns the parameter objects; the deployment
finaliser or an out-of-band operator step writes the application and Google values
after apply. The signup-code pepper keeps its Terraform-generated random value and
is readable only by the signup role.
The Signup role column indicates whether the signup Lambda’s IAM grant includes the parameter. The Google credentials are excluded: the signup function has no operator routes and the
ADMIN_GOOGLE_* environment variables are stripped from it entirely.
The google_client_id parameter is a public identifier and remains type = "String".
Keeping the declared type aligned with adopted parameters also prevents Terraform
from rewriting an existing value during adoption.
The database path is fixed by the stack. The signing-key paths may be overridden
with event_signing_key_ssm_parameter, operator_signing_key_ssm_parameter, and
totp_encryption_key_ssm_parameter. The Google credential paths may be overridden
with google_client_id_ssm_parameter and google_client_secret_ssm_parameter.
The totp_encryption_key parameter is retained from the previous email + TOTP scheme. It is still provisioned by Terraform but is no longer used by the sign-in path.
Cold-start preload
src/lambda.ts awaits preloadApiSsmParameters() (src/config/ssm-preload.ts) before it serves any request. The preload uses the shared @stralos-co-nz/api-common parameter cache: batched GetParameters calls of up to ten names, with jittered retry on throttling. After the preload, SSM-backed reads answer from memory, and an expired value is still served while the cache refreshes it in the background, so no request waits on Parameter Store.
Two functions run lambda.ts, api and signup, with different environment variables. The preload skips any variable a function is not given. A new *_SSM_PARAMETER read on the request path must be added to the preload list; test/ssmPreload.unit.test.ts fails until it is.
Both the shared Lambda role (
ReadAdminKeyMaterial) and the signup role (ReadAdminSignupMaterial) grant ssm:GetParameter and ssm:GetParameters for the batched preload.
Google sign-in Terraform variables
Enabling Google sign-in requires four steps in order:
- Create the OAuth client in Google Cloud.
- Store the client ID and secret in SSM at the expected paths.
- Set
enable_google_sign_in = true,google_hosted_domain, and (if not using the default)google_redirect_uriin the environment’s.tfvars. - Apply Terraform, then sign in.
enable_google_sign_in = false, the sign-in routes answer 503. With the password path removed, this means the admin console for that environment is unreachable until the flag is enabled.
New environment provisioning
For a fresh environment, Terraform creates all seven parameter objects with a cryptographically randomterraform-bootstrap-pending:<random> placeholder using
a write-only (value_wo) block. The placeholder is ephemeral — it is never
persisted to plan state or Terraform state. value_wo_version = 0 combined with
ignore_changes = [value_wo_version] ensures that subsequent applies do not
overwrite the values written by the finaliser.
For signup_code_pepper, that random initial value is the retained secret rather
than a placeholder awaiting the deployment finaliser.
The prevent_destroy = true lifecycle guard prevents accidental destruction of
parameters that hold live key material.
The four application SecureStrings (database_url, event_signing_key, operator_signing_key, totp_encryption_key) are populated by the deployment finaliser after apply. The two Google sign-in credentials (google_client_id, google_client_secret) have no finaliser; write their real values out of band:
0600 JSON input file containing the
parameter name, value, SecureString type, and Overwrite: true; remove that file
through the approved secrets-handling workflow afterwards. Do not put the client
secret directly in shell history or process arguments.
Until the real values are written, Google sign-in cannot complete successfully.
Adopting pre-existing parameters
Where parameters were provisioned before Terraform ownership, setadopt_existing_ssm_parameters = true in the environment’s .tfvars file. This
activates import blocks for every parameter flagged adopt = true in the
managed-parameters map. Adoption brings those parameters under Terraform management
without replacing their values. The ignore_changes rule on value_wo_version
ensures that the import apply cannot re-seed a parameter that already holds live
key material.
The adopt flag is set only on parameters that genuinely pre-exist in a given
environment. Parameters created fresh by this apply do not carry adopt = true;
including them in an import block would fail the plan because Terraform cannot
import a resource that does not yet exist. Leave adopt_existing_ssm_parameters
at its default false when Terraform should create fresh parameters.
IDP-M2b Terraform variables
The following optional variables enable Cognito provisioning, invite email, the federated identity-link table, and the runtime→admin report channel. Every variable defaults tonull or false; an unset value omits the corresponding feature entirely rather than half-configuring it.
The
cognito and invites schedules are each gated on their respective variables: an
unset cognito_user_pool_id means no cognito EventBridge rule is created, so there
is no periodic failure from a missing pool ID.
Invite mail sends from no-reply@<invite mail domain>. Terraform creates the identity on a dedicated mail subdomain rather than the environment apex, so each environment owns one identity and its DNS records in a single state. It also creates the <prefix>-invites configuration set (TLS required, reputation metrics on) and passes its name to the Lambdas as SES_CONFIGURATION_SET; every send names it. Prod enables ses_feedback_notifications_enabled, because its SES production access relies on bounces and complaints reaching the alerts topic. The account-level SES suppression list (bounces and complaints) is enabled out of band in the account; Terraform does not manage it.
Set region_login_urls whenever mail_hosted_zone_id is set. Enabling invite mail adds INVITE_FROM_ADDRESS to the API and events functions. The API resolves the regional URLs at startup for operator resend, and the events worker resolves them when it dispatches invitations. If neither REGION_LOGIN_URLS nor the single-region INVITE_LOGIN_URL fallback is present, resend configuration cannot initialise and invite dispatch fails. Keep region_login_urls and mail_hosted_zone_id together in each environment’s configuration.
Organisation self-service Terraform variables
The following variables enable the organisation self-service surface (/api/admin/org/*). Both must be set together. Setting only one half leaves the surface in an unsafe or non-functional state, so they are deployed in two separate Terraform applies in a fixed order.
The map key in
user_token_source_map is compared for exact equality against organisations.home_region_key. Spelling it differently from the stored region key (for example aunz instead of au-nz) causes every organisation to receive 403 with no application log entry that names the mismatch.
ADMIN_USER_TOKEN_SOURCES is set on the API Lambda only, not on the shared Lambda environment. Placing it in the shared environment causes an init failure and a 502 on every route across all five Lambda functions, including /health, whenever the value is absent or malformed.
The issuer attribute is optional. Omit it to use the default stralos-api-global. Terraform materialises an unset optional attribute as null, so the Lambda environment encoding explicitly strips the null key before calling jsonencode; the consumer’s schema accepts an absent issuer but rejects null.
Phased deployment
These two variables must be applied in two separate Terraform applies:- Phase 1 — set
org_admin_caller_role_arnsonly. This creates theAWS_IAMmethod chain on/api/admin/org/*and the matching resource-policyDeny. The path is now reachable only by the named roles and still returns503because the verifier is off. - Phase 2 — set
user_token_source_map. This enablesADMIN_USER_TOKEN_SOURCESon the API Lambda and the org self-service routes start answering.
{proxy+} catch-all (no IAM requirement), which places the org self-service surface on the open internet with only the user token as a gate. Phase 1 must be confirmed live in each environment before Phase 2 is applied to that environment.
Release journey probe Terraform variable
service_token_source_map enables the GET /api/admin/service/probe endpoint used by the PRD 03 promotion pipeline to verify a promoted environment answers an authenticated admin request.
The map shape mirrors
user_token_source_map. The issuer attribute is optional; omit it to use the default stralos-api-global. Terraform strips null issuer values before encoding to avoid an empty-string init failure.
ADMIN_SERVICE_TOKEN_SOURCES is a separate environment variable from ADMIN_USER_TOKEN_SOURCES. They are two independent trust decisions. Setting one does not enable the other.
Service probe endpoint
GET /api/admin/service/probe?nonce=<hex> requires a bearer service token with scope admin:probe-read. The response body contains only constants and the caller’s own nonce:
The nonce echo is load-bearing. Without it a
200 response could be satisfied by a cached response or an error page that returned the right shape. The endpoint reads no tenant row, no operator row, and no catalogue item.
The route returns 503 when ADMIN_SERVICE_TOKEN_SOURCES is not configured, 401 when no bearer token is presented or verification fails, 403 when the token does not carry admin:probe-read, and 503 when the api-global JWKS cannot be fetched (distinguished from a bad token to avoid misleading the promotion gate’s negative control).
Deployment environments
TheTerraform deploy workflow plans dev on pull requests. Dev applies run through
the separate manual Deploy dev workflow. Test and production apply through a
validated release promotion or through an explicit manual dispatch carrying the
frozen promotion digest.
Run the workflow with
target=test to deploy the test stack. The repository also contains staging.tfvars with https://admin.staging.stralos.app and event sync disabled, but staging is not a selectable workflow target.
For a first production apply, do not create the Google SSM parameters by hand:
Terraform creates them with bootstrap placeholders. Register
https://admin.stralos.app/auth/callback on the shared Google OAuth client, then
replace both placeholders with the real client ID and secret as described in
New environment provisioning. Until they contain
real values, Google sign-in cannot complete successfully. The
production configuration auto-provisions a verified stralos.co.nz Workspace
account on first sign-in, so no operator bootstrap is required for this path.
When enable_event_sync = false, Terraform omits the event flush, reconciliation, provisioning saga, Cognito sweep, and invite EventBridge schedules and triggers. The API, database-migration Lambda, and events Lambda still deploy; the events Lambda remains present but unscheduled.
Writer cutover step
The deploy workflow includes an optional writer-cutover step that transfers the directory-writer role to the current environment. The step is opt-in: it does not run on a standard push tomain or a normal dispatch. Enable it explicitly when you intend to perform the cutover as part of a deploy.
When the step is enabled and the cutover fails for any reason — including an unreadable or absent marker — the workflow fails closed. The deploy does not continue past a failed cutover. Confirm the marker is readable and the expected writer state is in place before enabling the step.
Infrastructure protection and backups
The production Terraform configuration associates the admin CloudFront distribution (admin.stralos.app) with the shared edge WAF Web ACL. The regional WAF association remains on the API Gateway stage behind it. Edge filtering runs before a request reaches that origin.
The admin RDS instance’s automated backup retention is configured to 30 days in production and 7 days in dev and test. These database backups cover the admin store and its supporting tables.
Monitoring
The admin plane carries CloudWatch alarms covering all admin Lambda functions and the API Gateway entrypoint. Alarms fire to an SNS topic (${name_prefix}-alarms). The topic requires a subscription to deliver notifications; creating that subscription is an operational follow-up and is not provisioned by Terraform.
Lambda alarms
Every admin Lambda has two alarms:- Errors — trips when the function throws (crash, timeout, OOM, or init failure). The threshold is 1 for rarely-invoked functions (
migrate,operator-ops,secret-sync, andsignupwhen enabled) and slightly higher for the traffic-bearing ones (api: 3,events: 5) to account for self-healing retries without masking sustained failures. - Throttles — trips on the first throttle in a 5-minute window.
api and events) also carry an error-rate alarm. It evaluates 100 * errors / invocations only when there are at least 10 invocations in the period, which prevents a low-denominator false alarm from a quiet window. The threshold is 20%. This alarm catches a proportional burst (such as a credential-desync outage) even when the absolute Errors threshold has not been reached.
A Terraform check block compares the alarm map with the Lambda functions already live in AWS and reports a non-blocking plan warning for any gap. A function introduced by the same apply is visible only on a later plan, so this is drift detection rather than a deployment gate.
Secret-sync dead-man’s switch
Thesecret-sync function carries an additional sweep-stalled alarm. It monitors AWS/Events SuccessfulInvocationAttempts on the sweep EventBridge rule (not the Lambda invocation count), so it fires for both a dead schedule and a schedule that fires but fails to deliver. The alarm uses a 1-hour period, evaluation_periods = 3, and treat_missing_data = breaching. If the default hourly schedule is changed, the period must be updated to match.
API Gateway 5xx alarm
Theapi Lambda handles failures internally and returns HTTP 5xx responses rather than throwing. Those responses count as successful Lambda invocations, so the Lambda Errors alarm stays at zero during an application or dependency outage. The API Gateway 5XXError metric captures them instead.
The ${name_prefix}-apigw-5xx alarm trips on the first 5xx in a 5-minute window (threshold = 1, treat_missing_data = notBreaching). It complements the Lambda Errors alarm: together they cover both function-level failures and application-level HTTP failures for the API entrypoint.
Safe error handler
Route handlers map known errors to typed responses. Any error thathandleRouteError does not map is rethrown; previously Fastify’s default handler answered with error.message, which let verbatim Postgres text reach the response body.
The safe error handler (registerSafeErrorHandler from api-common) intercepts every rethrown error and answers with fixed copy plus a correlation reference:
- A database-saturation error (Postgres code
53300) returns503withcode: "service_unavailable"andRetry-After: 5. The driver text never appears in the response; the full error is written to the log under the same reference. - Any other unhandled error returns
500withcode: "internal_error"and a reference. The describer logs onlyname,code,message, andstack— not any additional properties the error carries. This prevents token-claim errors from leaking the decoded JWT payload into structured logs.
API Lambda Postgres pool
The API Lambda’s Postgres pool is capped to prevent connection exhaustion on the shared admin database.
Local development, the CLI tools, and the one-shot Lambda functions (migration, operator-ops) keep the
pg defaults (max: 10, no connection timeout).
Related page
See Admin console frontend for the operator SPA.Lambda activation
Theapi and retention functions expose named live aliases. API Gateway targets the API alias, and the weekly retention schedule targets the retention alias. The dedicated signup integration targets its separate function.
Terraform lists the aliased functions in release_functions and ignores alias function_version changes. In a full deployment, the core post-apply step moves the listed aliases to the versions Terraform published. The API function ignores source_code_hash, so that initial version can hold the previous code: its finaliser subsequently promotes the migrated API artifact to the alias. Retention code is shipped by Terraform, so its post-apply alias move activates that version. Do not assume the post-apply API alias move alone activates the new API artifact.
