auth/role_grant_offer_queries.ts

Role grant offer database queries.

Covers the offer side of the consentful-role-grants flow: create (with re-offer upsert), decline, retract, list, find-pending, sweep-expired, and the atomic query_accept_offer that bridges offer → role_grant.

IDOR guards are expressed in each helper's signature — decline/accept require the recipient's to_account_id, retract requires the grantor's from_actor_id.

view source

Declarations
#

19 declarations

AcceptOfferInput
#

auth/role_grant_offer_queries.ts view source

AcceptOfferInput import type {AcceptOfferInput} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

offer_id

type Uuid

to_account_id

Account of the accepting recipient — IDOR guard against another account accepting the offer.

type Uuid

actor_id

Accepting actor — the actor that will hold the resulting role_grant. Must belong to to_account_id; the query verifies and throws if not (defense-in-depth — the action handler passes auth.actor.id which is session-bound, but the query enforces the invariant for all callers including tests and future direct consumers).

Required because under multi-actor an account may host many actors; the resulting role_grant must bind to the actor that actually accepted, not "an" actor on the account picked by query order.

type Uuid

ip?

Optional IP to stamp on the audit events.

type string | null

AcceptOfferResult
#

auth/role_grant_offer_queries.ts view source

AcceptOfferResult import type {AcceptOfferResult} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Result of query_accept_offer — the role_grant produced (new or pre-existing on race), plus the (now-accepted) offer.

role_grant

type RoleGrant

offer

type RoleGrantOffer

created

true if this call is the one that accepted the offer (new role_grant inserted); false on a race returning the already-created role_grant.

type boolean

superseded_offers

Sibling offers superseded by this accept — empty on the race-loser path. Each entry carries its grantor's from_account_id so the caller can fan out role_grant_offer_supersede notifications without a second round-trip.

type SupersededOffer[]

audit_events

Audit events emitted in-transaction — fed back through audit.notify by the caller, which fans out to the registered listeners. Includes one role_grant_offer_supersede per superseded sibling.

type AuditLogEvent[]

BuiltinOfferRefusal
#

auth/role_grant_offer_queries.ts view source

BuiltinOfferRefusal

type "bootstrap_only" | "scoped" | "grantor_not_authorized"

import type {BuiltinOfferRefusal} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Why query_accept_offer refused to mint a builtin-role grant.

Each is a row no current grant path creates but a database can still hold (a direct query_role_grant_offer_create call, or an offer made under a since-closed path), so accept — the chokepoint where an offer becomes a role_grant — checks them itself.

  • bootstrap_only — the offer is of keeper, which is bootstrap-only: never conferred through an offer, whoever made it.
  • scoped — the offer binds a builtin role to a scope; builtin roles are global-only.
  • grantor_not_authorized — the grantor no longer holds an active global admin on an active actor of an active account. Conferring a builtin role is global-admin authority, so it is re-checked when the grant is minted, not only when the offer was made.

DeclinedOffer
#

auth/role_grant_offer_queries.ts view source

DeclinedOffer import type {DeclinedOffer} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Result of query_role_grant_offer_decline — the declined offer plus the grantor's account_id.

inheritance

from_account_id

Grantor's account_id, resolved via a join on actor so the audit envelope's target_account_id (decline is *to* the grantor) and the post-commit notification target are both addressable without a second round-trip.

type Uuid

query_accept_offer
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, input: AcceptOfferInput): Promise<AcceptOfferResult> import {query_accept_offer} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Accept an offer atomically: mark accepted, insert the role_grant, stamp resulting_role_grant_id, supersede sibling pending offers for the same (to_account, role, scope), and emit role_grant_offer_accept + role_grant_create + one role_grant_offer_supersede per sibling. Must run inside a transaction — the caller's route spec should declare transaction: true (or wrap explicitly).

Idempotent on race: if a second concurrent call observes the offer already accepted, returns the existing role_grant rather than creating a duplicate or throwing.

Error map:

Sibling supersede is what closes the "accept a pre-revoke sibling offer to bypass a revoke" path: once A is accepted, B/C/... can no longer be accepted even if the resulting role_grant is later revoked.

deps

input

returns

Promise<AcceptOfferResult>

throws

  • RoleGrantOfferNotFoundError - if the offer is missing or belongs to another recipient
  • RoleGrantOfferAlreadyTerminalError - if the offer is declined, retracted, or superseded
  • RoleGrantOfferExpiredError - if the offer is pending but past `expires_at`
  • RoleGrantOfferActorMismatchError - if the offer is actor-targeted to a different actor
  • RoleGrantOfferBuiltinRefusedError - if a builtin-role offer must not mint (see `BuiltinOfferRefusal`)
  • Error - if the accepting `actor_id` does not belong to `to_account_id`, or invariant assertions fail

mutates

  • role_grant_offer row — stamps `accepted_at` and `resulting_role_grant_id`
  • role_grant table — inserts the resulting role_grant (idempotent on race)
  • role_grant_offer siblings — stamps `superseded_at` on every other pending offer for the tuple
  • audit_log table — emits `role_grant_offer_accept` + `role_grant_create` + one `role_grant_offer_supersede` per sibling

query_role_grant_offer_create
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, input: CreateRoleGrantOfferInput): Promise<RoleGrantOffer> import {query_role_grant_offer_create} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Create a new role_grant offer, or refresh an existing pending offer for the same (to_account_id, role, scope_id, from_actor_id) tuple.

Re-offer semantics: a second call by the same grantor with the same (to_account, role, scope) while pending upserts the existing row, refreshing message and expires_at (and to_actor_id — supplying a different to_actor_id on re-offer narrows the existing row to the named actor; supplying null widens it back to account-grain) and clearing expire_audited_at, so a re-offer of an expired, already-audited row gets its own role_grant_offer_expire when the refreshed expiry passes. A different grantor offering the same (to_account, role, scope) creates a distinct row — multiple pending grantors coexist. After a terminal state, a re-offer is a fresh INSERT.

Self-offer rejection: throws RoleGrantOfferSelfTargetError if the offering actor belongs to the recipient account.

Actor-targeted offers: when to_actor_id is supplied, query_accept_offer rejects any actor other than the named one. Closes the audit hole where offer-shape events would otherwise leave target_actor_id null even when the recipient binding is known at offer time. The actor↔account binding is verified here in one SELECT.

deps

input

returns

Promise<RoleGrantOffer>

throws

  • RoleGrantOfferSelfTargetError - if the offering actor belongs to `to_account_id`
  • RoleGrantOfferActorAccountMismatchError - if `to_actor_id` is set but does not belong to `to_account_id`

mutates

  • role_grant_offer table — inserts a new offer or upserts the matching pending row

query_role_grant_offer_decline
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, offer_id: string, to_account_id: string, reason: string | null): Promise<DeclinedOffer | null> import {query_role_grant_offer_decline} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Mark an offer declined.

Guarded by to_account_id (IDOR). Returns null if the offer does not exist or belongs to a different account. Throws RoleGrantOfferAlreadyTerminalError if the offer exists for the caller but is already in a terminal state.

Returns the declined offer with the grantor's from_account_id joined in via CTE — the decline audit envelope populates both target_actor_id (the grantor actor) and target_account_id (the grantor account), satisfying the "both populated → same account" invariant the audit-log column comments describe.

deps

offer_id

type string

to_account_id

type string

reason

type string | null

returns

Promise<DeclinedOffer | null>

throws

  • RoleGrantOfferAlreadyTerminalError - if the offer is already accepted, declined, retracted, or superseded

mutates

  • role_grant_offer row — sets `declined_at` and `decline_reason`

query_role_grant_offer_find_pending
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, offer_id: string): Promise<RoleGrantOffer | null> import {query_role_grant_offer_find_pending} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Look up a pending offer by id. Returns null if the offer is terminal, expired (server-side filter), or missing.

deps

offer_id

type string

returns

Promise<RoleGrantOffer | null>

query_role_grant_offer_history_for_account
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, account_id: string, limit?: number, offset?: number): Promise<RoleGrantOffer[]> import {query_role_grant_offer_history_for_account} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

List every offer involving an account (either direction), newest first.

Includes terminal offers — used by the grantor-side admin / history view.

deps

account_id

type string

limit

type number
default 100

offset

type number
default 0

returns

Promise<RoleGrantOffer[]>

query_role_grant_offer_list
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, to_account_id: string): Promise<RoleGrantOffer[]> import {query_role_grant_offer_list} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

List pending, non-expired offers for an account, soonest expiry first.

Expired offers are filtered server-side (expires_at > NOW()) so the inbox never surfaces a row that can no longer be accepted. The periodic sweep (query_role_grant_offer_sweep_expired) audits each expiry once.

deps

to_account_id

type string

returns

Promise<RoleGrantOffer[]>

query_role_grant_offer_retract
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps, offer_id: string, from_actor_id: string): Promise<RoleGrantOffer | null> import {query_role_grant_offer_retract} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Mark an offer retracted by the grantor.

Guarded by from_actor_id (IDOR). Returns null if the offer does not exist or was issued by a different actor. Throws RoleGrantOfferAlreadyTerminalError if the offer exists for this grantor but is already in a terminal state.

deps

offer_id

type string

from_actor_id

type string

returns

Promise<RoleGrantOffer | null>

throws

  • RoleGrantOfferAlreadyTerminalError - if the offer is already accepted, declined, retracted, or superseded

mutates

  • role_grant_offer row — sets `retracted_at`

query_role_grant_offer_sweep_expired
#

auth/role_grant_offer_queries.ts view source

(deps: QueryDeps): Promise<RoleGrantOffer[]> import {query_role_grant_offer_sweep_expired} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Claim the pending offers whose expires_at has passed and whose expiry has not been audited yet, stamping expire_audited_at on each, and return them soonest-expiry first.

The stamp is the sweep's idempotency: a claimed row never matches again, so each expiry is audited exactly once. It must share a transaction with the caller's role_grant_offer_expire audit inserts — committed together, or rolled back together so the next sweep retries. Concurrent sweeps claim disjoint rows: a row another sweep has stamped but not committed blocks on its row lock, then fails the re-checked expire_audited_at IS NULL. cleanup_expired_role_grant_offers is the caller.

deps

returns

Promise<RoleGrantOffer[]>

mutates

  • role_grant_offer rows — stamps `expire_audited_at` on each claimed row

RoleGrantOfferActorAccountMismatchError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferActorAccountMismatchError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown when query_role_grant_offer_create is called with a to_actor_id that does not exist or does not belong to to_account_id. Surfaces the actor↔account binding mismatch at the boundary instead of letting the FK silently disagree with the recipient field.

inheritance

extends: Error

constructor

type new (): RoleGrantOfferActorAccountMismatchError

RoleGrantOfferActorMismatchError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferActorMismatchError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown when an actor-targeted offer is being accepted by an actor other than offer.to_actor_id. Distinct from RoleGrantOfferNotFoundError (the IDOR mask): once an offer has been resolved to the recipient account, a wrong-actor accept on a same-account actor is a contract violation, not a privacy boundary — surface a specific error so the client UI can distinguish "this offer isn't for you" from "no such offer".

inheritance

extends: Error

constructor

type new (offer_id: string): RoleGrantOfferActorMismatchError

offer_id

type string

RoleGrantOfferAlreadyTerminalError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferAlreadyTerminalError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown by offer-lifecycle queries when the offer is in a non-pending state (accepted / declined / retracted / superseded) and therefore not actionable. Distinct from RoleGrantOfferExpiredError — expiry has its own user-facing story ("ask the grantor to re-send") so it travels separately.

inheritance

extends: Error

constructor

type new (offer_id: string): RoleGrantOfferAlreadyTerminalError

offer_id

type string

RoleGrantOfferBuiltinRefusedError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferBuiltinRefusedError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown by query_accept_offer when the offer confers a builtin role (admin / keeper) that this accept must not mint — refusal names the rule. Carries the locked (still pending, untouched) offer so the caller can audit the refusal with the offer's role and scope.

inheritance

extends: Error

offer

type RoleGrantOffer

readonly

refusal

type BuiltinOfferRefusal

readonly

constructor

type new (offer: RoleGrantOffer, refusal: BuiltinOfferRefusal): RoleGrantOfferBuiltinRefusedError

offer

refusal

RoleGrantOfferExpiredError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferExpiredError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown when an offer's expires_at has passed. The accept path enforces this independently of the sweep — a stale offer past its expiry must not be accepted, even in the race window between expiry and the sweep stamping the audit event.

inheritance

extends: Error

constructor

type new (offer_id: string): RoleGrantOfferExpiredError

offer_id

type string

RoleGrantOfferNotFoundError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferNotFoundError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown when an offer cannot be located for the caller. Covers both "offer does not exist" and "offer belongs to a different recipient" (IDOR guard) — the standard 404-over-403 pattern that avoids disclosing whether an offer id exists.

inheritance

extends: Error

constructor

type new (offer_id: string): RoleGrantOfferNotFoundError

offer_id

type string

RoleGrantOfferSelfTargetError
#

auth/role_grant_offer_queries.ts view source

import {RoleGrantOfferSelfTargetError} from '@fuzdev/fuz_app/auth/role_grant_offer_queries.js';

Error thrown when a grantor attempts to offer a role_grant to their own account.

Enforced via a single SELECT on the grantor's actor.account_id (rather than via a CHECK constraint or a denormalized column). Resolving from the grantor side keeps the check multi-actor-correct: under multi-actor the recipient account may host many actors, but the grantor → account binding remains 1:1 by definition of actor.

inheritance

extends: Error

constructor

type new (): RoleGrantOfferSelfTargetError

Depends on
#

Imported by
#