Skip to content

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:

  1. 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?".
  2. 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.
  3. 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_typewhat kind of account is this? Defaults to standard for consumer signups. Other values: tomoda (internal team), synthetic (monitoring probes), partner (merchants, advertisers — see Partners), external-curators (community curators).
  • rolewhat is this account allowed to do? Defaults to base. 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) → active on a successful sign-up via any method.
  • active → deactivated on DELETE /auth/profile. Sets deactivated_at = now(), revokes every refresh token + session.
  • deactivated → active on login during the 30-day grace window (finalizeLogin clears deactivated_at).
  • deactivated → (hard-deleted) by the deactivated_acct_purge scheduler (24h cadence) once now() - 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
Email 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