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 /forgot/request {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 /forgot/reset {token, newPassword}→ updatespasswordHash, marks tokenusedAt.
Sessions & Presence
- Refresh rotation:
POST /identity/refreshexchanges a valid refresh token for a fresh access + refresh pair (rotating refresh). The endpoint now carries a@Throttlecap (10/min short, 100/h long) to blunt token-replay and brute-force attempts. - Presence:
me/push-tokenregistration and other authenticated touches stamplastSeenAt. ApresenceOf(lastSeenAt)helper derives an online / last-seen label surfaced on profile and people screens.
GDPR — Export & Self-Deletion
Right to Access — GET /me/export
Returns a single JSON dossier (format: 'regulus-gdpr-export-v1', with exportedAt) of everything the platform holds about the caller. The profile blob is sanitised before serialisation — passwordHash, apPrivateKey, and apPublicKey are stripped so the export never leaks the credential hash or the actor's ActivityPub signing keys.
Right to Erasure — POST /me/delete
Requires the account password (bcrypt.compare). It does not hard-delete the row in Alpha; instead it locks the account: the passwordHash is overwritten with a locked value (invalidating the password even if a ban is later lifted) and PII is anonymised in a single transaction. Invite codes are marked revoked (not deleted — for audit). AuditLogEntry rows referencing the user keep targetId as a preserved string (no real FK). Deleted accounts use a @deleted.rgls email sentinel so digest/push fan-out skips them.
ADR-009 (draft) proposes legalising Postgres FKs to
Useras the single cross-context exception so cascades carry erasure cleanly; non-identity cross-context FKs stay banned. Awaiting approval.
Events Emitted
| Event | Payload | Trigger |
|---|---|---|
identity.user_registered | {userId, email, inviterUserId?} | After user row created |
identity.invite_code.used | {codeId, newUserId, inviterUserId} | After registration |