Query users from a Func
ctx.users is the project user directory: find resolves one person by identifier, query pages through the list. Covers the full user object, the three traps in find, the filter and ordering rules for query, and the section that matters most — the platform has no notion of roles, so the access gate is yours to write.
User accounts are managed by the platform, and Func reaches them through ctx.users — the project-wide user directory: find one person by identifier, page through many, verify and reset passwords. It is a different thing from ctx.auth, which only answers "who made this call".
First, separate ctx.auth from ctx.users
These two namespaces are the easiest to conflate, and the consequences are asymmetric: treating ctx.users as "the current user" means one wrong email changes somebody else's account.
| Namespace | Scope | Methods |
|---|---|---|
ctx.auth | Whoever made this call, resolved from the session cookie | currentUser() / requireUser() / login() / register() |
ctx.users | The whole project's user directory, able to address anyone | find() / query() / checkPassword() / setPassword() |
For any mutating call, the identifier must come from a fact the server just confirmed, not from a browser-supplied field. Changing the caller's own password is
ctx.users.setPassword({ userId: ctx.auth.requireUser().id, ... }), never{ email: input.email }— the latter means "change whoever the browser claims to be".
The user object
ctx.auth.currentUser(), ctx.users.find(), and ctx.users.query() all return the same structure. Field names are snake_case:
| Field | Meaning |
|---|---|
id | The user key, always present. This is the only value that should be used as an ownership key |
account / email / phone | Three identifiers, any of which may be empty depending on how the user signed up |
name / avatar | Display name and avatar URL. There is no nickname field |
status | enabled or disabled |
profile | Site-defined custom fields, see Custom profile fields |
last_login_at / created_at / updated_at | Timestamps |
The object contains no password, session token, linked OAuth providers, or internal IDs. None of those enter the sandbox.
Finding one person: find
ctx.users.find(ref) resolves exactly one user by identifier. userId, email, and account are mutually exclusive — passing two is a 400.
import type { TalizenFuncContext } from 'talizen/func-runtime'
export function lookup(input, ctx: TalizenFuncContext) {
const user = ctx.users.find({ email: input.email })
if (!user) return { ok: false } // missing returns null, it does not throw
return { ok: true, name: user.name }
}
Three traps:
- A missing user is
null, not an error. "Does this person exist" is a normal branch for site code, not something to wrap in try/catch. - One email may belong to several users, and
findthrows 409 rather than picking a row. Projects that allow duplicate emails must look up byaccountoruserId. accountonly resolves users with a password identity. An OAuth-only account has no password identity row and cannot be found byaccount— useemailoruserId.
Exposing the result of
findas "is this email registered" is an account-enumeration hole. In anonymous-facing flows such as password reset and signup, both branches must return exactly the same thing — see Reset and Change Passwords.
Finding many: query
ctx.users.query(query) returns users by page, in the same shape as ctx.db.query: { total, list, limit }.
export function directory(input, ctx: TalizenFuncContext) {
requireAdmin(ctx) // see the next section; this line is not optional
const result = ctx.users.query({
search: input.keyword, // matches account / email / phone / name
status: 'enabled', // 'enabled' | 'disabled'; omit for no filter
order_by: 'created_at desc',
limit: 20,
offset: (input.page - 1) * 20,
})
// Hand out only the fields the page actually needs
return {
total: result.total,
list: result.list.map((user) => ({
id: user.id,
name: user.name,
createdAt: user.created_at,
})),
}
}
| Parameter | Meaning |
|---|---|
search | Substring match across account, email, phone, and name. Omit for no filter |
status | enabled or disabled; anything else is a 400 |
limit | Default 20, maximum 100, clamped silently; the returned limit is what actually applied |
offset | Paging offset |
order_by | created_at, last_login_at, or id, each optionally asc/desc. Default created_at desc; any other column is a 400 |
Users returned by query carry no profile. Custom fields may hold back-office-only content, and a list result is the thing most likely to be forwarded wholesale to the browser. When you need one person's full record, take the id and call find.
There are no aggregates, no grouping, and no filtering by
profilefields. To select people by a business fact ("everyone who bought a course"), write that fact into your own JSON table keyed byuser.idand query the table. The user directory is not a business database.
The gate: do not skip this section
find has a natural barrier — you must already know an identifier. query has none: it makes the directory something you can page through. A Func that forgets the access check is a public customer-list export endpoint.
// The platform does not know who your admins are; this check is yours to write
function requireAdmin(ctx: TalizenFuncContext) {
const user = ctx.auth.requireUser() // 1. must be signed in
const admin = ctx.db.get('admins', user.id) // 2. must be in your own grant table
if (!admin) throw new Error('forbidden')
return user
}
- Every Func calling
querymust callrequireUser()first and then decide whether this person qualifies. The platform does project isolation only; it has no notion of roles. - Do not return
result.listto the browser directly. User objects carry emails and phone numbers — pick the fields the page needs. - Model "admin" with your own JSON table keyed by
user.id. Do not hard-code a rule such as an email-domain suffix. - Directory reads are never cached: results reflect live account rows.
Custom profile fields
Besides the built-in fields, each project can define custom user fields. The schema is configured in the editor under Backend → Users. Every field carries two switches:
| Switch | Meaning |
|---|---|
x-customer-readable | Whether the browser can read the field (default: yes) |
x-customer-writable | Whether the browser can write the field (default: no) |
Both switches constrain the browser. Func is server code, so find() returns the unfiltered profile — including fields marked x-customer-readable: false. Returning user.profile wholesale from a Func therefore bypasses the switch.
export function me(input, ctx: TalizenFuncContext) {
const user = ctx.users.find({ userId: ctx.auth.requireUser().id })
// Do not return { profile: user.profile } — it may contain internal-only fields
return { plan: user.profile?.plan ?? 'free' }
}
query() omits profile entirely, precisely so that casually forwarding a list cannot become a bulk leak. For one person's full record, call find with their id.
Func cannot modify a profile today. It is writable only at account creation through
ctx.auth.register({ profile }); there is no update path afterwards. Data that changes — plan state, credits, preferences — belongs in your own JSON table keyed byuser.id, leaving profile for the few attributes fixed at signup.
When not to use it
Do not build your own user table
Never create identity tables such as users or auth_users, and never use an email as a business key. Accounts, passwords, sessions, and OAuth are platform capabilities — see Sign In From a Func.
Do not use it for business queries
The directory filters only by identifier and the few built-in fields. Business-dimension filtering belongs in your own JSON table keyed by user.id.
Do not gate access in the page
"Only admins see this list" must be decided inside the Func. Hiding UI in the browser is presentation only; the endpoint remains directly callable.
Do not use it as a session check
Always use ctx.auth to answer "who is calling". ctx.users.find can return anyone's object and proves nothing about the caller.
Acceptance checklist
- Every Func using
ctx.userscallsrequireUser()and checks authorization beyond that. - What reaches the browser is a selected set of fields — never a whole user object, never a whole
profile. - Mutating calls take an identifier confirmed by the server, not an email supplied by the browser.
- Anonymous-facing flows return identical responses whether or not the user exists.
- List endpoints pass
limitandoffsetand page throughtotal. - Changing user data lives in a JSON table keyed by
user.id, not inprofile(read-only from Func) and not in a second identity table.
