Identity — Domain Spec¶
Identity is the single canonical users row plus the auth, session,
and device state that controls access to it. This page is the rules
the rest of the product trusts about who a caller is and what
state their account is in.
Mental model¶
Three shapes are load-bearing:
- One user, many doors. Six sign-in methods all resolve to the
same
user_id. Downstream code never branches on "how did this user log in?". - Two-stage deletion. A deletion request soft-deactivates the account for a 30-day grace window, then a scheduled worker hard- deletes it. Login during the grace window reactivates the row.
- Stateless access, stateful refresh. The access JWT is HMAC- signed and verified inline. The refresh token is a server-issued string with a Postgres row (source of truth) and a Redis mirror (fast lookup).
Account states¶
| State | Set by | Effect |
|---|---|---|
active (deactivated_at IS NULL) |
Sign-up via any method | Full access |
deactivated (deactivated_at IS NOT NULL, within 30d) |
DELETE /auth/profile |
All sessions revoked; row preserved; login during grace transparently reactivates |
| (hard-deleted) | deactivated_acct_purge worker after 30d |
Row + all owned rows gone; group-chat messages anonymised to user_id = NULL; direct-chat rows hard-deleted; avatar + moment media removed from object storage |
Sign-up methods¶
| Method | Identity proof |
|---|---|
| Email + password | OTP-verified email + bcrypt-hashed password |
| Google OAuth | Google ID token, server-verified via google.golang.org/api/idtoken |
| Apple Sign-In | Apple ID token, JWKS verified server-side |
| LINE OAuth | LINE ID token |
| WebAuthn / passkey | Stored public key + signed challenge |
Registration sets email_verified = true because OTP verification
gates /auth/register. Disposable email domains are rejected. Under-
18 accounts are blocked.
Session model¶
| Object | Lifetime | Where stored |
|---|---|---|
| Access JWT | 24 h | Client memory + storage |
| Refresh token | 7 d (web) / 30 d (native, detected from User-Agent) | Postgres refresh_tokens + Redis mirror |
| Session row | Until revoked | Postgres sessions (one per active device, includes device info, IP, last-active) |
| API key | Until revoked | Postgres api_keys (sk_<uuid> format, scoped, orthogonal to JWT) |
Refresh-token rotation is mandatory on every use. Revocation is per-session — logging out a device deletes its refresh token and its session row in both stores.
Account type and role¶
Every User row carries two orthogonal identity fields beyond the auth state above. They drive feature gating across the app.
account_type— what kind of account is this? Defaults tostandardfor consumer signups. Other values:tomoda(internal team),synthetic(monitoring probes),partner(merchants, advertisers — see Partners),external-curators(community curators).role— what is this account allowed to do? Defaults tobase. Other values:admin,curator,auditor,operator,support.
The pair drives authorization. Policies are named and centralized in backend/internal/access; handlers mount them rather than checking roles inline. The full table lives in Architecture → Authorization, but the load-bearing invariants are:
| Invariant | Why it matters |
|---|---|
account_type rides on the JWT alongside role |
Authz checks for global policies are DB-free |
| Standard accounts only surface in friend search / discovery | Synthetics, partners, Tomoda team, and external curators are filtered out of user-facing lookups |
| Partner authorization is membership-based, not claim-based | A partner account doesn't grant access to a partner; only PartnerMembership rows do |
| Role demotions take effect on next token refresh (≤24 h) | Claim-based policies don't re-read the DB per request — instant revocation needs a different middleware |
Lifecycle / state transitions¶
Account lifecycle: (none) → active → deactivated → (hard-deleted).
(none) → activeon a successful sign-up via any method.active → deactivatedonDELETE /auth/profile. Setsdeactivated_at = now(), revokes every refresh token + session.deactivated → activeon login during the 30-day grace window (finalizeLoginclearsdeactivated_at).deactivated → (hard-deleted)by thedeactivated_acct_purgescheduler (24h cadence) oncenow() - deactivated_at >= 30d.
Apple server-to-server consent-revoked / account-delete
notifications take the same active → deactivated path so the
standard 30-day purge applies.
Invariants¶
| Invariant | Why it matters |
|---|---|
| One identity, many doors | Downstream code never branches on auth method |
| OTP gates sensitive mutations | Signup, password reset, email change, phone update all require a verified OTP |
| Refresh-token rotation is mandatory | Long-idle tokens beyond per-client TTL are rejected |
| Revocation is per-session | "Log out all devices" iterates the user's sessions |
| API keys are orthogonal to JWT | A request authenticates via either JWT or X-API-Key, never both |
| Max 3 accounts per device fingerprint per 30 days | Anti-multi-account abuse |
| Display name has a 14-day cooldown after change | Anti-impersonation hygiene |
| Avatar uploads ≤ 5 MB, jpeg/png/gif/webp only | Object-storage budget + safety |
User-profile reads¶
A user's profile is served in two shapes, each owned by a different domain and picked by what the caller needs.
| Shape | Route | Owner | Fields | Used by |
|---|---|---|---|---|
| Lean card | GET /users/:userId |
Identity | id, name, username, avatar_url, bio, is_friend |
Mention chips, chat headers, link previews |
| Full profile card | GET /discovery/profiles/:id |
Discovery | Identity fields plus recent moments, upcoming / past events, mutual friends, friendship_status |
The profile screen and share links |
The lean card does no cross-domain reads: it is the cheap identity
projection. The full card aggregates across moments, events, and the
friend graph, so it belongs to the read-aggregation domain. Both
accept either a user UUID or a case-insensitive username as the path
segment, and both populate viewer-relative fields (is_friend,
friendship_status) only when the caller is authenticated.
Cross-domain interactions¶
| Other domain | Assumption |
|---|---|
| Discovery | Serves the full aggregated profile card; identity serves the lean card |
| Partners | A user can belong to one or more partners via PartnerMembership. Account type and membership are independent. |
| Friends | A user_id always resolves to a row; on hard delete, friendship rows are removed |
| Events | On user hard delete, owned events with no other participants are dropped; events with participants are cancelled |
| Chat | On hard delete, group-room messages are anonymised to user_id = NULL; direct rooms are wholesale deleted |
| Moments | On hard delete, moment media is removed from object storage before row CASCADE |
| Payments | Subscription state lives on the users row (stripe_customer_id, subscription_status, subscription_plan, subscription_end) |
Privacy / visibility¶
| Field | Default | Notes |
|---|---|---|
| Display name + username | Public | Username is unique; cooldown applies on rename |
| Avatar | Public | Pulled from object storage URL |
| Private | Used only for auth and transactional email | |
| Phone | Private | Optional, OTP-verified |
| Active sessions | Self only | Visible on the security screen |
| Login history | Self only | Last 20 attempts, success + failure |
notifications_enabled |
true |
Stored on the user row; no consumer yet |
See also¶
- Narrative: Bible → Identity
- Backend: Backend → Auth service, Backend → User service
- System view: Architecture → Authentication
- UX:
../ux/identity.md(coming soon)