AuthQuery users from a Func

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.

NamespaceScopeMethods
ctx.authWhoever made this call, resolved from the session cookiecurrentUser() / requireUser() / login() / register()
ctx.usersThe whole project's user directory, able to address anyonefind() / 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:

FieldMeaning
idThe user key, always present. This is the only value that should be used as an ownership key
account / email / phoneThree identifiers, any of which may be empty depending on how the user signed up
name / avatarDisplay name and avatar URL. There is no nickname field
statusenabled or disabled
profileSite-defined custom fields, see Custom profile fields
last_login_at / created_at / updated_atTimestamps

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 find throws 409 rather than picking a row. Projects that allow duplicate emails must look up by account or userId.
  • account only resolves users with a password identity. An OAuth-only account has no password identity row and cannot be found by account — use email or userId.

Exposing the result of find as "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,
    })),
  }
}
ParameterMeaning
searchSubstring match across account, email, phone, and name. Omit for no filter
statusenabled or disabled; anything else is a 400
limitDefault 20, maximum 100, clamped silently; the returned limit is what actually applied
offsetPaging offset
order_bycreated_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 profile fields. To select people by a business fact ("everyone who bought a course"), write that fact into your own JSON table keyed by user.id and 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 query must call requireUser() first and then decide whether this person qualifies. The platform does project isolation only; it has no notion of roles.
  • Do not return result.list to 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:

SwitchMeaning
x-customer-readableWhether the browser can read the field (default: yes)
x-customer-writableWhether 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 by user.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.users calls requireUser() 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 limit and offset and page through total.
  • Changing user data lives in a JSON table keyed by user.id, not in profile (read-only from Func) and not in a second identity table.

Render diagnostics