auth/account_queries.ts

Account and actor database queries.

Provides CRUD operations for the account and actor tables. For v1, every account has exactly one actor (1:1).

view source

Declarations
#

22 declarations

ACCOUNT_COLUMNS
#

auth/account_queries.ts view source

readonly ["id", "username", "email", "email_verified", "password_hash", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by"] import {ACCOUNT_COLUMNS} from '@fuzdev/fuz_app/auth/account_queries.js';

The full account column set, named explicitly so a row read fails loud on schema drift.

SELECT * silently omits a dropped column, which the login lookups then misread: query_account_by_username_or_email filters its result with account.deleted_at === null, so a missing deleted_at column reads back as undefined, undefined === null is false, and *every* login resolves to "not found" (401) — a silent, total auth outage instead of an error. Selecting named columns turns that drift into a hard Postgres column "..." does not exist. Mirrors the Rust side (fuz_auth/src/account_queries.rs), which selects named columns and decodes them positionally. Keep in sync with Account and the account DDL in auth/auth_ddl.ts.

AccountIdentitySnapshot
#

auth/account_queries.ts view source

AccountIdentitySnapshot import type {AccountIdentitySnapshot} from '@fuzdev/fuz_app/auth/account_queries.js';

Identifying values snapshotted into a deletion/purge audit event so the identity behind a now-orphaned audit_log id isn't lost. Mirrors the Rust AccountIdentitySnapshot.

username

type string

email

type string | null

ACTOR_COLUMNS
#

auth/account_queries.ts view source

readonly ["id", "account_id", "name", "created_at", "updated_at", "updated_by", "deleted_at", "deleted_by"] import {ACTOR_COLUMNS} from '@fuzdev/fuz_app/auth/account_queries.js';

The full actor column set — the same fail-loud discipline as ACCOUNT_COLUMNS (this module owns both tables). Keep in sync with Actor and the actor DDL in auth/auth_ddl.ts.

AdminAccountListOptions
#

auth/account_queries.ts view source

AdminAccountListOptions import type {AdminAccountListOptions} from '@fuzdev/fuz_app/auth/account_queries.js';

limit?

Max accounts to return. Defaults to ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT when omitted; pass null explicitly to disable the limit (unbounded fetch — for trusted internal callers / scripts; the RPC schema bounds wire callers to [1, ADMIN_ACCOUNT_LIST_LIMIT_MAX]).

type number | null

offset?

Pagination offset. Defaults to 0.

type number | null

include_deleted?

Include soft-deleted (tombstoned) accounts. Defaults to false — the listing shows active accounts only, matching auth resolution. Set true for the admin UI's "show deleted" view, which offers reactivation via account_undelete.

type boolean | null

query_account_by_email
#

auth/account_queries.ts view source

(deps: QueryDeps, email: string): Promise<Account | undefined> import {query_account_by_email} from '@fuzdev/fuz_app/auth/account_queries.js';

Find an account by email (case-insensitive).

deps

email

type string

returns

Promise<Account | undefined>

query_account_by_id
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string): Promise<Account | undefined> import {query_account_by_id} from '@fuzdev/fuz_app/auth/account_queries.js';

Find an active account by id (deleted_at IS NULL).

This is the auth-resolution workhorse (build_request_context / build_account_context) and the admin-target lookup, so it deliberately excludes soft-deleted accounts: a tombstoned account must not authenticate (delete = soft, purge = hard). Purge, which must operate on soft-deleted rows too, uses query_purge_account directly.

deps

id

type string

returns

Promise<Account | undefined>

query_account_by_username
#

auth/account_queries.ts view source

(deps: QueryDeps, username: string): Promise<Account | undefined> import {query_account_by_username} from '@fuzdev/fuz_app/auth/account_queries.js';

Find an account by username (case-insensitive).

deps

username

type string

returns

Promise<Account | undefined>

query_account_by_username_or_email
#

auth/account_queries.ts view source

(deps: QueryDeps, input: string): Promise<Account | undefined> import {query_account_by_username_or_email} from '@fuzdev/fuz_app/auth/account_queries.js';

Find an account by username or email.

If the input contains @, tries email lookup first then username. Otherwise tries username first then email. This supports a single login field that accepts either format.

Excludes soft-deleted accounts (deleted_at IS NULL) — this is the login lookup, and a tombstoned account must not authenticate. The underlying query_account_by_username / query_account_by_email stay unfiltered because the invite-collision checks need to see soft-deleted accounts (usernames/emails stay reserved after a soft-delete).

deps

query dependencies

input

username or email address

type string

returns

Promise<Account | undefined>

the matching active account, or undefined

query_account_has_any
#

auth/account_queries.ts view source

(deps: QueryDeps): Promise<boolean> import {query_account_has_any} from '@fuzdev/fuz_app/auth/account_queries.js';

Check if any account exists.

deps

returns

Promise<boolean>

query_account_soft_delete
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string, deleted_by: string | null): Promise<AccountIdentitySnapshot | undefined> import {query_account_soft_delete} from '@fuzdev/fuz_app/auth/account_queries.js';

Soft-delete an account — the reversible tombstone (delete = soft).

Stamps deleted_at + deleted_by (the initiator's actor) on the active row — paired like role_grant's revoked_at / revoked_by, and deliberately leaving updated_at / updated_by untouched (deletion isn't a content edit). Returns the identity snapshot for the account_delete audit event, or undefined when no active row matched (missing or already soft-deleted). Auth resolution (query_account_by_id) already excludes soft-deleted accounts; the caller revokes sessions/tokens.

deps

id

type string

deleted_by

type string | null

returns

Promise<AccountIdentitySnapshot | undefined>

query_account_undelete
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string): Promise<AccountIdentitySnapshot | undefined> import {query_account_undelete} from '@fuzdev/fuz_app/auth/account_queries.js';

Reactivate a soft-deleted account — clears the deleted_at / deleted_by tombstone (the inverse of query_account_soft_delete).

Operates only on a currently soft-deleted row (deleted_at IS NOT NULL) and returns the identity snapshot for the account_undelete audit event, or undefined when no soft-deleted row matched (missing or already active). Reactivation does not restore the revoked sessions/tokens — the account is live again but its principals re-auth fresh. updated_at / updated_by stay untouched.

deps

id

type string

returns

Promise<AccountIdentitySnapshot | undefined>

query_active_actors_by_account
#

auth/account_queries.ts view source

(deps: QueryDeps, account_id: string): Promise<Actor[]> import {query_active_actors_by_account} from '@fuzdev/fuz_app/auth/account_queries.js';

List active (non-tombstoned) actors on an account, ordered by created_at.

Filters deleted_at IS NULL so a soft-deleted actor can never be resolved as the acting actor (and carry its role_grants into the role gate). Used by resolve_acting_actor to resolve the acting actor for a request: 1 actor picks transparently, multiple require an explicit acting field on the request payload. The admin/snapshot handlers that legitimately need tombstoned rows stay on the unfiltered query_actors_by_account.

deps

account_id

type string

returns

Promise<Actor[]>

query_actor_by_id
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string): Promise<Actor | undefined> import {query_actor_by_id} from '@fuzdev/fuz_app/auth/account_queries.js';

Find an actor by id.

deps

id

type string

returns

Promise<Actor | undefined>

query_actor_soft_delete
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string, deleted_by: string | null): Promise<boolean> import {query_actor_soft_delete} from '@fuzdev/fuz_app/auth/account_queries.js';

Soft-delete one actor (sets deleted_at + deleted_by). Returns true when an active row flipped, false when none matched. Emitted per actor alongside the account-level soft-delete.

deps

id

type string

deleted_by

type string | null

returns

Promise<boolean>

query_actor_undelete
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string): Promise<boolean> import {query_actor_undelete} from '@fuzdev/fuz_app/auth/account_queries.js';

Reactivate one soft-deleted actor (clears deleted_at / deleted_by). Returns true when a soft-deleted row flipped back, false when none matched. Emitted per actor alongside the account-level reactivation.

deps

id

type string

returns

Promise<boolean>

query_actors_by_account
#

auth/account_queries.ts view source

(deps: QueryDeps, account_id: string): Promise<Actor[]> import {query_actors_by_account} from '@fuzdev/fuz_app/auth/account_queries.js';

List every actor on an account, ordered by created_at, including soft-deleted (tombstoned) rows.

Used by the admin/snapshot handlers (account_delete / account_purge / account_undelete) that must enumerate every actor that ever existed on the account to snapshot names into per-actor audit events. For the acting-actor resolution path — which must never resolve a tombstoned actor — use query_active_actors_by_account. For lookups by id, use query_actor_by_id instead.

deps

account_id

type string

returns

Promise<Actor[]>

query_admin_account_list
#

auth/account_queries.ts view source

(deps: QueryDeps, options?: AdminAccountListOptions | undefined): Promise<{ account: { id: string & $brand<"Uuid">; username: string; ... 5 more ...; deleted_at: string | null; }; actor: { ...; } | null; role_grants: { ...; }[]; pending_offers: { ...; }[]; }[]> import {query_admin_account_list} from '@fuzdev/fuz_app/auth/account_queries.js';

List accounts with their actors, active role_grants, and pending inbound role_grant offers for admin display.

Pages the accounts query (one round-trip), then fans out three parallel lookups scoped to the page's account_ids (one round-trip). The role_grants and offers queries use a subquery on actor.account_id so the page bound pushes through to the DB without round-tripping actor.ids back to the application. Pending offers surface the "offer pending — awaiting acceptance" UX; message is intentionally excluded (cross-admin visibility of grantor notes would expand beyond what the audit log discloses).

deps

query dependencies

options?

optional {limit, offset}. Default limit is ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT; pass limit: null to disable.

type AdminAccountListOptions | undefined
optional

returns

Promise<{ account: { id: string & $brand<"Uuid">; username: string; email: string | null; email_verified: boolean; created_at: string; updated_at: string; updated_by: (string & $brand<"Uuid">) | null; deleted_at: string | null; }; actor: { ...; } | null; role_grants: { ...; }[]; pending_offers: { ...; }[]; }[]>

admin account entries sorted by creation date (oldest first)

query_create_account
#

auth/account_queries.ts view source

(deps: QueryDeps, input: CreateAccountInput): Promise<Account> import {query_create_account} from '@fuzdev/fuz_app/auth/account_queries.js';

Create a new account.

deps

query dependencies

input

the account fields

returns

Promise<Account>

the created account

query_create_account_with_actor
#

auth/account_queries.ts view source

(deps: QueryDeps, input: CreateAccountInput): Promise<{ account: Account; actor: Actor; }> import {query_create_account_with_actor} from '@fuzdev/fuz_app/auth/account_queries.js';

Create an account and its actor in a single operation.

For v1, every account gets exactly one actor with the same name as the username.

deps

query dependencies

input

the account fields

returns

Promise<{ account: Account; actor: Actor; }>

the created account and actor

query_create_actor
#

auth/account_queries.ts view source

(deps: QueryDeps, account_id: string, name: string): Promise<Actor> import {query_create_actor} from '@fuzdev/fuz_app/auth/account_queries.js';

Create a new actor for an account.

deps

query dependencies

account_id

the owning account

type string

name

display name (defaults to account username)

type string

returns

Promise<Actor>

the created actor

query_purge_account
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string): Promise<AccountIdentitySnapshot | undefined> import {query_purge_account} from '@fuzdev/fuz_app/auth/account_queries.js';

Hard-purge an account — irreversible cascading removal (purge = hard).

Physically deletes the row, cascading to actors, role_grants, sessions, and tokens. Operates on active OR already-soft-deleted rows. Returns the identity snapshot for the account_purge audit event, or undefined when no row matched. The audit_log identity columns carry no FK, so the purged id survives on historical rows for forensic correlation back to the purge event.

Keeper-gated, loud, irreversible — restrict to the keeper credential and confirm explicitly at the call site. The purge name flags the danger.

deps

id

type string

returns

Promise<AccountIdentitySnapshot | undefined>

query_update_account_password
#

auth/account_queries.ts view source

(deps: QueryDeps, id: string, password_hash: string, updated_by: string | null, expected_hash: string): Promise<boolean> import {query_update_account_password} from '@fuzdev/fuz_app/auth/account_queries.js';

Update the password hash for an account, conditional on the current stored hash matching expected_hash — the verify-write atomic guard.

The condition closes the race where two concurrent password changes both verify against the pre-update hash (loaded by the authorization phase outside the route's transaction) and would otherwise both UPDATE, silently clobbering whichever lands first. With the conditional WHERE, the second UPDATE matches zero rows; the route reads the boolean return and surfaces 401 instead of pretending success.

Pass the same hash the verify ran against — typically ctx.account.password_hash from the request context.

deps

id

type string

password_hash

type string

updated_by

type string | null

expected_hash

type string

returns

Promise<boolean>

true if the row was updated, false if expected_hash no longer matched (concurrent change won — caller should treat as a stale-credential failure).

Depends on
#

Imported by
#