Auth & Roles
JWT
All API endpoints are protected with JWT Bearer authentication (except POST /auth/register, POST /auth/login, GET /health).
JWT Payload
interface JwtPayload {
sub: string; // User UUID (internal, never exposed publicly)
email: string; // User email
role: string; // 'user' | 'moderator' | 'admin'
iat: number; // Issued at (unix seconds)
exp: number; // Expiry (unix seconds)
}Invariant: The JWT payload is stable. Do not extend it without an ADR. Persona, interests, and other context-specific data are fetched separately.
Token Lifetime
Default expiry: 7 days. No refresh token in Alpha — re-login required after expiry.
Role Hierarchy
| Role | Who | Capabilities |
|---|---|---|
user | All registered users | Standard platform actions |
moderator | Assigned by admin | Hide/unhide posts and replies, review reports |
admin | Manually assigned | All moderator powers + role change, ban, feature flags, audit log, broadcast |
Role is stored in users.role and embedded in the JWT. Privilege escalation requires an admin to update users.role directly.
Guards
JwtAuthGuard
Applied globally via APP_GUARD. Every request must present a valid Authorization: Bearer <token> header unless the route is explicitly marked @Public().
// Skip auth for public endpoints
@Public()
@Post('/auth/register')
async register(@Body() dto: RegisterDto) { ... }AdminGuard
Applied on top of JwtAuthGuard for admin-only endpoints. Checks user.role === 'admin'.
@UseGuards(AdminGuard)
@Get('/admin/users')
async listUsers() { ... }ModeratorGuard
Checks user.role === 'moderator' || user.role === 'admin'.
ThrottlerGuard
Applied globally. Default: 60 requests per minute per IP. Per-route overrides for sensitive endpoints (e.g. POST /auth/login is 5/min).
Access Matrix
Reputation-based capability checks live in AccessMatrixService (apps/api/src/shared/access/).
export type Capabilities = {
canVoteWiki: boolean; // total rep ≥ 5
canMentor: boolean; // rep ≥ 30 in interest, or total ≥ 50
canModerate: boolean; // role = moderator | admin
canProposeGuild: boolean; // Wave 3 — always false in Alpha
canPostJournalism: boolean; // rep ≥ 20 in ANY single interest
};Client endpoint: GET /access/me returns the current user's full Capabilities object.
@RequiresRep Decorator
@RequiresRep({ interest: 'philosophy', min: 10 })
@Post('/wiki/proposals')
async createProposal(@Body() dto: CreateProposalDto) { ... }Injects AccessMatrixService check; throws ForbiddenException if not met.
Auth Flow
sequenceDiagram
participant Client
participant API
participant DB
Client->>API: POST /auth/reserve-invite {code}
API->>DB: inviteCode.status = 'reserved', reservedUntil = now+30min
API-->>Client: {sessionId, expiresAt}
Client->>API: POST /auth/register {sessionId, email, password, ...}
API->>DB: create User, mark inviteCode 'used'
API->>DB: create InviteNode, bootstrap EmojiWallet (50 mana)
API-->>Client: {accessToken, user}
Client->>API: POST /auth/login {email, password}
API-->>Client: {accessToken, user}
Note over Client,API: All subsequent requests include Authorization: Bearer <token>Public Actor Identifiers
Internal UUID (users.id) is never exposed directly in public-facing APIs. Public identifiers use Webfinger-style format:
acct:[email protected]This is the federation-ready format that will map to ActivityPub actors in v2.0.