Sign-in

Accounts for an app with a backend. You name one model of the database as the user; the platform keeps passwords, email codes and devices, and tells each endpoint who is calling. All of it is server code: db.auth. The app only calls your endpoints — it never sees a token or a password hash.

At a glance

TypeScript
// account.server.ts — runs on the app backend
const User = model({
  name: t.string().default(""),
  notes: t.many("Note"),
})
const Note = model({
  text: t.string(),
  owner: t.one("User").onDelete("cascade"),
})
const db = defineDb({ User, Note }).withAuth({ model: "User" })

export type Me = { id: number, name: string }

export async function register(login: string, password: string, name: string): Promise<Me> {
  await db.auth.signUpWithPassword(login, password, { name })
  return db.auth.requireUser().select({ id: true, name: true })
}

export async function login(login: string, password: string): Promise<Me> {
  await db.auth.signInWithPassword(login, password)
  return db.auth.requireUser().select({ id: true, name: true })
}

export async function logout() {
  await db.auth.signOut()
}

/** Who this device is signed in as, or null. */
export async function me(): Promise<Me | null> {
  return db.auth.user().select({ id: true, name: true })
}

export async function addNote(text: string) {
  const user = await db.auth.requireUser().select({ id: true })      // a guest gets a 401 here
  return db.note.insert({ text, owner: user })
}

export async function myNotes() {
  const user = await db.auth.requireUser().select({ id: true })
  return db.note.where({ owner: { id: user.id } }).select({ id: true, text: true })
}
TypeScript
// main.ts — the app
import { login, logout, me, register, type Me } from "./account.server"

const Field = (placeholder: string, name: string) =>
  UIInput().style({ height: 44, px: 12, borderRadius: 10, bgColor: "white", color: "black", placeholder }).named(name)
const Action = (label: string, name: string, run: () => Promise<void>) =>
  UIButton(UIText(label).style({ color: "white" }))
    .style({ height: 44, borderRadius: 10, bgColor: "#4f7cff" })
    .named(name)
    .onClick(() => { run().catch(failed) })

const status = UIText("").style({ color: "white", fontSize: 20 })
const who = Field("Login or email", "login")
const password = Field("Password, 8 characters or more", "password").style({ type: "password" })
const form = UIColumn(
  who, password,
  Action("Sign in", "signin", async () => show(await login(who.value, password.value))),
  Action("Create account", "signup", async () => show(await register(who.value, password.value, who.value))),
).style({ gap: 10 })
const signOut = Action("Sign out", "signout", async () => { await logout(); show(null) })

// what the server refused is said to the person: "Invalid login or password", "This login is taken"
const failed = (e: unknown) => toast(e instanceof RpcError ? e.message : String(e))

const show = (user: Me | null) => {
  status.text = user ? `Hello, ${user.name}` : "Sign in"
  form.style({ display: user ? "none" : "flex" })
  signOut.style({ display: user ? "flex" : "none" })
  password.value = ""
}

UIScreen(status, form, signOut).style({ bgColor: "#1e2230", p: 20, pt: "safe-top", gap: 16 }).open()

// the device remembers its session: after a restart `me()` answers the same account
me().then(show, failed)

Turning it on

TypeScript
const db = defineDb({ User, Note }).withAuth({ model: "User" })
  • withAuth is part of the definition: write it on defineDb(...) itself.
  • The user model is an ordinary model of yours: add what your app knows about a person — a name, a role, an avatar. Two rules:
    • every field is .optional() or has a .default(): the platform inserts the row the first time someone signs in, and knows nothing to put in it;
    • it keeps the built-in id.
  • Nothing secret is in the user's row. Logins, emails and password hashes are kept by the platform, apart from your models.

Who is calling

TypeScript
db.auth.user()                    // the signed-in user's row, or null for a guest
db.auth.requireUser()             // the row; a guest → the call rejects with 401 "Sign in required"
db.auth.requireUser(filter)       // the row; a user the filter doesn't match → 403 "Forbidden"
  .select(shape)                  // which fields to read, as in a query; without it: id and plain fields

They are queries over your user model, for the user of this request. Read what the endpoint needs:

TypeScript
export async function addNote(text: string) {
  const user = await db.auth.requireUser().select({ id: true })
  return db.note.insert({ text, owner: user })
}

export async function profile() {
  return db.auth.requireUser().select({ name: true, notes: { text: true } })
  // { name: "Ann", notes: [{ text: "milk" }] }
}

A filter makes it a check of access too. It is a where of the user model:

TypeScript
const User = model({ name: t.string().default(""), role: t.enum("member", "admin").default("member") })

export async function allUsers() {
  await db.auth.requireUser({ role: "admin" }).select({ id: true })      // 401 for a guest, 403 for a member
  return db.user.select({ id: true, name: true, role: true })
}
Note

these queries must be awaited (or returned). A bare db.auth.requireUser() runs nothing and so protects nothing, which is why it doesn't compile: the user of a request is read with await — a query that is not awaited checks nothing.

The rule for data that belongs to someone: take the user from db.auth, never from an argument. An id the app sends can be anyone's.

TypeScript
export async function removeNote(id: number) {
  const user = await db.auth.requireUser().select({ id: true })
  // the filter is the check: someone else's note is not found
  const removed = await db.note.where({ id, owner: { id: user.id } }).deleteMany()
  if (!removed) throw new ApiError(404, "No such note")
}

Passwords

TypeScript
await db.auth.signUpWithPassword(login, password, data?)   // a new account; this device is signed in as it
await db.auth.signInWithPassword(login, password)          // this device is signed in
await db.auth.setPassword(password)                        // the signed-in user's new password
await db.auth.signOut()                                    // this device is a guest again
  • login is what the person types to sign in: an email (it has an @), or a name of 3–32 letters, digits, ., _, -. Case doesn't matter: ANN signs in as ann.
  • data is the fields of the new user's row: { name }.
  • A password is 8 to 128 characters.
  • setPassword signs every other device of the user out.
  • Nothing is sent to an email given as a login, and nothing confirms it. To know that an address is the person's, sign them in with a code.

What a wrong input gets — an ApiError, so the app's call rejects with this status and message:

status message when
400 Password must be at least 8 characters sign-up, setPassword
400 Login must be 3–32 letters, digits, dots, dashes or underscores sign-up
400 Invalid email sign-up with an @ in the login
409 This login is taken / An account with this email already exists sign-up
401 Invalid login or password sign-in: one message for every reason
429 Too many attempts — try again later 10 failed sign-ins for one login in 10 minutes

Email codes

Sign-in with no password: a 6-digit code is mailed, the person types it in.

TypeScript
export async function sendCode(email: string) {
  await db.auth.sendCode(email)
}

export async function signInWithCode(email: string, code: string): Promise<Me> {
  await db.auth.signInWithCode(email, code)
  return db.auth.requireUser().select({ id: true, name: true })
}
  • The first sign-in of an address makes its account: the user's row is inserted with its defaults (name: "" above). Ask for the rest afterwards.
  • The code is typed on the device that asked for it. It lives 10 minutes and takes 5 wrong tries.
  • sendCode always resolves for a well-formed address — it doesn't tell whether an account exists.
  • Under lecodes dev and the other local runs nothing is mailed: the code is printed in the terminal, and 000000 signs any address in.
status message when
429 Try again in 30 s a second code for the address within 30 seconds
429 Too many codes requested — try again later more than 3 in 10 minutes
400 Code expired — request a new one no code was sent from this device, or it is older than 10 minutes
400 Invalid code
429 Too many attempts — request a new code 5 wrong tries

Your own way in

Passwords and email codes are what the platform checks. Anything else — an invite, a Telegram mini-app's init data, a company's single sign-on, a provider like Google — your own server code checks, and then says who the device is:

TypeScript
await db.auth.signIn(userId)       // this device is signed in as that user; 404 when there is no such row
TypeScript
const Invite = model({
  code: t.string().unique(),
  user: t.one("User").onDelete("cascade"),
})

export async function signInWithInvite(code: string): Promise<Me> {
  const invite = await db.invite.where({ code }).select({ user: { id: true } }).first()
  if (!invite) throw new ApiError(401, "Unknown invite")
  await db.auth.signIn(invite.user.id)
  return db.auth.requireUser().select({ id: true, name: true })
}
  • The user's row is yours to make: db.user.insert({ name }).
  • signIn trusts you: check the person BEFORE calling it. Whoever can make this endpoint call it with someone's id is that someone.
  • Such an account has no password, and setPassword refuses it (400): it has nothing to type a password next to.
Not implemented

there is no built-in "Sign in with Google / Apple / Yandex / VK". A project that needs one runs the provider's flow itself and ends it with db.auth.signIn(userId).

Guests

Every device has a session from its first call, before any sign-in. db.auth.sessionId is its id, and a model may point at it — data of a device, not of an account:

TypeScript
const CartItem = model({
  product: t.string(),
  session: t.one("Session").onDelete("cascade"),
})
const db = defineDb({ User, CartItem }).withAuth({ model: "User" })

export async function addToCart(product: string) {
  await db.cartItem.insert({ product, session: { id: db.auth.sessionId } })
}

export async function cart() {
  return db.cartItem.where({ session: { id: db.auth.sessionId } }).select({ id: true, product: true })
}

The session stays the same through sign-in and sign-out, so the cart is still there after either.

The account's devices and login

TypeScript
db.auth.sessions       // a query: the signed-in user's devices — { id, createdAt, lastSeenAt, revoked }
db.auth.identities     // a query: what the user signs in by — { provider, subject, verifiedAt, createdAt }
TypeScript
export async function devices() {
  return db.auth.sessions
}

export async function signOutOtherDevices() {
  return db.auth.sessions.where({ id: { $ne: db.auth.sessionId } }).updateMany({ revoked: true })   // how many
}

export async function signsInBy() {
  const [way] = await db.auth.identities.select({ provider: true, subject: true })
  return way ?? null
  // { provider: "login", subject: "ann" }      provider: "email" | "login"
}
  • A revoked device is a guest at its next call, with a new session: what pointed at the old one is not its any more.
  • A user signs in by one thing: the email or the login of the sign-up, or the email of the first code. identities answers that one row — or none, for an account your own code signed in. verifiedAt is set once a code proved the address.

A session holds no address and no device name. What your app wants to remember about a sign-in — where it came from, when — is a model of yours, pointing at the session:

TypeScript
const SignIn = model({
  user: t.one("User").onDelete("cascade"),
  session: t.one("Session").onDelete("cascade"),
  ip: t.string().optional(),
  at: t.date().default("now"),
})

export async function login(login: string, password: string) {
  await db.auth.signInWithPassword(login, password)
  const user = await db.auth.requireUser().select({ id: true })
  await db.signIn.insert({ user, session: { id: db.auth.sessionId }, ip: request.ip })
}

An address is personal data: keep it only if the app needs it, and say so to its users.

To delete an account, delete the user's row. The platform's records of the user go with it, the user's devices become guests, and your own models follow their onDelete:

TypeScript
export async function deleteAccount() {
  const user = await db.auth.requireUser().select({ id: true })
  await db.user.delete(user.id)
}

In the app

  • The app holds nothing to manage. The session travels with every call and every channel by itself, and is kept on the device between launches.
  • After a sign-in or a sign-out, the app's channel subscriptions are asked again for the new user by themselves.
  • The server's ApiErrors arrive as RpcError: show e.message for a 4xx, treat 401 on any call as "signed out" (the session was revoked from another device).

Pitfalls

TypeScript
db.auth.requireUser()                                         // ✗ compile error: not awaited, checks nothing
await db.auth.requireUser().select({ id: true })              // ✓

export async function myNotes(userId: number) { … }           // ✗ the app can send anyone's id
const user = await db.auth.requireUser().select({ id: true }) // ✓ the server knows who calls

const User = model({ name: t.string() })                      // ✗ withAuth throws: User.name needs .optional() or .default(...)
const User = model({ name: t.string().default("") })          // ✓

const db = defineDb({ User }); db.withAuth({ model: "User" }) // ✗ type error at `db.auth`: only what withAuth answers has it
const db = defineDb({ User }).withAuth({ model: "User" })     // ✓

See also