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 / CLI:

  • This doc: AppUser auth — end-users of apps deployed on Mortar
  • For the Mortar control plane (project owners signing in to manage their workspace via mortar CLI) — 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 (mortar CLI 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 — same email can sign up across different workspaces independently. 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 6-digit OTP + send via comms.sms
POST/v1/{tenant}/auth/phone/verifyAPI keyconsume code, find-or-create AppUser by phone, return JWT
POST/v1/{tenant}/auth/email/verify/startAPI keyemail a verification token (silently no-ops if email isn’t registered — no enumeration)
POST/v1/{tenant}/auth/email/verify/confirmAPI keyconsume token, set email_verified=true
POST/v1/{tenant}/auth/password/reset/startAPI keyemail a password-reset token (silently no-ops if email isn’t registered)
POST/v1/{tenant}/auth/password/reset/confirmAPI keyconsume token, update password hash

(Device registration / me endpoints are planned.)

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)

// POST /v1/{tenant}/auth/phone/start
{ "phone": "+8613800138000" }
// 200
{ "sent": true, "expires_in_sec": 300 }
// 429 cooldown / daily_cap_recipient / daily_cap_tenant
// 503 comms_not_configured

// POST /v1/{tenant}/auth/phone/verify
{ "phone": "+8613800138000", "code": "123456" }
// 200 — same {token, user_id, phone, expires_in_sec} shape
// 401 invalid_code  /  429 too_many_attempts (attempt cap hit)

Phone-OTP, email verify, password reset — composition

These flows are an auth extension composing two primitives:

  • comms (comms.sms or comms.email) delivers the code / token.
  • cache stores the code 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 (cap 5 verify attempts per window — past the cap, the code is invalidated so brute force can’t continue).

A send-side throttle sits on every */start endpoint, also via the cache primitive, distinct 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 overall SMS / email spend).

Blocked requests return 429 with Retry-After. China SMS providers additionally require a pre-registered SignName + TemplateCode (模板报备); for the dev local 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 — never reveals whether the email is registered (no enumeration). Codes are one-time-use: a successful verify drops both the code and the attempt counter.

Three JWT flavors

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

  • Account JWT — Mortar workspace owner login (control plane, /v1/_accounts/* consumed by the mortar CLI). 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}, TTL = MORTAR_JWT_TTL (default 7 days), carries uid + tid. Layered on top of the project’s API key to scope subsequent data / files / realtime calls to one end-user.

A single Signer handles AppUser JWTs; signing key is per-workspace (so the workspace can rotate 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}. 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 with customers that have nothing to do with appunvs.