Identity Module
Location: apps/api/src/modules/identity/
Aggregate roots: User, InviteCode, Persona
Invite Codes
Format
REG-XXXXXX- Prefix:
REG- - 6 characters from alphabet
A-Z2-9(no 0/1 to avoid visual confusion) - Generated with
crypto.randomInt— cryptographically secure, notMath.random - Example:
REG-T4K9XA
Allocation Formula
The number of codes a user can generate is capped by their reputation and account age:
const allotment = clamp(5 + Math.floor(rep / 50) + Math.floor(ageDays / 14), 5, 30);| Factor | Contribution |
|---|---|
| Base | 5 codes |
| Reputation | +1 per 50 total rep points |
| Account age | +1 per 14 days since registration |
| Minimum | 5 |
| Maximum | 30 |
In Alpha, codes have a 30-day validity window from generation. Expired unused codes do not count towards the cap.
Reserve → Register Flow
sequenceDiagram
participant User
participant API
User->>API: POST /auth/reserve-invite {code}
Note over API: Check code is 'active'
API-->>User: {sessionId, expiresAt (+30 min)}
Note over User,API: User fills registration form
User->>API: POST /auth/register {sessionId, email, password, ...}
Note over API: Validate sessionId not expired<br/>Create user, mark code 'used'<br/>Create InviteNode<br/>Bootstrap EmojiWallet (50 mana)
API-->>User: {accessToken, user}TTL: Reserved codes revert to active if the registration is not completed within 30 minutes. The cleanup runs via database query on each reserve attempt.
Code Status Lifecycle
active → reserved → used
active → expired (TTL sweep)
active → revoked (admin action)Personas
Users can create up to 5 personas. One persona is always marked isDefault = true — this is created automatically as Main on registration.
Persona Fields
| Field | Type | Notes |
|---|---|---|
name | varchar(40) | Unique per user |
about | varchar(280) | Short bio |
accent | varchar(20) | Color token: ink | gold | sage | azure |
profilePicUrl | text? | Separate avatar per persona |
isDefault | bool | Only one true per user |
Usage
Posts can be attributed to a persona via Post.personaId. A null personaId means the post is under the user's default persona. The composer shows a picker for switching between personas.
API
| Method | Path | Description |
|---|---|---|
| GET | /me/personas | List all personas |
| POST | /me/personas | Create (max 5 enforced) |
| PATCH | /me/personas/:id | Update name, about, accent |
| DELETE | /me/personas/:id | Delete (cannot delete default if others exist) |
| POST | /me/personas/:id/set-default | Promote to default |
Registration Requirements
| Field | Required | Validation |
|---|---|---|
email | yes | Valid email, unique |
password | yes | ≥ 8 chars |
firstName | yes | |
lastName | yes | |
dateOfBirth | yes | Must be ≥ 18 |
country | yes | ISO 3166-1 alpha-2 |
city | yes | |
sessionId | yes | Valid non-expired reservation |
Password Reset
POST /auth/forgot-password {email}→ generates short opaque token, sends email (Resend). In Alpha withoutRESEND_API_KEY, the token is logged to stdout.- Token is valid for 1 hour.
POST /auth/reset-password {token, newPassword}→ updatespasswordHash, marks tokenusedAt.
GDPR Deletion
DELETE /me triggers a cascade delete from users table, propagating to all related rows via Prisma onDelete: Cascade. The user's invite codes are marked revoked (not deleted — for audit). AuditLogEntry rows referencing the user are kept with the targetId preserved as a string (no real FK).
Events Emitted
| Event | Payload | Trigger |
|---|---|---|
identity.user_registered | {userId, email, inviterUserId?} | After user row created |
identity.invite_code.used | {codeId, newUserId, inviterUserId} | After registration |