Skip to content

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, with signup using callsign (display name), email, password and password confirmation. It registers through the same API, signs in automatically and opens Terminal. No email verification is required; workspace two-factor enrollment policies still apply. Front remains an option on the same page.

New links and their QR codes include the issuing Mainframe, for example https://terminal.mainline.inc/invite/K7MX4R9A?mainframe=https%3A%2F%2F1031.mainframe.mainline.inc. Use that explicit origin for validation, signup, sign-in and Front phone enrollment. The web page applies the same host policy as Terminal sign-in. Invalid, empty or duplicate targets are rejected; they never fall back to a different Mainframe. Front 2.5.2 or newer preserves this target when scanning or opening the link.

For a legacy link without mainframe, such as 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. The query parameter is part of the invite URL, not part of the eight-character code. Codes are eight characters from 23456789ABCDEFGHJKLMNPQRSTUVWXYZ; lowercase input is accepted.

GET /api/invites/K7MX4R9A
{
"valid": true,
"workspace": { "name": "Operations" },
"role": { "id": "observer", "name": "observer", "field": false },
"passwordMinLength": 12
}

role.field marks a field-like role: one that may talk on voice (voice.talk) but holds no entity.control. The invite page then asks for the invite to be scanned in Front instead of leading with web signup.

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.

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/join
Content-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.

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/email
Content-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 }) on its live client. Public pages can use joinMainframeInvite(code, input, baseUrl) and signInMainframe(email, password, baseUrl) without opening a WebSocket. The invite page keeps credentials and the retry request ID only in memory.

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.

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.

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.