Skip to content

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, not Math.random
  • Example: REG-T4K9XA

Allocation Formula

The number of codes a user can generate is capped by their reputation and account age:

typescript
const allotment = clamp(5 + Math.floor(rep / 50) + Math.floor(ageDays / 14), 5, 30);
FactorContribution
Base5 codes
Reputation+1 per 50 total rep points
Account age+1 per 14 days since registration
Minimum5
Maximum30

In Alpha, codes have a 30-day validity window from generation. Expired unused codes do not count towards the cap.

Reserve → Register Flow

mermaid
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

FieldTypeNotes
namevarchar(40)Unique per user
aboutvarchar(280)Short bio
accentvarchar(20)Color token: ink | gold | sage | azure
profilePicUrltext?Separate avatar per persona
isDefaultboolOnly 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

MethodPathDescription
GET/me/personasList all personas
POST/me/personasCreate (max 5 enforced)
PATCH/me/personas/:idUpdate name, about, accent
DELETE/me/personas/:idDelete (cannot delete default if others exist)
POST/me/personas/:id/set-defaultPromote to default

Registration Requirements

FieldRequiredValidation
emailyesValid email, unique
passwordyes≥ 8 chars
firstNameyes
lastNameyes
dateOfBirthyesMust be ≥ 18
countryyesISO 3166-1 alpha-2
cityyes
sessionIdyesValid non-expired reservation

Password Reset

  1. POST /forgot/request {email} → generates short opaque token, sends email (Resend). In Alpha without RESEND_API_KEY, the token is logged to stdout.
  2. Token is valid for 1 hour.
  3. POST /forgot/reset {token, newPassword} → updates passwordHash, marks token usedAt.

Sessions & Presence

  • Refresh rotation: POST /identity/refresh exchanges a valid refresh token for a fresh access + refresh pair (rotating refresh). The endpoint now carries a @Throttle cap (10/min short, 100/h long) to blunt token-replay and brute-force attempts.
  • Presence: me/push-token registration and other authenticated touches stamp lastSeenAt. A presenceOf(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 User as the single cross-context exception so cascades carry erasure cleanly; non-identity cross-context FKs stay banned. Awaiting approval.


Events Emitted

EventPayloadTrigger
identity.user_registered{userId, email, inviterUserId?}After user row created
identity.invite_code.used{codeId, newUserId, inviterUserId}After registration

Regulus — invite-only social-knowledge platform