feat(provisioning): game-account signup, admin email invites, unlink (2.0)

Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.

- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
  shard (hashed there) and never stored/logged; the end-user browser IP is passed
  for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
  username + password) for the signed-in user and mirrors the link locally. Gated
  by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
  429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
  invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
  mailer.sendInvite (falls back to returning the accept link if email is off);
  public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
  user at the invite's preset role and logs them in, bypassing the registration
  gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
  mirror drop; account.unlinked ingest reconciles the mirror when a player runs
  [unlink in game. account.audit / account.unlinked are logged (admin channel
  only — never on the public SSE allowlist).

Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.

Refs .plans/protocol2-integration.md (Phase 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 15:50:49 -05:00
parent 55a3adea99
commit 91c206bf76
20 changed files with 1150 additions and 8 deletions

View File

@@ -326,6 +326,126 @@
}
}
},
"/api/v1/auth/invite/{token}": {
"get": {
"tags": [
"Auth"
],
"summary": "Look up an email invite by token",
"description": "Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Invite details",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"role": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Invalid or expired invite",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/auth/invite/{token}/accept": {
"post": {
"tags": [
"Auth"
],
"summary": "Accept an email invite (creates the account at the invited role)",
"description": "Creates the website user at the invites pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Account created and session issued",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponse"
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"404": {
"description": "Invalid or expired invite",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Username taken or invite already used",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {}
}
},
"/api/v1/auth/logout": {
"post": {
"tags": [
@@ -7148,6 +7268,239 @@
]
}
},
"/api/v1/admin/users/{id}/shard/link/{account}": {
"delete": {
"tags": [
"Admin · Users"
],
"summary": "Unlink a game account from this user (admin only)",
"description": "Severs a game accounts tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "User id."
},
{
"name": "account",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Game account to unlink."
}
],
"responses": {
"200": {
"description": "Unlinked",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"account": {
"type": "string"
},
"unlinked": {
"type": "boolean"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"403": {
"description": "Protected staff account (refused by shard)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "Not linked",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"502": {
"description": "Bad Gateway"
},
"503": {
"description": "Service Unavailable"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/invites": {
"post": {
"tags": [
"Admin · Invites"
],
"summary": "Create and email an account invite at a chosen access level",
"description": "",
"responses": {
"201": {
"description": "Invite created",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {}
},
"get": {
"tags": [
"Admin · Invites"
],
"summary": "List recent invites (no tokens)",
"description": "",
"parameters": [
{
"name": "limit",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Invites, newest first",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/invites/{id}": {
"delete": {
"tags": [
"Admin · Invites"
],
"summary": "Revoke a pending invite",
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "Invite id."
}
],
"responses": {
"200": {
"description": "Revoked",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "No pending invite to revoke",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/uo-link/config": {
"get": {
"tags": [
@@ -7977,6 +8330,100 @@
}
}
},
"/api/v1/player/shard/account": {
"post": {
"tags": [
"Player · Shard"
],
"summary": "Create a game account (hybrid signup) and link it to the caller",
"description": "Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.",
"responses": {
"201": {
"description": "Account created and linked",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"account": {
"type": "string"
},
"linked": {
"type": "boolean"
}
}
}
}
}
},
"400": {
"description": "Validation error or rejected name/password",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Game-account signup unavailable (site or shard)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"409": {
"description": "Account name already taken",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Per-IP account cap reached",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "Shard unavailable — retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {}
}
},
"/api/v1/player/shard/accounts": {
"get": {
"tags": [