auth — Fabric account model

Authentication for Fabric itself (Users signing into the editor to build apps via AI). This auth lives in fabric-server; it is not the same as Mortar’s feature/auth, which provides end-user auth for the apps that appunvs users publish onto Mortar Cloud (see mortar/docs/auth.md).

Scope split (see CONVENTIONS.md for the full term map):

  • This doc: a User’s account on Fabric. Stays in fabric-server forever — appunvs’s own product data does not sit on Mortar.
  • mortar/docs/auth.md: end-user accounts inside published apps. That’s where feature/auth lives.

Tables

CREATE TABLE users (
  id            TEXT PRIMARY KEY,
  email         TEXT NOT NULL UNIQUE COLLATE NOCASE,
  password_hash TEXT NOT NULL,
  created_at    INTEGER NOT NULL
);

Endpoints

methodpathauthpurpose
POST/auth/email/signup/send-codesend a generic, rate-limited mailbox challenge before signup/bind
POST/auth/signupconsume mailbox proof atomically, create User account, return session JWT + Set-Cookie
POST/auth/loginverify credentials, return session JWT + Set-Cookie
POST/auth/logoutclear the browser session cookies
GET/auth/mesessioncurrent User profile
POST/auth/email/send-codesessionlegacy repair: send to the current bound, unverified email
POST/auth/email/verifysessionlegacy repair: verify the current bound email

Request / response shapes

// POST /auth/email/signup/send-code → 200
{ "email": "alice@example.com" }
// Generic for registered, rate-limited and delivery-failed addresses.
{ "ok": true, "verification_id": "ver_..." }

// POST /auth/signup  →  200
{
  "email": "alice@example.com",
  "password": "Hunter2!",
  "verification_id": "ver_...",
  "verification_code": "123456",
  "policy_acceptance": { "terms_version": "...", "privacy_version": "...", "accepted": true, "age_confirmed_14_plus": true }
}
// response
{ "user_id": "u_...", "session_token": "<JWT>" }

// POST /auth/login → 200 (email/password + policy acceptance; no mailbox proof)

The six-digit mailbox code is bcrypt-hashed, expires after five minutes, is attempt-capped and one-time. Challenge verification, account creation, provider receipt evidence, policy acceptance, email-identity activation and challenge consumption commit in one PostgreSQL transaction. Mailbox proof establishes mailbox control only; it does not satisfy the separate phone_verified public publish gate.

Registered-address fake ids and live/expired/locked challenges all execute one cost-10 bcrypt comparison, return the same wrong-proof response and reach a 250 ms response floor. An independent per-replica IP budget allows 30 proof attempts per 10 minutes, separate from send-code limits.

Session JWT

Issued on signup/login, TTL 24h (configurable via cfg.Auth.SessionHours), carries uid only. Used for every user-scope fabric-server API — dashboard, project management, AI turns, billing, API keys.

Delivery — two channels, one JWT

The same JWT reaches fabric-server two ways depending on client:

  • Mobile host shells (iOS / Android) store the token in secure storage (Keychain / Keystore) and send it as Authorization: Bearer <JWT>.
  • fabric-web (browser) never holds the token in JS. On signup / login / SSO callback fabric-server also sets it as an httpOnly cookie fabric_session (plus a readable companion fabric_uid carrying just the user id). The browser attaches it automatically (credentials: 'include'); Next.js middleware.ts reads it to gate the signed-in routes server-side before render — this removes the refresh flash-of-unauthenticated-content and keeps the JWT out of reach of page JS (XSS-safe).

Server token extraction (auth.TokenFromRequest) prefers the Authorization header and falls back to the fabric_session cookie, so a single verify path serves both channels. POST /auth/logout clears the cookies (a no-op beyond 200 for header clients — stateless JWT, no server-side allowlist yet).

Cookie attributes: HttpOnly; SameSite=Lax; Path=/, Secure in prod, and Domain=appunvs.com in prod so the same-site app. + api. subdomains share it (dev is host-only on localhost; ports are ignored). Config: FABRIC_AUTH_COOKIE_DOMAIN / FABRIC_AUTH_COOKIE_SECURE.

  • CSRF: no anti-CSRF token is used. Defense is SameSite=Lax + a strict CORS origin allowlist (FABRIC_HTTP_CORS_ALLOWED_ORIGINS, never * in prod) + the API requiring a JSON body / custom header on writes, so both cross-site and cross-origin-same-site state changes are blocked. Keeping the prod CORS allowlist strict is load-bearing for this.
  • Cookie scope (hardening TODO): Domain=appunvs.com sends the cookies to every appunvs.com subdomain — including the untrusted AI previews on *.preview.appunvs.com. Today that’s contained (fabric_session is HttpOnly so bundle JS can’t read it; CORS preflight blocks abusing it for writes), but it’s needless attack surface, and the readable fabric_uid is visible to preview bundle JS. Industry-standard fix: serve untrusted previews (and published apps) from a separate sandbox registrable domain (the githubusercontent.com / csb.app pattern) so auth cookies are never in their scope — or colocate web+api on one host so the cookie can be host-only (no Domain=). Tracked as a V-series hardening item.

Password policy

Enforced both in fabric/web frontend and fabric-server backend (see fabric/server/internal/store/users.go::validatePassword):

  • Minimum 8 characters
  • First character uppercase A–Z
  • Contains at least one digit
  • Contains at least one symbol from !@#$%^&*()_+-=[]{};:,.?
  • Only letters / digits / those symbols (no spaces, no Unicode)

Hashed with golang.org/x/crypto/bcrypt, cost 10. Email is lower-cased

  • trimmed before storage and compared case-insensitively.

Persistence

fabric-server persists identities in its own Postgres database, configured by FABRIC_DB_DSN. It is not delegated to Mortar: Fabric’s own user data stays in Fabric’s service and schema, separate from the BaaS offered to generated applications.

User-vs-AppUser disambiguation

  • User (this doc) = a Fabric user — the human who chats with AI to build a Project.
  • AppUser (mortar/docs/auth.md) = an end-user of an app that a User has published; signs into the published app, not into the editor.

Each User’s published project maps 1:1 to a Mortar workspace (fabric/docs/architecture.md); the Mortar workspace’s feature/auth then handles AppUsers inside that published app.