Security boundary
The admin-event and report routes are private API Gateway operations that require an authorised AWS IAM principal and SigV4:POST /api/global/admin-events route also requires a compact EdDSA JWS
with the expected issuer, audience, environment, region, schema version, and
event identifier. IAM controls who can reach the route; JWS verification
protects the event’s origin and contents.
POST /reports/runtime additionally verifies the compact JWS against the
regional runtime JWKS (api-global’s key set) using a dedicated issuer and
audience pair. Four independent guards prevent a user’s own access token from
being replayed as a report; see the Report channel section below.
API Gateway resource policy
The admin API can restrict its machine-to-machine paths with IAM-authorised API Gateway methods and matching resource-policyDeny statements. Each guard is active only when its Terraform control is configured; otherwise the path continues through the unauthenticated NONE proxy method.
The organisation caller setting is a list because each region has its own execution role. The organisation path is deliberately
org/, not org*; the broader pattern would also match the operator-facing /api/admin/organisations routes.
Each resource-policy guard must be enabled with its matching AWS_IAM method chain. A principal condition on an unauthenticated NONE method has no aws:PrincipalArn, so its Deny condition rejects every caller. The policy also retains AllowEverythingElse so enabling one guarded path does not block the operator API, health check, or public key endpoints.
Enable a guard only after its permitted caller signs the request. The current signup relay’s adminApiFetch helper SigV4-signs requests to an API Gateway execute-api hostname; configure that hostname for the production relay.
Event families
Schema version 1 supports:
The
directory aggregate is distinct from user. Each directory.regions_replaced event carries the user’s full current region set as a replacement (never a delta). An empty regions array is valid and means the user has no regions. Receiving this event enqueues a row in directory_replacement_outbox; the directory-flush Lambda then drains that queue into the DynamoDB user-directory table.
membership.status_changed was added at IDP-M2a. It is the event emitted when
the runtime reports that a user has accepted an invitation. The existing
granted, revoked, and role_changed types cannot express an acceptance, so
a new type was introduced. It reuses MembershipPayload, which now always
includes invitationGeneration.
The retired default_markup_percent and pricing_average_speed_kmh keys remain
recognisable for safe convergence. Receiving either event deletes the legacy
runtime value and advances the aggregate version; it cannot restore the retired
configuration.
Membership payload and invitation generation
Every membership event carriesinvitationGeneration: the identity of the
current invitation incarnation. This field was added to the memberships table
at IDP-M2a. It is incremented only when a membership transitions into invited
from a non-invited state — on the initial grant and on each revoke–re-invite
cycle. A role change, an acceptance, and a reconciliation corrective never
increment it.
The generation exists because the memberships table has a
UNIQUE(user_id, organisation_id) constraint: a revoke followed by a re-invite
reuses the same row and its id. A stale acceptance report — consented before
the revoke, delivered after the re-invite — would otherwise name a row that
still exists and is once again invited, and every other validation check would
pass. Matching the generation exactly closes that hole.
The generation is present on all membership events so the runtime’s projection
holds it regardless of which event last touched the row. An acceptance report
echoes it back, and admin requires an exact match.
Seed template payload
Aseed_template.provisioned event carries the rate categories, disposal substances, and takeoff categories selected for the
new organisation’s business type. New snapshots carry an empty vocab array;
immutable older snapshots may still replay vocabulary items that were frozen
before seed vocabulary was retired. Each rate category in the payload may include:
The payload may also carry a top-level
disposalSubstances array when the snapshot was frozen with a business type (i.e. created after migration 0040). Each item carries:
When
disposalSubstances is absent, the tenant falls back to its own built-in defaults. Immutable older snapshots never carry this field; the absence is permanent and must remain supported by tenant ingest forever.
Both parentGroup and allowedUnitCodes are permanently optional. An
org_seed_snapshots row is immutable once frozen, so a snapshot written before
either field existed can never gain it. Ingest falls back rather than failing the
whole organisation over a missing field in one category.
Disposal substances
The payload may include a top-leveldisposalSubstances array carrying the admin-managed defaults for the organisation’s business type. Each entry has the shape:
The seeding rule depends on whether the field is present:
Each row is range-checked independently. A row with a blank or over-long name, a density or bulking percentage outside its allowed range, or a compaction percentage outside 0–100 is skipped with a warning; the remaining rows are still seeded. One invalid row never fails the whole organisation.
Takeoff categories
The payload may include a top-leveltakeoffCategories array: the admin-managed takeoff categories for the organisation’s business type, frozen into org_seed_snapshots.takeoff_categories (admin-api migration 0047). Each item has one field:
The admin payload builder omits the field when the snapshot has no frozen list (a snapshot written before
0047). The seeding rule depends on whether the field is present:
The contract checks shape only. Each entry is then checked independently: a blank label, a label over the setting-value length limit, the reserved label
All (case-insensitive), or a case-insensitive repeat of an earlier label is skipped with a warning, and the remaining rows are still seeded. All is the takeoff UI’s built-in show-everything filter and is never stored as a category.
Like disposalSubstances, takeoffCategories is permanently optional: an immutable snapshot frozen before the field existed can never gain it.
Seeding is a starting point
The seed is applied on first provision only. When aseed_template.provisioned
event redelivers — on a retry or a daily reconciler re-arm — the ingest handler
does nothing for any category whose (organisation_id, name) row already exists
and is active. It does not overwrite parent_group, is_cartage_vehicle, or
allowed_unit_codes on redelivery.
Disposal substances follow the same rule: ingest skips the substance seed entirely when the organisation already has any substance rows. Tenant edits to seeded substances are never overwritten on redelivery.
Takeoff categories follow the same rule: ingest skips the seed when the organisation already has any takeoff_categories rows, soft-deleted rows included. That zero-row gate is why organisation.created first-sighting handling no longer seeds takeoff categories: it is delivered before seed_template.provisioned, and seeding there would stop the business type’s list from ever landing.
The seed establishes an initial value; the tenant then owns those rows.
Ingest outcomes
The ingest handler verifies the event and applies one aggregate version in a database transaction:- return the recorded state when the event ID was already seen;
- create and lock the aggregate high-water row;
- acknowledge an older version as
superseded; - apply the projection;
- advance the high-water mark; and
- record the event ID as seen.
200 with an outcome
and applied version.
The transaction rolls back projection, version, and idempotency changes together
when application fails.
Replica contract
The replica route acceptsaggregateType and aggregateId. It returns the
runtime projection plus its applied version, allowing the admin reconciler and
provisioning saga to verify delivery independently of a successful ingest
response.
A projection may have state: null after deletion. A seed-template
acknowledgement returns its organisationId. 404 means the runtime has no
high-water mark for that aggregate.
For the directory aggregate the response also includes two sibling fields
alongside state:
anchorVersion— thedirectoryVersionwritten to the DynamoDB anchor item, or0when no anchor exists yet. A non-zero applied version with a zero anchor version means a replacement event was received but has not yet been flushed to DynamoDB.replacementPending—truewhen the outbox holds at least one pending row for this user.
directory replica query. A
non-writer returns 400 with code not_directory_writer. A writer that has not
been configured with DIRECTORY_WRITER_REGION_KEY returns 503 with code
directory_writer_unconfigured.
Directory replacement outbox (IDP-M3b)
directory.regions_replaced events are applied by enqueuing a row in
directory_replacement_outbox. The separate directory-flush Lambda drains
that queue into the DynamoDB user-directory table, which is keyed by userId
(not email).
Each outbox row records the user ID, a monotone directoryVersion, and the full
replacement region set. The Lambda processes at most one pending row per user per
run (oldest first) under a per-user advisory lock. It applies a
TransactWriteItems that conditionally updates the anchor item, puts each new
region row, and deletes removed region rows. The anchor condition makes a stale
or delayed replacement inert — it can never be applied if the table already holds
an equal or higher version.
A row moves through these statuses:
The
directory-flush Lambda is deployed in every region but only writes DynamoDB
in the directory-writer region. The writer is identified by the
DIRECTORY_WRITER_REGION_KEY environment variable. A non-writer invocation
no-ops and logs a single line. Three independent mechanisms enforce single-writer
behaviour: EventBridge schedules the Lambda only in the writer region; the DynamoDB
write IAM policy is attached only there; and the Lambda guard no-ops if the env
var disagrees with the current REGION_KEY.
DIRECTORY_WRITER_REGION_KEY is also required by the admin API’s shared application constructor. Set it in the common Lambda environment, not only on the events worker: the API validates it at startup, while the events worker uses it for directory delivery.
The daily reconcile mode re-arms exhausted rows only. It does not read
DynamoDB; content reconciliation is admin’s responsibility because the authority
for a user’s correct region set lives in the admin store, not in this region’s
outbox.
The directory read path in auth/region-directory.ts queries the same DynamoDB
table on every login. It returns bare region key slugs. The shell’s post-auth region chooser that
displayed them was retired with its regional callback. The read uses a 1.5-second timeout and excludes the
#meta anchor item from results.
Admin delivery and reconciliation
The admin outbox keeps one ordered stream per aggregate. Lower versions block higher versions until processed. A seed-template event also waits for its organisation predecessor. Reconciliation compares admin state with the runtime replica. It can re-arm missed or conflicted rows; the normal flusher then redelivers them. The provisioning saga activates a new organisation only after both required outbox rows are processed and independently visible through the replica route.Asymmetric corrective rule (§6.2)
When reconciliation finds that admin holdsstatus: invited while the runtime
holds status: active for a user or membership, it suppresses the corrective
rather than reverting the runtime’s state. This difference means the runtime
made an optimistic flip on acceptance, which is correct behaviour during the
delivery window. Reverting it would lock out a user who had just accepted an
invitation.
Every other status difference still emits a corrective unconditionally, including
both security-critical cells where admin holds disabled.
When suppression fires, reconciliation alerts with a diagnosis from the
runtime_report_seen ledger, which records every report received. The diagnosis
names the specific cause and carries the repair action:
The governing row is selected by
invitationGeneration, not by recency: rows
accumulate across a membership’s lifetime, and arrival order is not transition
order.
Signup channel
The browser-facing signup endpoints arePOST /api/login/signup/start, POST /api/login/signup/verify, and POST /api/login/signup/complete. They terminate at api-login on the login host. Api-login relays the same request body to the corresponding admin-api route; a browser must not call admin-api directly.
The signup flow is three steps:
- Start: the browser submits an email address. Admin sends a one-time verification code and returns
202 Accepted. - Verify: the browser submits the email address and the eight-digit code. Admin checks the code and returns
{ status: "verified" }without consuming the code or creating any account. This step confirms mailbox control before the browser collects organisation and user details. - Complete: the browser submits the email, the same code, organisation details (including an optional
countryCode), a password, and an optional mobile number. Admin re-checks the code, creates the Cognito account and tenant in order, and returns the new organisation and user.
+, -, ( and ), with at least seven digits. An invalid value fails body validation with 400 before the code is re-checked. A valid mobile is written to signup_intents.mobile (migration 0044) only after the code matches.
Signup is relayed by the login service rather than a regional API because no region exists until the completion request selects the organisation’s home region.
Dedicated signup runtime
The signup routes run on a dedicated Lambda and execution role rather than the shared admin API Lambda. API Gateway routes only/api/admin/signup/* to this function; it runs the same Fastify application as the admin API, but with signup-specific configuration and permissions.
The role can call only cognito-idp:AdminCreateUser and cognito-idp:AdminSetUserPassword against the configured user pool. Do not add actions merely because the shared Cognito client implements them. AdminSetUserPassword is pool-wide, so treat the signup function as an account-provisioning security boundary.
Terraform’s signup_enabled variable controls the Lambda, its role, and its Cognito and SES grants. The API Gateway method chain may also exist for the separately guarded signup channel, and the code-pepper parameter exists independently of this flag.
Enabling signup also requires outbound invite mail and a
region_login_urls entry for the configured region. Terraform checks these dependencies before creating the function.
Verification-code pepper
Terraform creates the signup verification-code pepper as an SSM SecureString in every environment. It supplies the parameter name throughSIGNUP_CODE_PEPPER_SSM_PARAMETER; the Lambda resolves the value at runtime. Only the signup execution role can read this parameter.
For local development and tests, SIGNUP_CODE_PEPPER may contain a literal value of at least 32 characters. Do not put the literal pepper in a Lambda environment or Terraform configuration, where it could be exposed in state or plan output.
Transport and command authentication
The complete signup boundary uses two independent controls whensignup_relay_role_arn is configured:
- API Gateway verifies the request’s SigV4 signature and restricts the path to api-login’s configured execution role.
- Admin-api verifies an EdDSA command in the
x-stralos-signup-commandheader.
stralos-login-<env>, audience stralos-admin-signup-<env>, protected-header type stralos-signup+jwt, and relay key login. Admin also rejects a principal-token payload, a command signed with admin’s own key material, a cross-environment command, a mismatched operation, or a body-hash mismatch.
Each step uses a distinct command type (signup.start_requested, signup.verify_requested, signup.complete_requested). A start command is not usable on the verify or complete route, and vice versa.
The JWS contains metadata only: schema version, command ID and type, relay key, environment, SHA-256 request-body hash, and an optional client IP hint. The email, organisation details, password and mobile number remain in the TLS-protected HTTP body; none of them is copied into the token.
Configuration
ADMIN_SIGNUP_SOURCES is a JSON object keyed by relay key. The login relay entry has this shape:
ADMIN_SIGNUP_SOURCES unset leaves the admin signup routes unavailable. An empty object is invalid configuration. SIGNUP_ENABLED=true and ADMIN_SIGNUP_SOURCES must be configured together; admin refuses to start when only one half is enabled.
Set Terraform’s signup_relay_role_arn to api-login’s execution-role ARN only after the relay is sending SigV4-signed requests. This switches the signup routes from the open NONE proxy to their AWS_IAM methods and enables the matching resource-policy guard. Without it, the JWS remains the only admin-side application guard and the documented two-layer boundary is incomplete.
Open beta signup channel
The open beta runs the same intent, code, Cognito and tenant flow as paid signup on the same signup Lambda, relay, signed command and body binding. The intent is markedchannel = 'beta', the beta has its own caps, and no card is taken. Paid signup is unchanged, except that its daily email budget no longer counts beta intents.
Every route verifies the relay command before applying the gate, so an unsigned caller learns nothing about the beta, not even whether it is open. With the flag off, or a cap reached on start or complete, the route answers
503 with code beta_signup_closed. The complete schema has no discountCode; a value sent is stripped before it can reach the organisation row. A code issued by a paid start cannot finish a beta signup.
The relay uses its own command types for the beta, verified by a separate verifier, so a paid signup command is not valid on a beta route and the reverse:
Caps. The caps count
signup_intents rows with channel = 'beta'. The total-company cap is re-checked inside the complete transaction under a transaction-scoped advisory lock, so two completes cannot both pass at the limit.
Terraform passes these to the signup Lambda as
SIGNUP_BETA_ENABLED, SIGNUP_BETA_DAILY_EMAIL_BUDGET, SIGNUP_BETA_DAILY_ORG_LIMIT and SIGNUP_BETA_MAX_ORGS. The caps are read only when SIGNUP_BETA_ENABLED is exactly true.
Completion notice. After a beta complete commits, the signup Lambda asynchronously invokes the events worker (mode: "beta-signup-notice"), which emails signup_support_email a “New Stralos beta signup [env]” notice. It says the company joined the open beta (free for 14 days, no card) and gives the beta end date in Pacific/Auckland time. The 14 days mirror api-billing’s beta length; api-billing is what ends access. The worker locks the intent row, skipping one another sender holds, and sets completion_notified_at in the same transaction, so each notice is sent at most once. A dispatch failure never changes the signup response; the hourly signup-support pass retries unsent notices for 48 hours.
Daily report. The “Finished details but didn’t pay” section counts paid signups only. The “Open beta” section shows the number of beta companies, those created the previous day, those whose beta ended without a card in the last seven days (billing active with a period end in that window), and those with no billing record. Companies with no billing record cannot use Stralos; the report names them as candidates for stralos-api-billing’s beta:grant.
Migration. 0045_signup_intent_channel adds two nullable columns to signup_intents with no backfill and no cutover marker: channel (varchar(16), NULL for paid signup) and completion_notified_at. A partial index on (channel, created_at) WHERE channel IS NOT NULL serves the beta cap queries.
Signup support notifications
Whensignup_support_email is non-empty, the admin events worker emails that address about signup activity. Neither message contains a verification code, link, or password. Both reuse the invite sender and configuration set.
Immediate notice. When SES accepts a verification email for an address that is not already registered, the signup Lambda stamps signup_intents.verification_sent_at and invokes the events worker asynchronously (mode: "signup-verification-notice"). The worker sends a plain-text notice with the email address, request time, environment, region key, and signup request ID, then stamps support_notified_at. The signup Lambda queues the same dispatch for registered addresses, and the worker skips them, so response timing does not reveal whether an account exists. Cooldown-suppressed requests do not notify. A dispatch failure never changes the signup response. Delivery is at least once: a crash between SES acceptance and the database commit can duplicate a notice.
Daily unfinished-signup digest. The hourly signup-support EventBridge rule (cron(0 * * * ? *)) runs the worker. On the first tick at or after signup_report_hour in signup_report_timezone, it sends one digest with these sections:
- Didn’t finish signing up. Addresses that requested verification on an earlier calendar day and have no user and no completed signup. An address stays on each day’s digest until signup completes or its latest request is 14 days old. Each entry shows the first and latest request times, request count, stage reached, and the latest recorded mobile number or “Mobile: not provided”.
- Finished details but didn’t pay. Paid-signup organisations created on an earlier calendar day within the last 14 days whose billing record is missing or not
activeortrialing. Each entry shows the email address, company name, time the details were finished, billing state and mobile number. The worker reads billing with adynamodb:GetItemgrant limited toORG#keys on the billing entitlements table, the same grant the API Lambda has, added to theevent_flusherrole. When billing cannot be read, the heading still appears with a note that the list is missing, so a failed read is never mistaken for an empty list. - Open beta. Present only once the open beta has created a company. See Open beta signup channel.
signup.incomplete_report audit row keyed by the local date keep the digest to one send per day. A day with nothing to report and a readable billing record sends no email but still writes the audit row. The same tick retries up to 100 pending immediate notices and any unsent beta signup notices.
Terraform passes these to the worker as
SIGNUP_SUPPORT_EMAIL, SIGNUP_REPORT_TIMEZONE, and SIGNUP_REPORT_HOUR, and sets SIGNUP_SUPPORT_FUNCTION_NAME on the signup Lambda with a lambda:InvokeFunction grant on the events worker. The signup-support schedule exists only when enable_event_sync, signup_enabled, invite mail, and signup_support_email are all set. Without it, no digest is sent and the hourly retry of pending immediate notices does not run. Apply migrations 0038_signup_support_notifications and 0044_signup_intent_mobile before promoting code that reads or writes their columns.
Signup mobile for billing. stralos-api-billing stores no personal data, so its “New Stralos signup” email reads the mobile from the admin events worker. Billing invokes the worker through IAM Lambda Invoke with { mode: "signup-mobile", organisationId }. The worker returns { mobile }: the most recently updated non-null mobile on that organisation’s signup_intents rows, or null. The invoke grant is defined in stralos-api-billing’s Terraform.
Signup email IAM policy
The signup role sends verification codes through SES. Its policy allowsses:SendEmail on the invite configuration set and the sendable identity list, with ses:FromAddress fixed to the configured invite sender.
The sendable identity list is the sender’s domain identity plus, in dev and test only, recipient identities for stralos.co.nz (both the domain and address forms) so that sends succeed while those accounts are in the SES sandbox. This is an allowlist in ses.tf, not a .tfvars setting, so it cannot be copied into prod. The sender condition is the security boundary: the role cannot send as another verified address. If SES returns 403 for a verification-code recipient in a sandboxed account, confirm the recipient is covered by the identity list and the ses:FromAddress condition matches INVITE_FROM_ADDRESS.
Membership acceptance channel
POST /api/admin/memberships/accept is the invitation-acceptance path for the central M7 ceremony. The login service captures consent on the login host, then relays the acceptance here as a signed command. Admin performs the database transition and emits the authoritative membership.status_changed event.
The route is served by the main API Lambda, not the signup Lambda. It is configured independently of signup: ADMIN_MEMBERSHIP_SOURCES feeds its verifier, and setting ADMIN_SIGNUP_SOURCES on the API Lambda instead would trip the half-configured throw (signup verifier present, SIGNUP_ENABLED absent) and refuse every admin console request from cold start.
Transport and command authentication
Whenmembership_accept_caller_role_arn is configured, two independent controls are active:
- API Gateway verifies the SigV4 signature and restricts the exact path to api-login’s configured execution role.
- Admin-api verifies an EdDSA command in the
x-stralos-membership-commandheader.
typ separate them: stralos-admin-membership-<env> and stralos-membership+jwt. A captured signup command is not valid here, and a captured acceptance command is not valid as a signup.
The request body is bound by SHA-256 hash inside the JWS. Without this binding, a captured acceptance token would authenticate the relay rather than the specific request, and could be replayed against a different (userId, organisationId) pair — accepting an invitation the user never clicked.
Terraform variable
Set
membership_accept_caller_role_arn only after the relay is sending SigV4-signed requests. The variable controls the IAM-authorised method chain, the matching resource-policy Deny, and ADMIN_MEMBERSHIP_SOURCES on the API Lambda — all three arrive in one terraform apply. Configuring them out of order is not possible.
The DenyMembershipAcceptToEveryoneButTheRelay policy statement is scoped to the exact path /api/admin/memberships/accept (and /api/admin/memberships/accept/* for slip-proofing). It does not cover /api/admin/memberships or /api/admin/memberships/{id}, which remain on the open root proxy for the operator console.
Configuration
ADMIN_MEMBERSHIP_SOURCES is a JSON object keyed by relay key. It uses the same shape as ADMIN_SIGNUP_SOURCES:
ADMIN_MEMBERSHIP_SOURCES unset leaves the route answering 503. The fallback to ADMIN_SIGNUP_SOURCES applies only in a topology where signup runs in the main API function — in production, where signup has a dedicated Lambda, set ADMIN_MEMBERSHIP_SOURCES explicitly.
signup_relay_jwks_url must be set; it is the source Terraform reads to populate ADMIN_MEMBERSHIP_SOURCES. The membership_accept_caller_role_arn precondition enforces this: arming the transport into a misconfigured verifier is rejected at plan time.
Route behaviour
The route accepts{ userId, organisationId } and returns the membership outcome:
Both outcomes return
200. A repeated accept after a lost response carries the same consent and is not a conflict.
The route returns 503 when the verifier is not configured, 401 when the membership command header is absent, and 403 when the command cannot be verified, names a different operation, or does not match the request body.
Sign-out everywhere relay
POST /api/admin/sessions/revoke-all is the login-host path for sign-out everywhere. The login service validates the user’s refresh token against the central session store, then relays the revocation here as a signed command. Admin increments users.session_generation and emits user.updated to every region the user has been projected into.
This path exists because the login host has no regional runtime database. The report channel (POST /reports/runtime) is unavailable to it, so the revocation cannot travel the same way a regional sign-out request does. The transaction is the same one applySessionRevocation runs, so every region learns the new generation through the normal user.updated event.
There is 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.
Transport and command authentication
Whensession_revocation_caller_role_arn is configured, two independent controls are active:
- API Gateway verifies the SigV4 signature and restricts the exact path to api-login’s configured execution role.
- Admin-api verifies an EdDSA command in the
x-stralos-session-revocation-commandheader.
stralos-admin-session-revocation-<env>) and protected-header typ (stralos-session-revocation+jwt) are distinct from all other families. A captured command from any other family is not valid here.
The request body is bound by SHA-256 hash inside the JWS. Without this binding, a captured revocation command would authenticate the relay rather than the specific request, and could be replayed against a different userId, revoking sessions for a user who never requested it.
Terraform variable
Set
session_revocation_caller_role_arn only after the relay is sending SigV4-signed requests. The variable controls the IAM-authorised method chain, the matching resource-policy Deny, and ADMIN_SESSION_REVOCATION_SOURCES on the API Lambda — all three arrive in one terraform apply.
Configuration
ADMIN_SESSION_REVOCATION_SOURCES is a JSON object keyed by relay key. It uses the same shape as ADMIN_SIGNUP_SOURCES:
ADMIN_SESSION_REVOCATION_SOURCES is unset, the route falls back to ADMIN_MEMBERSHIP_SOURCES. Set it explicitly in environments where the verifier configuration may differ.
Route behaviour
The route accepts{ userId } and returns the revocation outcome:
A
200 response with outcome: "revoked" also carries userId and the new sessionGeneration. A 404 is returned when no user with that id exists. The route returns 503 when the verifier is not configured, 401 when the command header is absent, and 403 when the command cannot be verified, names a different operation, or does not match the request body.
The route is not idempotent by design. A second increment is monotone and strictly stricter than the first — it cannot resurrect a session — so replaying a captured command signs the same user out harder rather than causing a harmful state change.
Sign-in activation relay
POST /api/admin/activations/sign-in is the canonical activation path for a user who signs in on the login host. The login service holds proof of authentication (a verified ID token bound to users.id) and relays the activation here as a signed command. Admin-api performs the activation in one transaction under the user derivation lock.
The route is the fourth relayed command family, after signup, membership acceptance, and session revocation. It uses its own typ, audience, and command-type enum so that a captured command from any other family cannot be spent here.
Transaction behaviour
The transaction has three arms, each safe to repeat:
Every arm is idempotent by state machine. A retry, a second sign-in, or a replayed command within the command’s lifetime answers
unchanged and writes nothing.
The response carries the user’s current status so the login service gate can still refuse a disabled account even if the account was disabled between authentication and the relay arriving.
Transport and command authentication
Whenuser_activation_caller_role_arn is configured, two independent controls are active:
- API Gateway verifies the SigV4 signature and restricts the exact path to api-login’s execution role.
- Admin-api verifies an EdDSA command in the
x-stralos-user-activation-commandheader.
stralos-admin-user-activation-<env>) and protected-header typ (stralos-user-activation+jwt) are distinct from all other families. The request body is bound by SHA-256 hash inside the JWS to prevent a captured token being replayed against a different userId.
Terraform variable
Set
user_activation_caller_role_arn only after the relay is sending SigV4-signed requests. The variable controls the IAM-authorised method chain, the matching resource-policy Deny, and ADMIN_USER_ACTIVATION_SOURCES on the API Lambda — all three arrive in one terraform apply.
Configuration
ADMIN_USER_ACTIVATION_SOURCES is a JSON object keyed by relay key. It uses the same shape as ADMIN_SIGNUP_SOURCES:
ADMIN_USER_ACTIVATION_SOURCES unset leaves the route answering 503.
Route behaviour
The route accepts{ userId } and returns the activation outcome:
All three outcomes return
200 along with the user’s current userId and status. A 404 is returned when no user with that id exists. The route returns 503 when the verifier is not configured, 401 when the command header is absent, and 403 when the command cannot be verified, names a different operation, or does not match the request body.
Report channel
POST /reports/runtime is the runtime→admin report channel. The runtime
reports three event types:
A report is a command, never a direct admin-store write. The runtime reports either a completed
authentication ceremony or a user’s regional sign-out-everywhere request. Admin validates the assertion
against its own state and performs the transition through its normal write path.
Activation paths
User activation has three paths with different side effects:
The login service now relays sign-in activations to admin-api via
POST /api/admin/activations/sign-in. Admin-api performs the full activation in one transaction: the status flip, last_login_at, an audit row, and a user.updated event fanned out to all regions. This is the path taken for users who sign in on the login host.
The direct login-service path (a central-only CAS on users.status) pre-dates the relay and remains available for backwards compatibility. Its database role intentionally cannot write last_login_at, the audit log, or the admin outbox, so regional projections can remain invited. Users activated by the old path are repaired automatically: the relay recognises an active row with last_login_at IS NULL as a legacy central-only activation and propagates full state to regions on the user’s next sign-in.
The runtime report channel remains available for regional sign-in ceremonies. Treat the relay or the report-channel path as the complete activation path when diagnosing a user who can sign in but cannot access projects in a region.
Transport and authorisation
Two independent layers are required, matching the admin→runtime outbound channel:- API Gateway IAM — a resource policy restricts the route to the runtime’s env-scoped flusher role.
- EdDSA JWS — the compact JWS is verified against the regional runtime JWKS
(
ADMIN_REPORT_SOURCES). Four guards prevent a user access token from being replayed as a report:- The issuer/audience pair is
stralos-runtime-<env>-<regionKey>→stralos-admin-reports-<env>, distinct from the user-token pair. - The protected header must carry
typ: stralos-report+jwt. - The payload must carry no
typclaim (all user and service tokens carry one). - The verifying key must not be any of admin’s own signing key material
(compared by RFC 7638 thumbprint, not by
kid).
- The issuer/audience pair is
ADMIN_REPORT_SOURCES environment variable is a JSON object keyed by
region key. Each entry carries an issuer and a jwksUrl. An empty map is
rejected at startup (distinguish “no regions configured” from “channel disabled”).
Unset the variable entirely to leave the channel unconfigured; the route then
returns 503.
Report envelope
invitationGeneration is a top-level envelope field on acceptance reports, not
nested inside payload. Sending it inside payload is rejected. It must be
absent on activation and session-revocation reports; either report is rejected
if it carries one.
Validation and rejection codes
Admin validates the report against its current state. The route returns202
on success (applied or noop) and 409 on a definitive rejection. The following
codes apply to the membership-acceptance arm:
The
stale_invitation check runs before status checks, so a superseded consent
always receives a 409 rather than a 202 noop, and the corrective always
carries admin’s real current state.
For membership_mismatch: despite being an anomaly that pages an operator, admin
does hold the row and emits a corrective. The corrective carries admin’s own user
and organisation, repairing the mismatched fields as well as the status.
Idempotency and replay
Theruntime_report_seen table records every report by eventId (primary key).
A duplicate report replays the original decision verbatim — the outcome, the
rejection code, and the HTTP status code — rather than re-evaluating. Re-evaluation
would give a different answer than the one already committed, because admin state
has moved on. The duplicate: true flag is included in the response body.
Corrective obligations
Every rejection that admin can describe emits admin’s current full-state event through the normal outbox. This corrects the runtime’s optimistic flip; without it the runtime would hold an incorrectactive indefinitely. The emitted
corrective event id is recorded in runtime_report_seen.corrective_event_id
for the §6.2 runbook.
A noop response (membership already active) still emits a corrective when
admin’s user is disabled or admin’s organisation is not active, because §4.5.1
requires it as a second chance for a lost user or organisation event.
Invitation opt-out
Invitation emails (the account invite and the additional-organisation notice) carry RFC 8058List-Unsubscribe and List-Unsubscribe-Post headers and a visible link: “Not expecting this? Stop Stralos invitations to this address”. A recipient who uses either is recorded in the admin store’s invitation_opt_outs table (migration 0037), keyed by the lower-cased address, and receives no further invitations. The address does not need an account.
Opt-outs cover invitation mail only. Sign-in codes, signup verification codes, and password resets are requested by the person and never consult the table; an opt-out there could lock someone out of their own account.
Unsubscribe endpoint
/api/admin/invitations/unsubscribe is public and unauthenticated; the signed token query parameter is the whole authority.
GETrenders a confirmation page and changes nothing, because mail scanners and link previewers fetch every URL in a message.POSTrecords the opt-out. Mail clients send it for RFC 8058 one-click unsubscribe, and the confirmation page submits it.
400; an environment without a configured key returns 503. The route is rate limited.
The token is a versioned HMAC-SHA256 of the recipient address under a per-environment key. It is a capability over one address: it cannot be forged for an address that was never sent one, and it can only stop invitations to that address. It deliberately never expires, because unsubscribe links are used weeks after delivery. Rotating the key would invalidate every link already delivered, so nothing rotates it.
Configuration
Without both a URL and a key, invitations are sent without unsubscribe headers or link. The opt-out check itself does not depend on the key.
Enforcement
Every path that sends an invitation refuses an opted-out address with409 invitation_opted_out (“This person has opted out of Stralos invitations.”), which the admin UI shows verbatim:
- Organisation admin invite and resend (
POST /api/admin/org/users,POST /api/admin/org/users/:userId/resend-invite). - Operator create, grant, and resend paths.
- The invite dispatchers, which skip opted-out addresses, and the final send step.
- The Cognito sweeper, which suppresses a
resend_inviteoperation queued before the opt-out, because Cognito’s own resend message cannot carry an unsubscribe.
Reversing an opt-out
Rows are never deleted. Only the owner of the address can ask for a reversal, by writing to support from that address; a request from the inviting organisation is not enough. Support then setsreversed_at = now() on the row. A later unsubscribe re-arms the same row (new opted_out_at, reversed_at cleared). The operational procedure is in the stralos-docs support runbook.
