feat(api): add player email OTP verification (spec §B2 onboarding)
Forced web onboarding proves a player controls an email before it is bound to their account. POST /api/v1/account/email/start mints a random 6-digit code, mails it (or logs it server-side when no Mailer is wired — the demo has no SMTP), and POST /api/v1/account/email/verify redeems it, flipping users.email_verified in the same transaction that consumes the code. Brute force is bounded two ways: a 10-minute TTL and a 5-attempt cap, both enforced in the repo so the fake and Postgres agree. Only the sha-256 of the code is stored; the digits live only in the email. Both routes are app-tier external — verifying your own email is scoped to the principal, never names another user.
This commit is contained in:
9 files changed
+867
-4
No files matched your search
@@ -1444,6 +1444,93 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/account/email/start:
|
||||
post:
|
||||
tags: [account]
|
||||
operationId: emailOtpStart
|
||||
summary: Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
|
||||
description: >
|
||||
Generates a one-time code bound to the authenticated principal and the
|
||||
supplied address, persists only its hash, and delivers it out of band. The
|
||||
code is never returned in the response. A re-request supersedes the prior
|
||||
unconsumed code.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: app
|
||||
security: [{ accessJWT: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email: { type: string, format: email }
|
||||
responses:
|
||||
'202':
|
||||
description: Code minted and dispatched (or logged server-side when no mailer is wired).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sent, expires_at]
|
||||
properties:
|
||||
sent: { type: boolean, const: true }
|
||||
expires_at: { type: string, format: date-time }
|
||||
'400':
|
||||
description: Missing or malformed email address.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/api/v1/account/email/verify:
|
||||
post:
|
||||
tags: [account]
|
||||
operationId: emailOtpVerify
|
||||
summary: Redeem an email one-time code and mark the caller's email verified (spec §B2).
|
||||
description: >
|
||||
Consumes a previously delivered code for the authenticated principal. On
|
||||
success the user's email is written and email_verified is set true. Too many
|
||||
incorrect attempts lock the code (429); an unknown, expired, consumed, or
|
||||
mismatched code is a 400.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: app
|
||||
security: [{ accessJWT: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Email verified.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [verified, email]
|
||||
properties:
|
||||
verified: { type: boolean, const: true }
|
||||
email: { type: string, format: email }
|
||||
'400':
|
||||
description: Invalid or expired code.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'429':
|
||||
description: Too many incorrect attempts; the code is locked.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/me/submissions:
|
||||
post:
|
||||
tags: [submissions]
|
||||
|
||||
Reference in new issue
Block a user