Invite registration API
An invite authorizes new workspace accounts with a fixed role. Codes are reusable until retired or their successful registration limit is reached. Opening or validating a link does not create an account. The public Terminal page shows availability, workspace and role; registration is an API operation for clients.
Discover and validate
Section titled “Discover and validate”For https://terminal.example/invite/K7MX4R9A, fetch
https://terminal.example/.well-known/terminal.json:
{ "version": 1, "mainframeUrl": "/" }/ means this Terminal origin. A split deployment returns an absolute Mainframe
origin instead. Resolve it against the Terminal origin, require HTTPS outside
localhost, and do not substitute a previously saved server. Codes are eight
characters from 23456789ABCDEFGHJKLMNPQRSTUVWXYZ; lowercase input is accepted.
GET /api/invites/K7MX4R9A{ "valid": true, "workspace": { "name": "Operations" }, "role": { "id": "observer", "name": "observer" }, "passwordMinLength": 12}Validation is advisory: availability is checked again atomically during joining.
Malformed, unknown, exhausted, retired and missing-role codes all return 404
invite-unavailable. There is no unauthenticated invite listing or usage count.
Register
Section titled “Register”Submit the client’s supplied credentials as JSON. Email-format identifiers are
required; synthetic addresses such as device-42@users.invalid are accepted.
Names are 1–60 characters after trimming; emails are trimmed, lowercased and
limited to 200 characters; passwords must meet the returned minimum and be at
most 256 characters. Extra fields, including role and verified-email flags, are
rejected. Request bodies are limited to 4 KiB.
POST /api/invites/K7MX4R9A/joinContent-Type: application/json
{ "name": "Field team", "email": "device-42@users.invalid", "password": "client-supplied-password", "requestId": "5c36423a-adb6-4cfe-bbe0-318f4381be64"}New registration returns 201:
{ "userId": "01M35000000000000000000000", "email": "device-42@users.invalid", "joinedAt": 1790092800000, "replayed": false}joinedAt is UTC Unix milliseconds. Registration creates the account, password
credential, membership, invite provenance and successful-use count in one
transaction. It sets no session cookie and returns no password or login token.
The address remains unverified; there is no email-delivery requirement.
Existing accounts are never linked, upgraded or overwritten by an invite.
Reuse the same requestId and payload after a timeout or lost response. A
successful retry returns 200, the original receipt and replayed: true,
without consuming another use—even if the invite has since been retired or
exhausted. A UUID is scoped to one invite. Changing any normalized input with a
used requestId returns 409 idempotency-conflict. If the original account or
membership was deleted, retry returns 409 account-unavailable; it cannot
recreate it. Preserve the original payload for retries after a password change.
Sign in
Section titled “Sign in”After registration, use ordinary BetterAuth email/password sign-in and retain the returned session cookies using the client’s normal cookie handling:
POST /api/auth/sign-in/emailContent-Type: application/json
{ "email": "device-42@users.invalid", "password": "client-supplied-password" }New invite accounts have no forced temporary-password change. Workspace 2FA,
enrollment, ban and subsequent administrator-reset policies still apply. A
successful join does not bypass them. Browser cross-origin calls require
credentials and a configured trusted origin; native calls may omit Origin.
Generic /api/auth/sign-up/email remains closed.
The TypeScript SDK exposes validateInvite(code) and
joinInvite(code, { name, email, password, requestId }) without requiring
a WebSocket connection or session. Credential creation and retention belong to
the consuming client.
Errors and retrying
Section titled “Errors and retrying”Errors use { "error": { "code": "…", "message": "…" } }. Invite responses
are Cache-Control: no-store; do not record codes or passwords in client logs.
| HTTP | Code | Client handling |
|---|---|---|
| 400 | invalid-body, password-too-short |
Correct the input |
| 404 | invite-unavailable |
Obtain another invite |
| 409 | email-unavailable |
Existing account cannot join again; use ordinary sign-in |
| 409 | idempotency-conflict |
Restore the original request payload |
| 409 | account-unavailable |
Original account was removed; contact an administrator |
| 429 | rate-limited |
Honor Retry-After and keep the same request ID |
| 503 | join-unavailable |
Retry the same request after backoff |
| 403 / 415 | Origin / content-type rejection | Use the configured origin and JSON |
Defaults are 30 validations/minute/IP, 5 join attempts/minute/IP and 60 join attempts/minute/code. Retries also count as attempts, but never as another successful invite use. Limits persist in PostgreSQL. Shared deployments can configure these limits and trusted proxies explicitly.
Administrator API
Section titled “Administrator API”All /api/access/invites operations require system.admin:
| Operation | Request / response |
|---|---|
GET /api/access/invites?limit=50&cursor=…&state=active&roleId=observer |
{ invites, nextCursor }; filters optional; maximum page size 100 |
POST /api/access/invites |
{ "label": "Field team", "roleId": "observer", "maxUses": 20 } → 201 { invite } |
PATCH /api/access/invites/:id |
{ "expectedRevision": 1, "label": "Updated", "maxUses": null } → { invite } |
POST /api/access/invites/:id/retire |
{ "expectedRevision": 2 } → { invite } |
GET /api/access/users?inviteId=… |
Users filtered by original invite ID |
maxUses: null means unlimited (the create default). Role is required and
immutable; create another invite to use a different role. Limits cannot be less
than successful usage. Revision conflicts return 409 revision-conflict and
require a refresh; successful joins also advance the revision. Retirement is
permanent and repeated retirement is harmless. There is no delete operation.
User deletion does not reclaim uses. Roles referenced by any non-retired invite,
including an exhausted one, cannot be deleted.
User rows include joinedAt and
joinedViaInvite: { id, code, roleIdAtJoin } | null. This history survives invite
retirement and later user-role changes. Invite audit events contain IDs and
counts, with codes and credentials omitted.
Deployment and local verification
Section titled “Deployment and local verification”Set Mainframe’s TERMINAL_PUBLIC_URL to the canonical HTTPS Terminal origin.
Development permits localhost HTTP. Missing production configuration disables
invite creation visibly. Set VITE_MAINFRAME_URL when building a split-origin
Terminal; otherwise discovery uses /. Deploy the additive 0002_invite_codes
migration and Mainframe before publishing Terminal. Preserve these tables and
joined accounts during rollback.
The opt-in fixture apps/mainframe/test/invite-postgres.test.ts requires the
isolated localhost:25432/invite_test PostgreSQL database, with migrations
applied. Run with INVITE_POSTGRES_TEST=1, MAINFRAME_DEV_FIXTURES=1,
NODE_ENV=test and its DATABASE_URL. It exercises real HTTP joins/retries,
email sign-in, password changes/reset, TOTP, caps, retirement and rollback.