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.mdfor 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 wherefeature/authlives.
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
| method | path | auth | purpose |
|---|---|---|---|
| POST | /auth/email/signup/send-code | — | send a generic, rate-limited mailbox challenge before signup/bind |
| POST | /auth/signup | — | consume mailbox proof atomically, create User account, return session JWT + Set-Cookie |
| POST | /auth/login | — | verify credentials, return session JWT + Set-Cookie |
| POST | /auth/logout | — | clear the browser session cookies |
| GET | /auth/me | session | current User profile |
| POST | /auth/email/send-code | session | legacy repair: send to the current bound, unverified email |
| POST | /auth/email/verify | session | legacy 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 companionfabric_uidcarrying just the user id). The browser attaches it automatically (credentials: 'include'); Next.jsmiddleware.tsreads 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 & cookie-scope notes
- 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.comsends the cookies to every appunvs.com subdomain — including the untrusted AI previews on*.preview.appunvs.com. Today that’s contained (fabric_sessionis HttpOnly so bundle JS can’t read it; CORS preflight blocks abusing it for writes), but it’s needless attack surface, and the readablefabric_uidis visible to preview bundle JS. Industry-standard fix: serve untrusted previews (and published apps) from a separate sandbox registrable domain (thegithubusercontent.com/csb.apppattern) so auth cookies are never in their scope — or colocate web+api on one host so the cookie can be host-only (noDomain=). 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.