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
// 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 })
}// 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
const db = defineDb({ User, Note }).withAuth({ model: "User" })withAuthis part of the definition: write it ondefineDb(...)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.
- every field is
- 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
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 fieldsThey are queries over your user model, for the user of this request. Read what the endpoint needs:
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:
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 })
}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.
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
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 againloginis what the person types to sign in: an email (it has an@), or a name of 3–32 letters, digits,.,_,-. Case doesn't matter:ANNsigns in asann.datais the fields of the new user's row:{ name }.- A password is 8 to 128 characters.
setPasswordsigns 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.
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.
sendCodealways resolves for a well-formed address — it doesn't tell whether an account exists.- Under
lecodes devand the other local runs nothing is mailed: the code is printed in the terminal, and000000signs 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:
await db.auth.signIn(userId) // this device is signed in as that user; 404 when there is no such rowconst 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 }). signIntrusts 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
setPasswordrefuses it (400): it has nothing to type a password next to.
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:
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
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 }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.
identitiesanswers that one row — or none, for an account your own code signed in.verifiedAtis 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:
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:
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 asRpcError: showe.messagefor a 4xx, treat 401 on any call as "signed out" (the session was revoked from another device).
Pitfalls
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
- Database — models and queries
- Server functions — endpoints, channels, where the backend runs