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; registration is an API operation for clients.

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.

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 }) without requiring a WebSocket connection or session. Credential creation and retention belong to the consuming client.

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.