import { SmrtClassOptions } from '@happyvertical/smrt-core'; import { AccessRequestCollection } from '../collections/AccessRequestCollection.js'; import { AccessRequest } from '../models/AccessRequest.js'; import { Membership } from '../models/Membership.js'; import { Tenant } from '../models/Tenant.js'; import { User } from '../models/User.js'; import { AccessRequestStatus, TenantStatus, UserStatus } from '../types/index.js'; /** * Capabilities that gate the operator-facing methods of * {@link AccessRequestService}. */ export declare const ACCESS_REQUEST_CAPABILITIES: { /** Read the access-request queue (`list` / `get`). */ readonly READ: "access-requests:read"; /** Decide requests (`approve` / `decline` / `cancel` / `graduate`). */ readonly MANAGE: "access-requests:manage"; }; /** * One of the capability slugs in {@link ACCESS_REQUEST_CAPABILITIES}. */ export type AccessRequestCapability = (typeof ACCESS_REQUEST_CAPABILITIES)[keyof typeof ACCESS_REQUEST_CAPABILITIES]; /** * Context passed to an {@link AccessRequestAuthorizer} before an operator method * runs. */ export interface AccessRequestAuthorizationContext { /** The capability the operation requires. */ capability: AccessRequestCapability; /** The operator user id supplied to the method (`by`), if any. */ by?: string | null; /** The target access-request id, when the operation targets a specific row. */ accessRequestId?: string; } /** * Hook that authorizes an operator action. Throw (or reject) to deny — the * thrown error propagates to the caller unchanged. Resolve/return to allow. * * Wire this to your permission system, e.g. resolve the operator's permissions * and assert the required capability: * * ```typescript * const service = await AccessRequestService.create({ * db, * authorize: async ({ capability, by }) => { * if (!by || !(await isPlatformOperator(by, capability))) { * throw new Error(`Missing capability: ${capability}`); * } * }, * }); * ``` */ export type AccessRequestAuthorizer = (context: AccessRequestAuthorizationContext) => void | Promise; /** * Lifecycle event types emitted by {@link AccessRequestService}. */ export type AccessRequestEventType = 'access-request.created' | 'access-request.approved' | 'access-request.declined' | 'access-request.canceled' | 'access-request.graduated'; /** * Payload delivered to an {@link AccessRequestEventHandler}. */ export interface AccessRequestEvent { /** Which lifecycle transition fired. */ type: AccessRequestEventType; /** The access request after the transition. */ accessRequest: AccessRequest; /** When the event was emitted. */ at: Date; /** Operator user id responsible, for operator-driven transitions. */ by?: string | null; /** Graduated user (only on `access-request.graduated`). */ user?: User; /** Membership created/linked on graduation, when a tenant was attached. */ membership?: Membership; /** Tenant created/linked on graduation, when a tenant was attached. */ tenant?: Tenant; } /** * Event hook apps provide to react to access-request lifecycle changes. * Delivery is best-effort: a throwing handler is logged and swallowed so it * never rolls back an already-persisted transition. Do not rely on it for * critical-path work that must share the request's transaction. */ export type AccessRequestEventHandler = (event: AccessRequestEvent) => void | Promise; /** * Options for {@link AccessRequestService}. */ export interface AccessRequestServiceOptions extends SmrtClassOptions { /** Optional capability gate for operator methods (see {@link AccessRequestAuthorizer}). */ authorize?: AccessRequestAuthorizer; /** Optional lifecycle event hook (see {@link AccessRequestEventHandler}). */ onEvent?: AccessRequestEventHandler; } /** * Input for {@link AccessRequestService.createAccessRequest}. Only `email` is * required. */ export interface CreateAccessRequestInput { /** Requester email (validated + normalized to lowercase). */ email: string; /** Requester display name. */ name?: string | null; /** Where the request came from, e.g. `www`, `sdk`. */ source?: string; /** Free-form metadata (intended use, company, message, referrer, …). */ context?: Record; /** Optional requested org/tenant hint (advisory). */ tenantHint?: Record | null; /** Optional initial note. */ note?: string | null; } /** * Filter for {@link AccessRequestService.listAccessRequests}. */ export interface ListAccessRequestsFilter { /** Restrict to one status or any of several. */ status?: AccessRequestStatus | AccessRequestStatus[]; /** Restrict to a single (normalized) email. */ email?: string; /** Restrict to a single source. */ source?: string; /** Operator user id, forwarded to the authorizer as `by`. */ by?: string | null; /** Max rows to return. */ limit?: number; /** Rows to skip. */ offset?: number; /** Order-by clause (defaults to `created_at DESC`). */ orderBy?: string; } /** * Options shared by the operator decision methods. */ export interface DecideAccessRequestOptions { /** Operator user id recorded as `decidedBy` and forwarded to the authorizer. */ by?: string | null; } /** * Options for {@link AccessRequestService.approveAccessRequest}. */ export interface ApproveAccessRequestOptions extends DecideAccessRequestOptions { /** Operator note stored on the request. */ note?: string | null; } /** * Options for {@link AccessRequestService.declineAccessRequest}. */ export interface DeclineAccessRequestOptions extends DecideAccessRequestOptions { /** Decision reason stored as the request's note. */ reason?: string | null; } /** * Options for {@link AccessRequestService.cancelAccessRequest}. */ export interface CancelAccessRequestOptions extends DecideAccessRequestOptions { /** Cancellation reason stored as the request's note. */ reason?: string | null; } /** * Graduate into a **new** tenant, enrolling the requester (owner by default). */ export interface GraduateNewTenantOption { /** New tenant attributes — `name` is required. */ create: { name: string; slug?: string; description?: string; status?: TenantStatus; }; /** Role slug for the requester's membership (default `owner`). */ role?: string; } /** * Graduate into an **existing** tenant, enrolling the requester. */ export interface GraduateExistingTenantOption { /** Target tenant id. */ tenantId: string; /** Role slug for the requester's membership (default `member`). */ role?: string; } /** * Tenant handling at graduation: create a new tenant, attach to an existing * one, or `'none'` (user only, no membership). */ export type GraduateTenantOption = GraduateNewTenantOption | GraduateExistingTenantOption | 'none'; /** * Options for {@link AccessRequestService.graduateAccessRequest}. */ export interface GraduateAccessRequestOptions extends DecideAccessRequestOptions { /** Tenant handling (default `'none'`). */ tenant?: GraduateTenantOption; /** Status applied to the user produced/linked by graduation (default `ACTIVE`). */ activate?: UserStatus; /** * Convenience: allow graduating directly from `REQUESTED` (skipping the * `APPROVED` step). Defaults to `false`. */ allowFromRequested?: boolean; /** Operator note stored on the request. */ note?: string | null; } /** * Result of {@link AccessRequestService.graduateAccessRequest}. */ export interface GraduateAccessRequestResult { /** The graduated (created or linked) user. */ user: User; /** The membership, when a tenant was attached. */ membership?: Membership; /** The tenant, when one was created or attached. */ tenant?: Tenant; /** The access request, now `GRADUATED`. */ accessRequest: AccessRequest; /** Whether a brand-new user was created (`false` when an existing one was linked). */ created: boolean; } /** * Error codes raised by {@link AccessRequestError}. */ export type AccessRequestErrorCode = 'INVALID_EMAIL' | 'NOT_FOUND' | 'INVALID_TRANSITION' | 'TENANT_NOT_FOUND' | 'ROLE_NOT_FOUND'; /** * Error thrown for access-request domain failures the caller is expected to * surface (invalid email, unknown id, illegal state transition, …). Authorizer * denials are not wrapped — those propagate from the supplied authorizer * unchanged. */ export declare class AccessRequestError extends Error { readonly code: AccessRequestErrorCode; constructor(message: string, code: AccessRequestErrorCode); } /** * High-level orchestration for the access-request lifecycle and graduation. */ export declare class AccessRequestService { #private; constructor(options: AccessRequestServiceOptions); /** * Initialize the backing collections (creates/verifies their tables). */ initialize(): Promise; /** * Static factory — construct and initialize in one call. */ static create(options: AccessRequestServiceOptions): Promise; /** * The underlying collection, for advanced read scenarios. Prefer the service * methods, which apply normalization, the state machine, capability gating, * and events. */ get collection(): AccessRequestCollection; /** * Create an access request. **Public-safe**: no capability check — meant to be * callable unauthenticated by apps (which add their own rate-limiting). * * Validates and normalizes the email, then de-duplicates: if an open * (`REQUESTED`) request already exists for the email, this merges any newly * supplied context/name/source/hint into it and returns it instead of * creating a duplicate (no second `created` event). * * @remarks * De-duplication is **best-effort, not atomic**: it is a read-then-write * (`findOpenByEmail` → `create`) with no DB-level partial-unique constraint * (the table is append-style, keyed on `id`, because the same email may * accumulate many requests over its lifetime). Two requests for the same * email racing concurrently can therefore both create an open row. This is by * design — the spec makes dedup configurable and pushes abuse control to the * app (rate-limiting on the public endpoint). Operators triaging two open rows * for one email is benign; apps needing a hard single-open-request guarantee * should add a partial unique index (`UNIQUE(email) WHERE status='requested'`) * in their migration. * * @throws {@link AccessRequestError} (`INVALID_EMAIL`) when the email is invalid. */ createAccessRequest(input: CreateAccessRequestInput): Promise; /** * List access requests (operator-facing). Requires the `access-requests:read` * capability when an authorizer is configured. */ listAccessRequests(filter?: ListAccessRequestsFilter): Promise; /** * Get a single access request by id (operator-facing). Requires the * `access-requests:read` capability when an authorizer is configured. */ getAccessRequest(id: string, options?: { by?: string | null; }): Promise; /** * Approve a request: `REQUESTED → APPROVED`. Idempotent (re-approving an * already-`APPROVED` request is a no-op returning it). Requires * `access-requests:manage`. * * @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state. */ approveAccessRequest(id: string, options?: ApproveAccessRequestOptions): Promise; /** * Decline a request: `REQUESTED | APPROVED → DECLINED`. Idempotent. Requires * `access-requests:manage`. * * @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state. */ declineAccessRequest(id: string, options?: DeclineAccessRequestOptions): Promise; /** * Cancel a request: `REQUESTED | APPROVED → CANCELED`. Idempotent. Requires * `access-requests:manage`. * * @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state. */ cancelAccessRequest(id: string, options?: CancelAccessRequestOptions): Promise; /** * Graduate an approved request into a `User`, optionally attaching a tenant. * * Valid from `APPROVED` (or from `REQUESTED` when * {@link GraduateAccessRequestOptions.allowFromRequested} is set). Creates a * user when none exists for the email, or **links** the existing one * otherwise (reusing {@link UserCollection}). Idempotent: a second call on an * already-`GRADUATED` request returns the same user (and an existing * membership for the requested tenant, if any) without re-creating anything. * * Requires `access-requests:manage`. * * @throws {@link AccessRequestError} — `NOT_FOUND` (unknown id), * `INVALID_TRANSITION` (terminal/declined/canceled or `REQUESTED` without * `allowFromRequested`), `TENANT_NOT_FOUND`, or `ROLE_NOT_FOUND`. */ graduateAccessRequest(id: string, options?: GraduateAccessRequestOptions): Promise; } //# sourceMappingURL=AccessRequestService.d.ts.map