Machine-translated draft — terminology + flow still being reviewed.

auth — Mortar’s feature/auth

End-user authentication for apps deployed onto Mortar Cloud: when a user signs into a todo / chat / e-commerce app that someone built on Mortar (or built on appunvs and published onto Mortar), this is the auth feature handling that flow.

Composed on top of primitive/database. One of Mortar’s 5 features (auth / data / files / realtime / usage).

Not the same auth as the editor / dashboard:

  • This doc: AppUser auth — end-users of apps deployed on Mortar
  • For the Mortar dashboard (project owners signing in to manage their workspace) — that’s a separate, account-level auth, see architecture.md § Authentication layers.
  • For Fabric auth (Providers signing in to build apps), see fabric/docs/auth.md. Fabric’s own user data does not live on Mortar.

Three authentication contexts (don’t conflate)

ContextWhoWhereToken
Mortar accountWorkspace owner (the developer who pays for Mortar Cloud)mortar_accounts tableAccount session JWT (dashboard only)
API keyService-to-service / server-side SDK callsapi_keys table, scoped to a workspacemtr_live_... long-lived token
AppUser ⭐ this docEnd-user of an app deployed on Mortarapp_users table, tenant_id-scopedSession JWT + Device JWT

Tables

-- AppUsers: one row per end-user of a published app, scoped to the
-- workspace's tenant_id. email + phone are each unique PER TENANT only
-- WHEN PRESENT — a password user has email + no phone, a phone-OTP user
-- has phone + no email. A plain unique index would collide on the empty
-- string, so uniqueness is enforced via PARTIAL indexes.
CREATE TABLE app_users (
  id             TEXT PRIMARY KEY,
  tenant_id      TEXT NOT NULL,                     -- Mortar workspace ID
  email          TEXT,                              -- '' when phone-only
  phone          TEXT,                              -- '' when email-only
  password_hash  TEXT,                              -- '' for phone-OTP users
  email_verified BOOLEAN NOT NULL DEFAULT false,
  created_at     BIGINT NOT NULL
);

CREATE UNIQUE INDEX idx_app_users_ns_email
  ON app_users (tenant_id, email) WHERE email <> '';
CREATE UNIQUE INDEX idx_app_users_ns_phone
  ON app_users (tenant_id, phone) WHERE phone <> '';

CREATE TABLE app_user_devices (
  id         TEXT PRIMARY KEY,            -- client-generated UUID
  user_id    TEXT NOT NULL REFERENCES app_users(id),
  platform   TEXT NOT NULL,               -- ios|android|web
  created_at BIGINT NOT NULL,
  last_seen  BIGINT
);
CREATE INDEX idx_app_user_devices_user ON app_user_devices(user_id);

app_users is tenant-scoped — the same email can sign up across different workspaces independently. The app_ prefix distinguishes from Mortar’s own mortar_accounts (workspace owners).

Endpoints

All endpoints under /v1/{tenant}/auth/*. Public endpoints (signup / signin) require an API key in the Authorization header (or a public-anon key — TBD).

methodpathauthpurpose
POST/v1/{tenant}/auth/signupAPI keycreate AppUser (email+password), return session JWT
POST/v1/{tenant}/auth/signinAPI keyverify password, return session JWT
POST/v1/{tenant}/auth/phone/startAPI keycache a 6-digit OTP and deliver it via comms.sms
POST/v1/{tenant}/auth/phone/verifyAPI keyconsume the code, find-or-create the AppUser by phone, return JWT
POST/v1/{tenant}/auth/email/verify/startAPI keyemail a verification token (silently no-ops if the email isn’t registered — no enumeration)
POST/v1/{tenant}/auth/email/verify/confirmAPI keyconsume the token, set email_verified=true
POST/v1/{tenant}/auth/password/reset/startAPI keyemail a reset token (silently no-ops if the email isn’t registered)
POST/v1/{tenant}/auth/password/reset/confirmAPI keyconsume the token, update the password hash

(Device registration / me endpoints are planned.)

Phone-OTP, email verify, password reset — composition

These flows are an auth extension composing two primitives:

  • comms (comms.sms / comms.email) delivers the code or token.
  • cache stores the value with a TTL (5 min for SMS OTP, 15 min for email verify, 30 min for password reset) and runs the attempt counter via cache.IncrTTL. Past the cap (5 attempts per window) the code is invalidated so brute force can’t continue.

A send-side throttle sits on every */start endpoint, also via the cache primitive — separate from the global HTTP rate limiter:

  • per-recipient cooldown: 60 s minimum between sends to the same email / phone (key send:cd:{channel}:{tenant}:{recipient}),
  • per-recipient daily cap: 10 sends / 24 h,
  • per-tenant daily cap: 200 sends / 24 h (protects the project’s total SMS / email spend).

Blocked requests return 429 with Retry-After. China SMS providers additionally require a pre-registered SignName + TemplateCode (模板报备); for the local dev backend the code lands in data/comms/<tenant>/outbox.jsonl so the OTP can be read back without a real provider.

*/start always returns 200 — it never reveals whether the email is registered. Codes are one-time-use; a successful verify drops both the code and the attempt counter.

Request / response shapes

// POST /v1/{tenant}/auth/signup
// Authorization: Bearer mtr_live_...
{ "email": "alice@example.com", "password": "hunter2" }
// response 201
{
  "token": "<JWT>",
  "user_id": "u_...",
  "email": "alice@example.com",
  "expires_in_sec": 604800
}

// POST /v1/{tenant}/auth/signin → 200 (same response shape as signup)

Three JWT flavors

Per CONVENTIONS.md and architecture.md, Mortar runs three distinct token scopes (matching the three middleware chains):

  • Account JWT — Mortar workspace owner login (control plane, /v1/_accounts/* + Mortar Center dashboard). Out of scope for this doc; see architecture.md § Authentication layers.
  • API Key (mtr_live_* / mtr_test_*) — project-scoped credential embedded in mobile / web clients; authenticates every /v1/{tenant}/* call.
  • AppUser session JWT ⭐ this doc — issued by /v1/{tenant}/auth/{signup,signin}, with TTL = MORTAR_JWT_TTL (default 7 days), carrying uid + tid. Layered on top of the project’s API key to scope subsequent data / files / realtime calls to a specific end-user.

A single Signer handles AppUser JWTs; the signing key is per-workspace (so a workspace can rotate it without affecting other tenants).

Password hashing

golang.org/x/crypto/bcrypt with cost 10. Email is lower-cased before storage and compared case-insensitively.

Persistence

app_users + app_user_devices live in the workspace’s slice of the shared Postgres pool (see pricing.md § Capacity tiers for tier-specific row / RAM caps).

SDK surface

All four Mortar SDKs (TypeScript / Dart / Swift / Kotlin) expose this as mortar.auth.{signUp, signIn}. The AppUser’s session token is then passed back via mortar.withUserToken(session.token) to scope subsequent data / files / realtime calls. See each SDK’s README.

Why this is separate from Fabric auth

Fabric’s own Provider users (the humans using appunvs to build apps) sign into appunvs’s fabric-server, not into Mortar. fabric-server holds its own users + devices table — feature/auth is decoupled from appunvs entirely.

Same shape as a full-stack AI builder with a built-in cloud: the cloud’s auth feature handles end-users of the apps its creators publish, while the creators’ own accounts are managed in the builder’s own backend, not on its built-in cloud.

This separation is deliberate: Mortar is a BaaS product whose customers have nothing to do with appunvs.