alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,465 lines • 122 kB
JavaScript
import { $atom, $context, $inject, $module, Alepha, AlephaError, z } from "alepha";
import { $audit } from "alepha/api/audits";
import { $bucket } from "alepha/bucket";
import { $issuer, $permission, $secure, CryptoProvider, InvalidCredentialsError, SecurityProvider } from "alepha/security";
import { $action, BadRequestError, ConflictError, HttpError, NotFoundError, UnauthorizedError, okSchema } from "alepha/server";
import { $entity, $repository, db, pageQuerySchema, sql } from "alepha/orm";
import { $logger } from "alepha/logger";
import { AlephaApiVerification, VerificationService } from "alepha/api/verifications";
import { $client } from "alepha/server/links";
import { $notification } from "alepha/api/notifications";
import { CaptchaProvider } from "alepha/captcha";
import { $authApple, $authCredentials, $authFacebook, $authFranceConnect, $authGithub, $authGoogle, $authMicrosoft, ServerAuthProvider, authenticationProviderSchema } from "alepha/server/auth";
import { $etag } from "alepha/server/etag";
import { $cache, CacheProvider } from "alepha/cache";
import { DatabaseCacheProvider } from "alepha/cache/database";
import { DateTimeProvider } from "alepha/datetime";
import { $job } from "alepha/api/jobs";
import { randomInt } from "node:crypto";
import { FileSystemProvider } from "alepha/system";
import { AlephaApiKeys, ApiKeyService } from "alepha/api/keys";
import { AlephaOAuth, OAuthClientService, oauthOptions } from "alepha/api/oauth";
import { $parameter, AlephaApiParameters } from "alepha/api/parameters";
import { mcpStreamableHttpOptions } from "alepha/mcp";
//#region ../../src/api/users/audits/SessionAudits.ts
/**
* Authentication & session-security audit events.
*
* Holds two audit types:
* - `auth` — login / logout / token refresh / MFA.
* - `security` — rate limiting, session invalidation, and related guards.
*
* Failed events are logged with `success: false`; severity (`warning`) is
* derived centrally in `AuditService.create`. Register as a module variant
* and log via the exposed primitives:
* `sessionAudits(realm)?.auth.log("login", { success: false, … })`.
*/
var SessionAudits = class {
auth = $audit({
type: "auth",
description: "Authentication events (login, logout, token refresh, MFA).",
actions: [
"login",
"logout",
"token_refresh",
"mfa_setup",
"mfa_verify"
]
});
security = $audit({
type: "security",
description: "Security events (rate limiting, session invalidation, blocked access).",
actions: [
"rate_limited",
"sessions_invalidated",
"permission_denied",
"blocked"
]
});
};
//#endregion
//#region ../../src/api/users/audits/UserAudits.ts
/**
* User-management audit events.
*
* Holds the `user` audit type. Mirrors the `$notification`/`$job` holder
* pattern (see {@link UserNotifications}) — register as a module variant and
* log via the exposed primitive: `userAudits(realm)?.user.log("create", …)`.
*/
var UserAudits = class {
user = $audit({
type: "user",
description: "User management events (create, update, delete, role/password changes).",
actions: [
"create",
"update",
"delete",
"role_change",
"password_change",
"enable",
"disable"
]
});
};
//#endregion
//#region ../../src/api/users/buckets/UserBuckets.ts
/**
* User-specific file storage wrapper service.
*
* This service provides file storage for user-related files such as:
* - User avatars/profile pictures
*
* Declared as a module variant — not auto-injected. It is instantiated
* lazily the first time something calls `alepha.inject(UserBuckets)`.
*/
var UserBuckets = class {
/**
* Bucket for user avatar storage.
*/
avatars = $bucket({
maxSize: 5 * 1024 * 1024,
mimeTypes: [
"image/jpeg",
"image/png",
"image/gif",
"image/webp"
]
});
};
//#endregion
//#region ../../src/api/users/schemas/identityQuerySchema.ts
const identityQuerySchema = pageQuerySchema.extend({
userId: z.uuid().optional(),
provider: z.string().optional()
});
//#endregion
//#region ../../src/api/users/entities/users.ts
const DEFAULT_USER_REALM_NAME = "default";
const users = $entity({
name: "users",
schema: z.object({
id: db.primaryKey(z.uuid()),
version: db.version(),
createdAt: db.createdAt(),
updatedAt: db.updatedAt(),
realm: db.default(z.text(), DEFAULT_USER_REALM_NAME),
username: z.shortText({
minLength: 3,
maxLength: 30
}).optional(),
email: z.string().meta({ format: "email" }).optional(),
phoneNumber: z.e164().optional(),
roles: db.default(z.array(z.string()), []),
firstName: z.string().optional(),
lastName: z.string().optional(),
picture: z.string().optional(),
enabled: db.default(z.boolean(), true),
emailVerified: db.default(z.boolean(), false),
lastLoginAt: z.datetime().optional(),
organizationId: db.organization()
}),
indexes: [
{
expressions: (self) => [self.realm, sql`LOWER(${self.username})`],
unique: true,
name: "users_realm_username_lower_idx"
},
{
columns: ["realm", "email"],
unique: true
},
{
columns: ["realm", "phoneNumber"],
unique: true
}
]
});
//#endregion
//#region ../../src/api/users/entities/identities.ts
const identities = $entity({
name: "identities",
schema: z.object({
id: db.primaryKey(z.uuid()),
version: db.version(),
createdAt: db.createdAt(),
updatedAt: db.updatedAt(),
userId: db.ref(z.uuid(), () => users.cols.id),
password: z.text().optional(),
provider: z.text(),
providerUserId: z.text().optional(),
providerData: z.json().optional()
}),
indexes: [
"userId",
"provider",
{ columns: ["userId", "provider"] },
{
columns: ["provider", "providerUserId"],
unique: true
}
]
});
//#endregion
//#region ../../src/api/users/schemas/identityResourceSchema.ts
const identityResourceSchema = identities.schema.omit({ password: true });
//#endregion
//#region ../../src/api/users/atoms/realmAuthSettingsAtom.ts
const fieldRequirement = (description) => z.union([
z.const("none"),
z.const("optional"),
z.const("required")
]).describe(description);
const usernameFieldRequirement = (description) => z.union([
z.const("none"),
z.const("optional"),
z.const("required"),
z.const("email")
]).describe(description);
const realmAuthSettingsAtom = $atom({
name: "alepha.api.users.realmAuthSettings",
schema: z.object({
displayName: z.string().describe("Display name shown on auth pages (e.g., 'Customer Portal')").optional(),
description: z.string().describe("Description shown on auth pages").optional(),
logoUrl: z.string().describe("Logo URL for auth pages").optional(),
registrationAllowed: z.boolean().describe("Enable user self-registration"),
email: fieldRequirement("Email address field requirement for user accounts"),
username: usernameFieldRequirement("Username field requirement for user accounts"),
usernameRegExp: z.string().describe("Regular expression that usernames must match (if username is enabled)"),
usernameBlocklist: z.array(z.text()).describe("Usernames that the slugger / manual registration must reject. Default empty so apps can register `admin`/`root`/`me`/etc. if they want; populate it explicitly for handles you want to keep off-limits."),
phoneNumber: fieldRequirement("Phone number field requirement for user accounts"),
verifyEmailRequired: z.boolean().describe("Require email verification for user accounts"),
verifyPhoneRequired: z.boolean().describe("Require phone verification for user accounts"),
firstNameLastName: fieldRequirement("First and last name field requirement for user accounts"),
resetPasswordAllowed: z.boolean().describe("Enable forgot password functionality"),
captchaRequired: z.boolean().describe("Require captcha verification on registration (needs a CaptchaProvider registered, e.g. TurnstileCaptchaProvider)"),
adminEmails: z.array(z.email()).describe("List of email addresses that are automatically promoted to admin role on login"),
adminUsernames: z.array(z.text()).describe("List of usernames that are automatically promoted to admin role on login"),
defaultRoles: z.array(z.string()).describe("Default roles assigned to newly registered users"),
verifyEmailUrl: z.string().describe("Base URL for email verification links (used when verification method is 'link'). Token and email are appended as query params.").optional(),
passwordPolicy: z.object({
minLength: z.integer().min(1).describe("Minimum password length").default(8),
requireUppercase: z.boolean().describe("Require at least one uppercase letter"),
requireLowercase: z.boolean().describe("Require at least one lowercase letter"),
requireNumbers: z.boolean().describe("Require at least one number"),
requireSpecialCharacters: z.boolean().describe("Require at least one special character")
}),
loginRateLimit: z.object({
ipMaxAttempts: z.integer().min(1).describe("Max failed login attempts per IP before temporary lockout").default(15),
accountMaxAttempts: z.integer().min(1).describe("Max failed login attempts per account before temporary lockout").default(5),
windowMs: z.integer().min(1e3).describe("Rate limit window duration in milliseconds").default(900 * 1e3)
}),
registrationIpMaxAttempts: z.integer().min(1).describe("Max registration attempts per IP before temporary lockout. Default 10 protects against signup abuse; raise it in dev/e2e environments where a single localhost IP spawns many test users.").default(10),
refreshToken: z.object({ expirationIdle: z.integer().min(1e3).describe("Maximum time in milliseconds a refresh token may stay unused before being invalidated. When set, sessions whose last refresh is older than this window are rejected and deleted, even if the absolute `expiresAt` has not been reached. Recommended for SaaS auth posture (SOC2/ISO27001). Leave undefined to disable idle invalidation (default).").optional() })
}),
default: {
registrationAllowed: true,
email: "required",
username: "none",
usernameRegExp: "^[a-zA-Z0-9_-]{3,30}$",
usernameBlocklist: [],
phoneNumber: "none",
verifyEmailRequired: false,
verifyPhoneRequired: false,
resetPasswordAllowed: false,
captchaRequired: false,
firstNameLastName: "none",
adminEmails: [],
adminUsernames: [],
defaultRoles: ["user"],
passwordPolicy: {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialCharacters: false
},
loginRateLimit: {
ipMaxAttempts: 15,
accountMaxAttempts: 5,
windowMs: 900 * 1e3
},
registrationIpMaxAttempts: 10,
refreshToken: {}
}
});
//#endregion
//#region ../../src/api/users/entities/sessions.ts
const sessions = $entity({
name: "sessions",
schema: z.object({
id: db.primaryKey(z.uuid()),
version: db.version(),
createdAt: db.createdAt(),
updatedAt: db.updatedAt(),
refreshToken: z.uuid(),
userId: db.ref(z.uuid(), () => users.cols.id),
/**
* OAuth client this session was minted for, when it was created via the
* OAuth 2.1 authorization flow — the `client_id` of an `oauth_clients`
* row. Null for first-party logins. Deliberately NOT a DB-level foreign
* key: `sessions` is a core entity and must not depend on the optional
* OAuth module's table; the join to `oauth_clients` is done at query time.
*/
clientId: z.text({ maxLength: 64 }).optional(),
expiresAt: z.datetime(),
/**
* Last time the session was used to refresh an access token.
* Used by realm `refreshToken.expirationIdle` to invalidate idle sessions.
* `null` on existing rows pre-migration — falls back to `createdAt`.
*/
lastUsedAt: z.datetime().optional(),
ip: z.text().optional(),
/**
* ISO 3166-1 alpha-2 country code derived from the request geo headers
* (`cf-ipcountry` on Cloudflare, CDN equivalents elsewhere) at login time.
* `null` on pre-migration rows and where geo isn't available.
*/
country: z.text({ maxLength: 2 }).optional(),
userAgent: z.object({
os: z.text(),
browser: z.text(),
device: z.enum([
"MOBILE",
"DESKTOP",
"TABLET"
])
}).optional()
}),
indexes: [
"userId",
"expiresAt",
{
column: "refreshToken",
unique: true
}
]
});
//#endregion
//#region ../../src/api/users/providers/RealmProvider.ts
var RealmProvider = class {
alepha = $inject(Alepha);
defaultIdentities = $repository(identities);
defaultSessions = $repository(sessions);
defaultUsers = $repository(users);
realms = /* @__PURE__ */ new Map();
register(realmName, realmOptions = {}) {
if (realmName.includes(".")) throw new AlephaError(`Realm name "${realmName}" must not contain dots — dots are reserved for parameter tree paths`);
const features = {
jobs: false,
notifications: false,
apiKeys: false,
parameters: false,
avatars: false,
audits: false,
...realmOptions.features
};
const realm = {
name: realmName,
repositories: {
identities: realmOptions.entities?.identities ?? this.defaultIdentities,
sessions: realmOptions.entities?.sessions ?? this.defaultSessions,
users: realmOptions.entities?.users ?? this.defaultUsers
},
settings: {
...realmAuthSettingsAtom.options.default,
...realmOptions.settings,
passwordPolicy: {
...realmAuthSettingsAtom.options.default.passwordPolicy,
...realmOptions.settings?.passwordPolicy
},
loginRateLimit: {
...realmAuthSettingsAtom.options.default.loginRateLimit,
...realmOptions.settings?.loginRateLimit
},
refreshToken: {
...realmAuthSettingsAtom.options.default.refreshToken,
...realmOptions.settings?.refreshToken
}
},
features,
getSettings: async function() {
if (this.settingsParameter) return await this.settingsParameter.get();
return this.settings;
}
};
this.realms.set(realmName, realm);
return this.getRealm(realmName);
}
/**
* Gets a registered realm by name, auto-creating default if needed.
*/
getRealm(realmName = DEFAULT_USER_REALM_NAME) {
let realm = this.realms.get(realmName);
if (!realm) {
const firstRealm = Array.from(this.realms.values())[0];
if (realmName === "default" && firstRealm) realm = firstRealm;
else if (this.alepha.isTest()) realm = this.register(realmName);
else throw new AlephaError(`Missing realm '${realmName}', please declare $realm in your application.`);
}
return realm;
}
identityRepository(realmName = DEFAULT_USER_REALM_NAME) {
return this.getRealm(realmName).repositories.identities;
}
sessionRepository(realmName = DEFAULT_USER_REALM_NAME) {
return this.getRealm(realmName).repositories.sessions;
}
userRepository(realmName = DEFAULT_USER_REALM_NAME) {
return this.getRealm(realmName).repositories.users;
}
};
//#endregion
//#region ../../src/api/users/services/IdentityService.ts
var IdentityService = class {
alepha = $inject(Alepha);
log = $logger();
realmProvider = $inject(RealmProvider);
userAudits(realmName) {
if (this.realmProvider.getRealm(realmName).features.audits) return this.alepha.inject(UserAudits);
}
identities(userRealmName) {
return this.realmProvider.identityRepository(userRealmName);
}
/**
* Find identities with pagination and filtering.
*/
async findIdentities(q = {}, userRealmName) {
this.log.trace("Finding identities", {
query: q,
userRealmName
});
q.sort ??= "-createdAt";
const where = this.identities(userRealmName).createQueryWhere();
if (q.userId) where.userId = { eq: q.userId };
if (q.provider) where.provider = { like: q.provider };
const result = await this.identities(userRealmName).paginate(q, { where }, { count: true });
this.log.debug("Identities found", {
count: result.content.length,
total: result.page.totalElements
});
return result;
}
/**
* Get an identity by ID.
*/
async getIdentityById(id, userRealmName) {
this.log.trace("Getting identity by ID", {
id,
userRealmName
});
const identity = await this.identities(userRealmName).getById(id);
this.log.debug("Identity retrieved", {
id,
provider: identity.provider,
userId: identity.userId
});
return identity;
}
/**
* Delete an identity by ID.
*/
async deleteIdentity(id, userRealmName) {
this.log.trace("Deleting identity", {
id,
userRealmName
});
const identity = await this.getIdentityById(id, userRealmName);
await this.identities(userRealmName).deleteById(id);
this.log.info("Identity deleted", {
id,
provider: identity.provider,
userId: identity.userId
});
const realm = this.realmProvider.getRealm(userRealmName);
await this.userAudits(userRealmName)?.user.log("update", {
resourceType: "user",
userRealm: realm.name,
resourceId: identity.userId,
description: `Identity provider disconnected: ${identity.provider}`,
metadata: {
identityId: id,
provider: identity.provider,
userId: identity.userId
}
});
}
};
//#endregion
//#region ../../src/api/users/controllers/AdminIdentityController.ts
var AdminIdentityController = class {
url = "/identities";
group = "admin:identities";
identityService = $inject(IdentityService);
securityProvider = $inject(SecurityProvider);
/**
* Find identities with pagination and filtering.
*/
findIdentities = $action({
path: this.url,
group: this.group,
use: [$secure({ permissions: ["admin:identity:read"] })],
description: "Find identities with pagination and filtering",
schema: {
query: identityQuerySchema.extend({ userRealmName: z.string().optional() }),
response: z.page(identityResourceSchema)
},
handler: ({ query, user }) => {
const { userRealmName, ...q } = query;
return this.identityService.findIdentities(q, this.securityProvider.assertRealmScope(user, userRealmName));
}
});
/**
* Get an identity by ID.
*/
getIdentity = $action({
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:identity:read"] })],
description: "Get an identity by ID",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
response: identityResourceSchema
},
handler: ({ params, query, user }) => this.identityService.getIdentityById(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName))
});
/**
* Delete an identity.
*/
deleteIdentity = $action({
method: "DELETE",
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:identity:delete"] })],
description: "Delete an identity",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
response: okSchema
},
handler: async ({ params, query, user }) => {
await this.identityService.deleteIdentity(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName));
return {
ok: true,
id: params.id
};
}
});
};
//#endregion
//#region ../../src/api/users/schemas/sessionQuerySchema.ts
const sessionQuerySchema = pageQuerySchema.extend({ userId: z.uuid().optional() });
//#endregion
//#region ../../src/api/users/schemas/sessionResourceSchema.ts
/**
* Slim view of the session's owner — embedded by the admin listing so the
* UI can render a human-readable identifier instead of just a UUID. Comes
* back via a left join, so it's optional (a session whose user was deleted
* still returns; `user` is undefined).
*/
const sessionUserSummarySchema = z.object({
id: z.uuid(),
email: z.string().meta({ format: "email" }).optional(),
username: z.shortText({
minLength: 3,
maxLength: 30
}).optional(),
firstName: z.string().optional(),
lastName: z.string().optional()
});
const sessionResourceSchema = z.object({
id: z.uuid(),
version: z.number(),
createdAt: z.datetime(),
updatedAt: z.datetime(),
refreshToken: z.uuid(),
userId: z.uuid(),
expiresAt: z.datetime(),
ip: z.string().optional(),
country: z.string().optional(),
userAgent: z.object({
os: z.string(),
browser: z.string(),
device: z.enum([
"MOBILE",
"DESKTOP",
"TABLET"
])
}).optional(),
user: sessionUserSummarySchema.optional()
});
//#endregion
//#region ../../src/api/users/services/SessionCrudService.ts
/**
* Relation map embedding a slim user summary on every session row, so the
* admin UI can render `user.email`/`user.username` instead of a bare UUID.
* Left-join (default) so sessions whose owner was deleted still come back
* with `user: undefined`.
*/
const withUser = { user: {
join: users,
on: ["userId", users.cols.id]
} };
var SessionCrudService = class {
log = $logger();
realmProvider = $inject(RealmProvider);
sessions(userRealmName) {
return this.realmProvider.sessionRepository(userRealmName);
}
/**
* Find sessions with pagination and filtering.
*/
async findSessions(q = {}, userRealmName) {
this.log.trace("Finding sessions", {
query: q,
userRealmName
});
q.sort ??= "-createdAt";
const where = this.sessions(userRealmName).createQueryWhere();
if (q.userId) where.userId = { eq: q.userId };
const result = await this.sessions(userRealmName).paginate(q, {
where,
with: withUser
}, { count: true });
this.log.debug("Sessions found", {
count: result.content.length,
total: result.page.totalElements
});
return result;
}
/**
* Get a session by ID.
*/
async getSessionById(id, userRealmName) {
this.log.trace("Getting session by ID", {
id,
userRealmName
});
const session = await this.sessions(userRealmName).getOne({
where: { id: { eq: id } },
with: withUser
});
this.log.debug("Session retrieved", {
id,
userId: session.userId
});
return session;
}
/**
* Delete a session by ID.
*/
async deleteSession(id, userRealmName) {
this.log.trace("Deleting session", {
id,
userRealmName
});
await this.getSessionById(id, userRealmName);
await this.sessions(userRealmName).deleteById(id);
this.log.info("Session deleted", { id });
}
/**
* Delete many sessions by ID in one repository call.
*/
async deleteSessions(ids, userRealmName) {
if (ids.length === 0) return [];
this.log.trace("Deleting sessions", {
count: ids.length,
userRealmName
});
const deleted = await this.sessions(userRealmName).deleteMany({ id: { inArray: ids } });
this.log.info("Sessions deleted", { count: deleted.length });
return deleted.map(String);
}
};
//#endregion
//#region ../../src/api/users/controllers/AdminSessionController.ts
var AdminSessionController = class {
url = "/sessions";
group = "admin:sessions";
sessionService = $inject(SessionCrudService);
securityProvider = $inject(SecurityProvider);
/**
* Find sessions with pagination and filtering.
*/
findSessions = $action({
path: this.url,
group: this.group,
use: [$secure({ permissions: ["admin:session:read"] })],
description: "Find sessions with pagination and filtering",
schema: {
query: sessionQuerySchema.extend({ userRealmName: z.string().optional() }),
response: z.page(sessionResourceSchema)
},
handler: ({ query, user }) => {
const { userRealmName, ...q } = query;
return this.sessionService.findSessions(q, this.securityProvider.assertRealmScope(user, userRealmName));
}
});
/**
* Get a session by ID.
*/
getSession = $action({
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:session:read"] })],
description: "Get a session by ID",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
response: sessionResourceSchema
},
handler: ({ params, query, user }) => this.sessionService.getSessionById(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName))
});
/**
* Delete a session.
*/
deleteSession = $action({
method: "DELETE",
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:session:delete"] })],
description: "Delete a session",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
response: okSchema
},
handler: async ({ params, query, user }) => {
await this.sessionService.deleteSession(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName));
return {
ok: true,
id: params.id
};
}
});
/**
* Delete many sessions in one repository call.
*/
deleteSessions = $action({
method: "POST",
path: `${this.url}/delete`,
group: this.group,
use: [$secure({ permissions: ["admin:session:delete"] })],
description: "Delete many sessions",
schema: {
query: z.object({ userRealmName: z.string().optional() }),
body: z.object({ ids: z.array(z.uuid()).min(1).max(1e3) }),
response: z.object({ deleted: z.array(z.string()) })
},
handler: async ({ body, query, user }) => {
return { deleted: await this.sessionService.deleteSessions(body.ids, this.securityProvider.assertRealmScope(user, query.userRealmName)) };
}
});
};
//#endregion
//#region ../../src/api/users/schemas/createUserSchema.ts
const createUserSchema = users.insertSchema.omit({ realm: true });
//#endregion
//#region ../../src/api/users/schemas/updateUserSchema.ts
const updateUserSchema = users.insertSchema.omit({
id: true,
version: true,
createdAt: true,
updatedAt: true
}).partial();
//#endregion
//#region ../../src/api/users/schemas/userQuerySchema.ts
const userQuerySchema = pageQuerySchema.extend({
/**
* Free-text search applied (case-insensitive) across `email`,
* `username`, `firstName`, and `lastName`. Matches with a leading and
* trailing wildcard, so `?search=foo` finds any user whose email,
* username, or name contains `foo`.
*/
search: z.string().optional(),
email: z.string().optional(),
enabled: z.boolean().optional(),
emailVerified: z.boolean().optional(),
roles: z.array(z.string()).optional()
});
//#endregion
//#region ../../src/api/users/schemas/userResourceSchema.ts
const userResourceSchema = users.schema;
//#endregion
//#region ../../src/api/users/notifications/UserNotifications.ts
var UserNotifications = class {
passwordReset = $notification({
category: "security",
description: "Email sent to users with a verification code to reset their password.",
critical: true,
sensitive: true,
email: {
subject: "Reset your password",
body: (it) => `
<h1>Reset Your Password</h1>
<p>Hi ${it.email},</p>
<p>We received a request to reset your password. Use the code below to verify your identity:</p>
<p style="margin: 30px 0; text-align: center;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;">
${it.code}
</span>
</p>
<p>This code will expire in ${it.expiresInMinutes} minutes.</p>
<p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
<p>Best regards,<br>The Team</p>
`
},
translations: { fr: { email: {
subject: "Réinitialisez votre mot de passe",
body: (it) => `
<h1>Réinitialisez votre mot de passe</h1>
<p>Bonjour,</p>
<p>Nous avons reçu une demande de réinitialisation de votre mot de passe. Utilisez le code ci-dessous pour confirmer votre identité :</p>
<p style="margin: 30px 0; text-align: center;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;">
${it.code}
</span>
</p>
<p>Ce code expire dans ${it.expiresInMinutes} minutes.</p>
<p>Si vous n'avez pas demandé de réinitialisation, vous pouvez ignorer cet e-mail : votre mot de passe restera inchangé.</p>
`
} } },
schema: z.object({
email: z.string().meta({ format: "email" }),
code: z.string(),
expiresInMinutes: z.number()
})
});
emailVerification = $notification({
category: "security",
description: "Email sent to users with a verification code to verify their email address.",
critical: true,
sensitive: true,
email: {
subject: "Verify your email address",
body: (it) => `
<h1>Verify Your Email Address</h1>
<p>Hi ${it.email},</p>
<p>Thanks for signing up! Use the code below to verify your email address:</p>
<p style="margin: 30px 0; text-align: center;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;">
${it.code}
</span>
</p>
<p>This code will expire in ${it.expiresInMinutes} minutes.</p>
<p>If you did not create an account, please ignore this email.</p>
<p>Best regards,<br>The Team</p>
`
},
translations: { fr: { email: {
subject: "Vérifiez votre adresse e-mail",
body: (it) => `
<h1>Vérifiez votre adresse e-mail</h1>
<p>Bonjour,</p>
<p>Merci de votre inscription ! Utilisez le code ci-dessous pour vérifier votre adresse e-mail :</p>
<p style="margin: 30px 0; text-align: center;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; font-family: monospace; background-color: #f5f5f5; padding: 16px 24px; border-radius: 8px; display: inline-block;">
${it.code}
</span>
</p>
<p>Ce code expire dans ${it.expiresInMinutes} minutes.</p>
<p>Si vous n'avez pas créé de compte, ignorez cet e-mail.</p>
`
} } },
schema: z.object({
email: z.string().meta({ format: "email" }),
code: z.string(),
expiresInMinutes: z.number()
})
});
phoneVerification = $notification({
category: "security",
description: "SMS sent to users with a verification code to verify their phone number.",
critical: true,
sensitive: true,
sms: { message: (it) => `Your verification code is: ${it.code}. This code expires in ${it.expiresInMinutes} minutes.` },
schema: z.object({
phoneNumber: z.string(),
code: z.string(),
expiresInMinutes: z.number()
})
});
passwordResetLink = $notification({
category: "security",
description: "Email sent to users with a link to reset their password.",
critical: true,
sensitive: true,
email: {
subject: "Reset your password",
body: (it) => `
<h1>Reset Your Password</h1>
<p>Hi ${it.email},</p>
<p>We received a request to reset your password. Click the link below to create a new password:</p>
<p style="margin: 30px 0;">
<a href="${it.resetUrl}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Reset Password
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666;">${it.resetUrl}</p>
<p>This link will expire in ${it.expiresInMinutes} minutes.</p>
<p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
<p>Best regards,<br>The Team</p>
`
},
schema: z.object({
email: z.string().meta({ format: "email" }),
resetUrl: z.string(),
expiresInMinutes: z.number()
})
});
accountLockout = $notification({
category: "security",
description: "Email sent to users when their account is temporarily locked due to too many failed login attempts.",
critical: true,
sensitive: true,
email: {
subject: "Account temporarily locked",
body: (it) => `
<h1>Account Temporarily Locked</h1>
<p>Hi ${it.email},</p>
<p>Your account has been temporarily locked due to too many failed login attempts.</p>
<p>If this was you, please wait ${it.lockoutMinutes} minutes before trying again. If you've forgotten your password, you can reset it using the password reset feature.</p>
<p>If this wasn't you, someone may be trying to access your account. We recommend changing your password as soon as possible.</p>
<p>Best regards,<br>The Team</p>
`
},
translations: { fr: { email: {
subject: "Compte temporairement verrouillé",
body: (it) => `
<h1>Compte temporairement verrouillé</h1>
<p>Bonjour,</p>
<p>Votre compte a été temporairement verrouillé suite à un trop grand nombre de tentatives de connexion échouées.</p>
<p>Si c'était bien vous, patientez ${it.lockoutMinutes} minutes avant de réessayer. Mot de passe oublié ? Utilisez la réinitialisation de mot de passe.</p>
<p>Si ce n'était pas vous, quelqu'un essaie peut-être d'accéder à votre compte. Nous vous recommandons de changer votre mot de passe au plus vite.</p>
`
} } },
schema: z.object({
email: z.string().meta({ format: "email" }),
lockoutMinutes: z.number()
})
});
emailVerificationLink = $notification({
category: "security",
description: "Email sent to users with a link to verify their email address.",
critical: true,
sensitive: true,
email: {
subject: "Verify your email address",
body: (it) => `
<h1>Verify Your Email Address</h1>
<p>Hi ${it.email},</p>
<p>Thanks for signing up! Click the button below to verify your email address:</p>
<p style="margin: 30px 0;">
<a href="${it.verifyUrl}" style="background-color: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Verify Email
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666;">${it.verifyUrl}</p>
<p>This link will expire in ${it.expiresInMinutes} minutes.</p>
<p>If you did not create an account, please ignore this email.</p>
<p>Best regards,<br>The Team</p>
`
},
schema: z.object({
email: z.string().meta({ format: "email" }),
verifyUrl: z.string(),
expiresInMinutes: z.number()
})
});
};
//#endregion
//#region ../../src/api/users/services/UserService.ts
var UserService = class {
alepha = $inject(Alepha);
log = $logger();
verificationController = $client();
verificationService = $inject(VerificationService);
realmProvider = $inject(RealmProvider);
cryptoProvider = $inject(CryptoProvider);
userAudits(realmName) {
if (this.realmProvider.getRealm(realmName).features.audits) return this.alepha.inject(UserAudits);
}
userNotifications(realmName) {
if (this.realmProvider.getRealm(realmName).features.notifications) return this.alepha.inject(UserNotifications);
}
users(userRealmName) {
return this.realmProvider.userRepository(userRealmName);
}
/**
* Request email verification for a user.
* @param email - The email address to verify.
* @param userRealmName - Optional realm name.
* @param method - The verification method: "code" (default) or "link".
* @param verifyUrl - Base URL for verification link (required when method is "link").
*/
async requestEmailVerification(email, userRealmName, method = "code") {
this.log.trace("Requesting email verification", {
email,
userRealmName,
method
});
const user = await this.users(userRealmName).findOne({ where: { email: { eq: email } } });
if (!user) {
this.log.debug("Email verification requested for non-existent user", { email });
return true;
}
if (user.emailVerified) {
this.log.debug("Email verification requested for already verified user", {
email,
userId: user.id
});
return true;
}
try {
const verification = await this.verificationService.createVerification({
type: method,
target: email
});
if (method === "link") {
const realmSettings = await this.realmProvider.getRealm(userRealmName).getSettings();
const baseUrl = realmSettings.verifyEmailUrl ?? "/verify-email";
const url = new URL(baseUrl, "http://localhost");
url.searchParams.set("email", email);
url.searchParams.set("token", verification.token);
const fullVerifyUrl = realmSettings.verifyEmailUrl ? `${baseUrl}${url.search}` : url.pathname + url.search;
await this.userNotifications(userRealmName)?.emailVerificationLink.push({
contact: email,
variables: {
email,
verifyUrl: fullVerifyUrl,
expiresInMinutes: Math.floor(verification.codeExpiration / 60)
}
});
this.log.debug("Email verification link sent", {
email,
userId: user.id
});
} else {
await this.userNotifications(userRealmName)?.emailVerification.push({
contact: email,
variables: {
email,
code: verification.token,
expiresInMinutes: Math.floor(verification.codeExpiration / 60)
}
});
this.log.debug("Email verification code sent", {
email,
userId: user.id
});
}
} catch (error) {
this.log.warn("Failed to send email verification", {
email,
error
});
}
return true;
}
/**
* Verify a user's email using a valid verification token.
* Supports both code (6-digit) and link (UUID) verification tokens.
*/
async verifyEmail(email, token, userRealmName) {
this.log.trace("Verifying email", {
email,
userRealmName
});
const type = /^\d{6}$/.test(token) ? "code" : "link";
if ((await this.verificationController.validateVerificationCode({
params: { type },
body: {
target: email,
token
}
}).catch(() => {
this.log.warn("Invalid email verification token", {
email,
type
});
throw new BadRequestError("Invalid or expired verification token");
})).alreadyVerified) {
this.log.warn("Email verification token already used", { email });
throw new BadRequestError("Invalid or expired verification token");
}
const user = await this.users(userRealmName).getOne({ where: { email: { eq: email } } });
await this.users(userRealmName).updateById(user.id, { emailVerified: true });
this.log.info("Email verified", {
email,
userId: user.id,
type
});
const realm = this.realmProvider.getRealm(userRealmName);
await this.userAudits(userRealmName)?.user.log("update", {
resourceType: "user",
userId: user.id,
userEmail: email,
userRealm: realm.name,
resourceId: user.id,
description: "Email verified",
metadata: {
email,
verificationType: type
}
});
}
/**
* Check if an email is verified.
*/
async isEmailVerified(email, userRealmName) {
this.log.trace("Checking if email is verified", {
email,
userRealmName
});
return (await this.users(userRealmName).findOne({ where: { email: { eq: email } } }))?.emailVerified ?? false;
}
/**
* Find users with pagination and filtering.
*/
async findUsers(q = {}, userRealmName) {
this.log.trace("Finding users", {
query: q,
userRealmName
});
q.sort ??= "-createdAt";
const where = this.users(userRealmName).createQueryWhere();
if (q.search) {
const pattern = `%${q.search}%`;
where.or = [
{ email: { ilike: pattern } },
{ username: { ilike: pattern } },
{ firstName: { ilike: pattern } },
{ lastName: { ilike: pattern } }
];
}
if (q.email) where.email = { like: q.email };
if (q.enabled !== void 0) where.enabled = { eq: q.enabled };
if (q.emailVerified !== void 0) where.emailVerified = { eq: q.emailVerified };
if (q.roles) where.roles = { arrayContains: q.roles };
const result = await this.users(userRealmName).paginate(q, { where }, { count: true });
this.log.debug("Users found", {
count: result.content.length,
total: result.page.totalElements
});
return result;
}
/**
* Get a user by ID.
*/
async getUserById(id, userRealmName) {
this.log.trace("Getting user by ID", {
id,
userRealmName
});
return await this.users(userRealmName).getById(id);
}
/**
* Create a new user.
*/
async createUser(data, userRealmName) {
this.log.trace("Creating user", {
username: data.username,
email: data.email,
userRealmName
});
const realm = this.realmProvider.getRealm(userRealmName);
const realmSettings = await realm.getSettings();
if (data.username) {
if (await this.users(userRealmName).findOne({ where: {
realm: realm.name,
username: { ilike: data.username }
} })) {
this.log.debug("Username already taken", { username: data.username });
throw new BadRequestError("User with this username already exists");
}
}
if (data.email) {
if (await this.users(userRealmName).findOne({ where: {
realm: realm.name,
email: { eq: data.email }
} })) {
this.log.debug("Email already taken", { email: data.email });
throw new BadRequestError("User with this email already exists");
}
}
if (data.phoneNumber) {
if (await this.users(userRealmName).findOne({ where: {
realm: realm.name,
phoneNumber: { eq: data.phoneNumber }
} })) {
this.log.debug("Phone number already taken", { phoneNumber: data.phoneNumber });
throw new BadRequestError("User with this phone number already exists");
}
}
const user = await this.users(userRealmName).create({
...data,
roles: data.roles ?? realmSettings.defaultRoles,
realm: realm.name
});
this.log.info("User created", {
userId: user.id,
username: user.username,
email: user.email
});
await this.userAudits(userRealmName)?.user.log("create", {
resourceType: "user",
userRealm: realm.name,
resourceId: user.id,
description: "User created",
metadata: {
username: user.username,
email: user.email,
roles: user.roles
}
});
return user;
}
/**
* Update an existing user.
*/
async updateUser(id, data, userRealmName) {
this.log.trace("Updating user", {
id,
userRealmName
});
const before = await this.getUserById(id, userRealmName);
const realm = this.realmProvider.getRealm(userRealmName);
const users = this.users(userRealmName);
if (data.username !== void 0 && data.username !== null && data.username !== before.username) {
const existing = await users.findOne({ where: {
realm: realm.name,
username: { ilike: data.username }
} });
if (existing && existing.id !== id) throw new ConflictError("User with this username already exists");
}
if (data.email !== void 0 && data.email !== null && data.email !== before.email) {
const existing = await users.findOne({ where: {
realm: realm.name,
email: { eq: data.email }
} });
if (existing && existing.id !== id) throw new ConflictError("User with this email already exists");
data.emailVerified = false;
}
const user = await users.updateById(id, data);
this.log.debug("User updated", { userId: id });
const changes = {};
for (const key of Object.keys(data)) if (data[key] !== void 0 && before[key] !== data[key]) changes[key] = {
from: before[key],
to: data[key]
};
const isRoleChange = data.roles !== void 0 && JSON.stringify(before.roles) !== JSON.stringify(data.roles);
await this.userAudits(userRealmName)?.user.log(isRoleChange ? "role_change" : "update", {
resourceType: "user",
userRealm: realm.name,
resourceId: user.id,
description: isRoleChange ? "User roles changed" : `User updated: ${Object.keys(changes).join(", ")}`,
metadata: { changes }
});
return user;
}
/**
* Set (or reset) a user's password. Upserts a "credentials" identity
* with the new hash. Used by admin password-set flows; does NOT
* verify any old password or token — the caller is responsible for
* authorization.
*/
async setPassword(id, newPassword, userRealmName) {
this.log.trace("Setting password", {
id,
userRealmName
});
const user = await this.getUserById(id, userRealmName);
const realm = this.realmProvider.getRealm(userRealmName);
const settings = await realm.getSettings();
if (settings.passwordPolicy) {
const policy = settings.passwordPolicy;
if (policy.minLength && newPassword.length < policy.minLength) throw new BadRequestError(`Password must be at least ${policy.minLength} characters`);
}
const hash = await this.cryptoProvider.hashPassword(newPassword);
const identities = this.realmProvider.identityRepository(userRealmName);
const existing = await identities.findOne({ where: {
userId: { eq: id },
provider: { eq: "credentials" }
} });
if (existing) await identities.updateById(existing.id, { password: hash });
else await identities.create({
userId: id,
provider: "credentials",
password: hash
});
await this.userAudits(userRealmName)?.user.log("password_change", {
resourceType: "user",
userId: id,
userEmail: user.email ?? void 0,
userRealm: realm.name,
resourceId: id,
severity: "warning",
description: "Password set by admin"
});
}
/**
* Delete a user by ID.
*/
async deleteUser(id, userRealmName) {
this.log.trace("Deleting user", {
id,
userRealmName
});
const user = await this.getUserById(id, userRealmName);
await this.realmProvider.sessionRepository(userRealmName).deleteMany({ userId: { eq: id } });
await this.realmProvider.identityRepository(userRealmName).deleteMany({ userId: { eq: id } });
await this.users(userRealmName).deleteById(id);
this.log.info("User deleted", { userId: id });
const realm = this.realmProvider.getRealm(userRealmName);
await this.userAudits(userRealmName)?.user.log("delete", {
resourceType: "user",
userRealm: realm.name,
resourceId: id,
severity: "warning",
description: "User deleted",
metadata: {
username: user.username,
email: user.email
}
});
}
};
//#endregion
//#region ../../src/api/users/controllers/AdminUserController.ts
var AdminUserController = class {
url = "/users";
group = "admin:users";
userService = $inject(UserService);
securityProvider = $inject(SecurityProvider);
/**
* List roles available in a realm. Used by the admin UI to render the
* role picker and grey out defaults (which cannot be removed).
*/
findRoles = $action({
path: "/metadata/roles",
group: this.group,
use: [$secure({ permissions: ["admin:user:read"] })],
description: "List roles available in a realm",
schema: {
query: z.object({ userRealmName: z.string().optional() }),
response: z.array(z.object({
name: z.string(),
default: z.boolean().optional(),
description: z.string().optional()
}))
},
handler: ({ query, user }) => {
return this.securityProvider.getRoles(this.securityProvider.assertRealmScope(user, query.userRealmName)).map((r) => ({
name: r.name,
default: r.default,
description: r.description
}));
}
});
/**
* Find users with pagination and filtering.
*/
findUsers = $action({
path: this.url,
group: this.group,
use: [$secure({ permissions: ["admin:user:read"] })],
description: "Find users with pagination and filtering",
schema: {
query: userQuerySchema.extend({ userRealmName: z.string().optional() }),
response: z.page(userResourceSchema)
},
handler: ({ query, user }) => {
const { userRealmName, ...q } = query;
return this.userService.findUsers(q, this.securityProvider.assertRealmScope(user, userRealmName));
}
});
/**
* Get a user by ID.
*/
getUser = $action({
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:user:read"] })],
description: "Get a user by ID",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
response: userResourceSchema
},
handler: ({ params, query, user }) => this.userService.getUserById(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName))
});
/**
* Create a new user.
*/
createUser = $action({
method: "POST",
path: this.url,
group: this.group,
use: [$secure({ permissions: ["admin:user:create"] })],
description: "Create a new user",
schema: {
query: z.object({ userRealmName: z.string().optional() }),
body: createUserSchema,
response: userResourceSchema
},
handler: ({ body, query, user }) => this.userService.createUser(body, this.securityProvider.assertRealmScope(user, query.userRealmName))
});
/**
* Update a user.
*/
updateUser = $action({
method: "PATCH",
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:user:update"] })],
description: "Update a user",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
body: updateUserSchema,
response: userResourceSchema
},
handler: ({ params, body, query, user }) => this.userService.updateUser(params.id, body, this.securityProvider.assertRealmScope(user, query.userRealmName))
});
/**
* Set (or reset) a user's password. Admin-only flow — does NOT
* require knowing the previous password. Hash is stored as a
* "credentials" identity for the user (upsert).
*/
setUserPassword = $action({
method: "POST",
path: `${this.url}/:id/password`,
group: this.group,
use: [$secure({ permissions: ["admin:user:update"] })],
description: "Set a user's password",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
body: z.object({ password: z.string().min(1) }),
response: okSchema
},
handler: async ({ params, body, query, user }) => {
await this.userService.setPassword(params.id, body.password, this.securityProvider.assertRealmScope(user, query.userRealmName));
return {
ok: true,
id: params.id
};
}
});
/**
* Delete a user.
*/
deleteUser = $action({
method: "DELETE",
path: `${this.url}/:id`,
group: this.group,
use: [$secure({ permissions: ["admin:user:delete"] })],
description: "Delete a user",
schema: {
params: z.object({ id: z.uuid() }),
query: z.object({ userRealmName: z.string().optional() }),
response: okSchema
},
handler: async ({ params, query, user }) => {
await this.userService.deleteUser(params.id, this.securityProvider.assertRealmScope(user, query.userRealmName));
return {
ok: true,
id: params.id
};
}
});
/**
* Delete many users in one request. Each id is processed sequentially so
* cascades and side-effects run as if called one-by-one. Errors on a single
* id surface with that id in the response.
*/
deleteUsers = $action({
method: "POST",
path: `${this.url}/delete`,
group: this.group,
use: [$secure({ permissions: ["admin:user:delete"] })],
description: "Delete many users",
schema: {
query: z.object({ user