/** * This Source Code is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. * * Copyright (c) Infonomic Company Limited */ /** * `AdminUsersRepository` — the DB-adapter-facing contract for the * `byline_admin_users` table. * * The interface deliberately takes **pre-hashed** password strings * (`password_hash`) rather than plaintext. Argon2 / bcrypt hashing is a * service-layer concern that depends on `@byline/admin/auth` primitives; * keeping it out of the repository means the adapter stays unaware of * password policy and the hashing library of the day. * * **Optimistic concurrency.** Content-shaped writes (`update`, * `setPasswordHash`, `delete`) take an `expectedVid` and bump the stored * `vid` on success. If the stored `vid` does not match `expectedVid` the * adapter throws `AdminUsersError(VERSION_CONFLICT)`, signalling a stale * client. Admin-intent writes that do not depend on current state * (`setEnabled`, login counters) are vid-less — last-writer-wins is the * right semantic for those. * * Adapters (e.g. `@byline/db-postgres`) implement this interface; admin * services (`seed-super-admin`, admin-user commands) consume it. No * caller should ever construct `AdminUsersRepository` instances directly * outside the adapter — use the `AdminStore` bundle passed at * `initBylineCore()` time. */ /** * Public-facing admin-user row — the `password_hash` column is * deliberately omitted. Only `getByEmailForSignIn` returns the hash, and * only so the session provider can verify it. */ export interface AdminUserRow { id: string; vid: number; given_name: string | null; family_name: string | null; username: string | null; email: string; remember_me: boolean; last_login: Date | null; last_login_ip: string | null; failed_login_attempts: number; is_super_admin: boolean; is_enabled: boolean; is_email_verified: boolean; /** * Admin interface locale preference. `null` means "use the detection * cascade" (cookie → Accept-Language → defaultLocale). Stored as a * BCP 47 code; validated at the command layer against the host's * `i18n.admin.locales`. */ preferred_locale: string | null; created_at: Date; updated_at: Date; } /** * Admin-user row including the PHC password hash. Returned only by * `getByEmailForSignIn` — callers must treat it with care (never log, * never return to clients). */ export interface AdminUserWithPasswordRow extends AdminUserRow { password_hash: string; /** Native session generation, independent of edit revisions. */ session_version: number; } export interface CreateAdminUserInput { email: string; /** Pre-hashed PHC string. Service layer hashes plaintext before calling. */ password_hash: string; given_name?: string | null; family_name?: string | null; username?: string | null; is_super_admin?: boolean; is_enabled?: boolean; is_email_verified?: boolean; /** Initial locale preference. `null` defers to the detection cascade. */ preferred_locale?: string | null; } export interface UpdateAdminUserInput { given_name?: string | null; family_name?: string | null; username?: string | null; email?: string; is_super_admin?: boolean; is_enabled?: boolean; is_email_verified?: boolean; remember_me?: boolean; /** Pass `null` to clear and fall back to the detection cascade. */ preferred_locale?: string | null; } export type AdminUserListOrder = 'given_name' | 'family_name' | 'email' | 'username' | 'created_at' | 'updated_at'; export interface ListAdminUsersOptions { /** 1-based page number. */ page: number; /** Page size. Reasonable ceiling applied at the command layer. */ pageSize: number; /** Free-text search across email, given_name, family_name, username. */ query?: string; /** Column to sort by. */ order: AdminUserListOrder; /** True for DESC, false for ASC. */ desc: boolean; } export interface CountAdminUsersOptions { /** Free-text search — same semantics as `list`. */ query?: string; } export interface AdminUsersRepository { create(input: CreateAdminUserInput): Promise; getById(id: string): Promise; /** * Bulk lookup for audit actor-label resolution (the `actors` * map on admin document reads — see docs/07-auth-and-security/02-auditability.md, Workstream 1). Ids * with no matching row are simply absent from the result; callers * render a tombstone label for them. */ getByIds(ids: string[]): Promise; getByEmail(email: string): Promise; getByUsername(username: string): Promise; /** * Sign-in-only lookup. Returns the PHC hash alongside the public row so * the session provider can verify. Callers **must not** persist or echo * the `password_hash` field. */ getByEmailForSignIn(email: string): Promise; /** * Authenticated-verification lookup. Same shape as * `getByEmailForSignIn` but keyed by id — used by the self-service * change-password flow, where the actor is already authenticated and * we need to verify the *current* password before swapping in a new * one. Same handling rules apply: callers **must not** persist or * echo the `password_hash` field. */ getByIdForSignIn(id: string): Promise; /** Paginated, filtered, sorted list. */ list(options: ListAdminUsersOptions): Promise; /** Total row count matching the same filter (for pager `total_pages`). */ count(options?: CountAdminUsersOptions): Promise; /** * Content update with optimistic concurrency. Throws * `AdminUsersError(VERSION_CONFLICT)` if the stored `vid` differs from * `expectedVid`. Bumps `vid` on success and returns the fresh row. * A false `is_enabled` patch must atomically advance the native session * generation and revoke refresh sessions, under the account row lock. */ update(id: string, expectedVid: number, patch: UpdateAdminUserInput): Promise; /** * Replace the stored password hash with optimistic concurrency. * Version-gated on `expectedVid`. Caller supplies a pre-hashed PHC string. * Atomically advance the native session generation and revoke every refresh * session in the same transaction. Lock the account before refresh rows. * Returns the updated row so callers holding the edit form can refresh * their cached `vid` without a second round-trip. */ setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise; /** * Toggle enabled state. Disable must atomically advance the native session * generation and revoke refresh sessions. Enable never resets the generation. */ setEnabled(id: string, enabled: boolean): Promise; /** * Set the admin interface locale preference. Vid-less — user preference * is independent of content state. Pass `null` to clear and fall back * to the detection cascade (cookie → Accept-Language → defaultLocale). */ setPreferredLocale(id: string, locale: string | null): Promise; recordLoginSuccess(id: string, ip: string | null): Promise; recordLoginFailure(id: string): Promise; /** * Delete with optimistic concurrency. Version-gated on `expectedVid` to * prevent races against a concurrent update. */ delete(id: string, expectedVid: number): Promise; }