Users
Overview
The User table is the identity record every other part of this interface points at: the authentication decorators resolve a request to one, the token generators sign for one, and Session rows reference one. The table, its constant and its model live on the /db subpath; the sanitizer and the mail entry points live on the root subpath.
import { sanitizeUser } from "@antelopejs-private/cms/interfaces/cms-auth";
import {
USERS_TABLE_NAME,
User,
UserModel,
} from "@antelopejs-private/cms/interfaces/cms-auth/db";
The User Table
User extends Table.with(HashModifier) and is registered as the users table under the DMS core schema, so its rows are shared across tenants and its model is retrieved without a tenant instance. USERS_TABLE_NAME holds the table name, "users".
| Field | Type | Description |
|---|---|---|
_id | string | Primary key. |
createdAt | Date | Set on insert by the creation-time decorator. Indexed. |
updatedAt | Date | Set on write by the update-time decorator. Indexed. |
email | string | Login identifier. Indexed. |
name | string | Display name. |
avatar | DefaultDataTypes.ImageValue | null | Profile picture as an image value ({ key, alt? }) in the default storage, downscaled client-side to a square of at most 100×100 pixels. |
language | string | Preferred language or locale, for example "en" or "fr". Indexed. |
password | string | null | Hashed by the HashModifier; reads back as the hash, not the plaintext. null for accounts with no password. |
authKey | string | Per-user secret that takes part in JWT signing. Rotating it invalidates every token already issued to the user. |
isValidated | boolean | Whether the email address has been validated. Indexed. |
validationToken | string | null | Current email-validation code. Indexed. |
validationRequestedAt | Date | null | When the validation code was issued, for expiry and rate limiting. |
forgotPasswordToken | string | null | Current password-reset code. Indexed. |
forgotPasswordRequestedAt | Date | null | When the reset code was issued, for expiry and rate limiting. |
owner | boolean | Platform owner flag. This is what AuthOwnerOnly checks — see Authentication. |
twoFactorMethods | string[] | Second factors enabled on the account. |
twoFactorSecret | string | null | Active TOTP secret. |
twoFactorPendingSecret | string | null | TOTP secret awaiting confirmation during enrolment. |
twoFactorBackupCodes | string[] | Remaining single-use backup codes. |
twoFactorEmailCode | string | null | Current emailed second-factor code. |
twoFactorEmailCodeRequestedAt | Date | null | When the emailed code was issued, for expiry and rate limiting. |
The indexed fields — createdAt, updatedAt, email, language, isValidated, validationToken, forgotPasswordToken — are the ones you can look up through getAll on the underlying table or getBy on the model, which is how UserModel.getByEmail works.
Store and Check a Password
password carries the @Hashed decorator, and the table composes HashModifier. Assign a plaintext value on write and it is hashed on the way into the database; read it back and you get the hash, not the original.
import type { UserModel } from "@antelopejs-private/cms/interfaces/cms-auth/db";
import randomstring from "randomstring";
const AUTH_KEY_LENGTH = 64;
export async function createUser(
userModel: UserModel,
name: string,
email: string,
password: string,
) {
const ids = await userModel.insert({
createdAt: new Date(),
updatedAt: new Date(),
name,
email: email.toLowerCase(),
password, // hashed by the modifier on insert
authKey: randomstring.generate({ length: AUTH_KEY_LENGTH }),
owner: false,
language: "en",
});
return ids[0];
}
To check a candidate password, call testHash on the loaded row. The mixin hashes the candidate with the stored salt and compares it against the stored hash.
import { HTTPResult } from "@antelopejs/interface-api";
import type { UserModel } from "@antelopejs-private/cms/interfaces/cms-auth/db";
const UNAUTHORIZED = 401;
export async function checkCredentials(
userModel: UserModel,
email: string,
password: string,
) {
const user = await userModel.getByEmail(email.toLowerCase());
if (!user || !user.testHash("password", password)) {
throw new HTTPResult(UNAUTHORIZED, "error.invalid_credentials");
}
return user;
}
Emails are stored and looked up lowercased by convention; normalize before both writing and querying.
Query Users with UserModel
UserModel extends BasicDataModel(User, USERS_TABLE_NAME), so it has the standard get, getBy, getAll, insert, update and delete operations, plus three lookups of its own.
| Method | Signature | Description |
|---|---|---|
getByEmail | (email: string) => Promise<User | undefined> | The user with this exact email, read through the email index, or undefined. |
getOwners | () => Promise<User[]> | Every user with owner === true. |
countOwnersExcluding | (excludedIds: string[]) => Promise<number> | How many owners remain once the given user ids are set aside. Pass an empty array to count all owners. |
countOwnersExcluding exists for the last-owner check: before removing the owner flag from a set of users, count who would be left and refuse the operation if the answer is zero.
import { HTTPResult } from "@antelopejs/interface-api";
import type { UserModel } from "@antelopejs-private/cms/interfaces/cms-auth/db";
const CONFLICT = 409;
export async function demoteOwners(userModel: UserModel, userIds: string[]) {
const remaining = await userModel.countOwnersExcluding(userIds);
if (remaining === 0) {
throw new HTTPResult(CONFLICT, "error.last_owner");
}
for (const id of userIds) {
await userModel.update(id, { owner: false });
}
}
Invalidate Every Token for a User
authKey takes part in the signing secret of every token issued for the user, so replacing it makes all outstanding access, refresh, two-factor and tenant-assignment tokens fail validation at once. Combine it with SessionModel.deleteByUserId from Tokens and Sessions to sign a user out everywhere.
import type {
SessionModel,
User,
UserModel,
} from "@antelopejs-private/cms/interfaces/cms-auth/db";
import randomstring from "randomstring";
const AUTH_KEY_LENGTH = 64;
export async function signOutEverywhere(
userModel: UserModel,
sessionModel: SessionModel,
user: User,
) {
await sessionModel.deleteByUserId(user._id);
user.authKey = randomstring.generate({ length: AUTH_KEY_LENGTH });
await userModel.update(user);
}
userModel.update(user) takes the primary key from the object; the two-argument form update(id, partial) updates a subset of fields instead.
Strip Secrets Before Responding
A User loaded from the database carries authentication secrets. sanitizeUser returns a copy without them, and is what you return over HTTP.
sanitizeUser(user: User): Promise<Partial<User>>
It removes the account's secret material — the authKey, the email-validation and password-reset tokens with their timestamps, and the two-factor secret, backup codes and email code with its timestamp — along with any additional keys the deployment lists in its authentication configuration. twoFactorPendingSecret is not part of that set and stays on the result, so drop it yourself if the response reaches a client that must not see an enrolment in progress. Because the removed set is configurable, the return type is Partial<User>: treat every field as possibly absent rather than assuming a fixed shape.
import { Controller, Get } from "@antelopejs/interface-api";
import {
AuthRawUser,
sanitizeUser,
} from "@antelopejs-private/cms/interfaces/cms-auth";
import type { User } from "@antelopejs-private/cms/interfaces/cms-auth/db";
export class MeController extends Controller("/api/me") {
@Get("")
me(@AuthRawUser() user: User): Promise<Partial<User>> {
return sanitizeUser(user);
}
}
Send Account Emails
Four entry points send the account emails the authentication flows depend on. Each returns a promise that resolves once the message has been accepted and rejects when the mail provider reports a failure, so decide per call site whether to await it or dispatch it in the background.
| Function | Signature | Sends |
|---|---|---|
send2FAEmail | (user: User, code: string) => Promise<void> | The emailed second-factor code, to user.email. |
sendEmailValidationEmail | (user: User) => Promise<void> | The email-validation message, using the validationToken currently stored on the user. |
sendEmailForgotEmail | (user: User) => Promise<void> | The password-reset message, using the forgotPasswordToken currently stored on the user. |
sendAdminInviteEmail | (email: string, token: string, inviteeName?: string) => Promise<void> | An invite carrying a signup link built from token, to an address that has no user row yet. inviteeName addresses the invitee by name when the invite carried one. |
sendEmailValidationEmail and sendEmailForgotEmail read the code off the user, so generate it, persist it, and only then send.
import { sendEmailForgotEmail } from "@antelopejs-private/cms/interfaces/cms-auth";
import type { UserModel } from "@antelopejs-private/cms/interfaces/cms-auth/db";
import randomstring from "randomstring";
const RESET_CODE_LENGTH = 6;
export async function requestPasswordReset(
userModel: UserModel,
email: string,
) {
const user = await userModel.getByEmail(email.toLowerCase());
if (!user) return; // answer identically whether or not the address exists
user.forgotPasswordToken = randomstring.generate({
length: RESET_CODE_LENGTH,
capitalization: "uppercase",
});
user.forgotPasswordRequestedAt = new Date();
await userModel.update(user);
await sendEmailForgotEmail(user);
}
sendAdminInviteEmail takes a bare address and a token instead of a User, because an invitee has no user row until they sign up. The invite record itself, its token and its expiry belong to the cms interface, whose invite helper dispatches this mail for you.
Announce a Registration
announceRegistration runs everything that must happen once an account has really joined a tenant: the welcome notification, the collaborator notification to the platform owners, and then the USER_REGISTERED hook.
announceRegistration(
userModel: UserModel,
user: User,
tenantId: string,
): Promise<void>
Call it from any registration flow you own — a module with its own signup, a SaaS transactional flow calling it once provisioning has committed. The CMS password signup and a provider login consuming an invitation both go through it, which is what keeps notifications and automations independent of which door the account came through.
The hook is observational: registration is already complete when it runs, so a failing subscriber is logged rather than allowed to fail the request and strand a user whose account exists but whose retry token is gone. The same is true of the notifications — they are dispatched in the background and a failure there is logged, not propagated.
Next Steps
External Identities covers binding a user to the accounts they own on a login provider.