@accounter/server
Version:
Accounter GraphQL server
99 lines (98 loc) • 4.24 kB
TypeScript
import { DBProvider } from '../../app-providers/db.provider.js';
import { type EmailListenerConfig } from '../../financial-entities/helpers/business-suggestion-data-schema.helper.js';
import { IngestReasonCode } from '../contracts.js';
export type AliasResolutionResult = {
found: false;
reason: typeof IngestReasonCode.UNKNOWN_ALIAS;
} | {
found: true;
tenantId: string;
};
export type IssuedGrant = {
jti: string;
tenantId: string;
messageId: string;
rawMessageHash: string;
action: string;
expiresAt: Date;
decisionId: string;
auditId: string;
};
export type IssueGrantInput = {
tenantId: string;
messageId: string;
rawMessageHash: string;
expiresAt: Date;
correlationId?: string;
/** Recognized issuing business, bound into the grant for the ingest step. */
businessId?: string | null;
};
export type BusinessRecognitionResult = {
/** The recognized issuing business, or null when no business matched. */
businessId: string | null;
/** The business's email-processing config (empty when unrecognized). */
config: EmailListenerConfig;
};
export type ValidateGrantInput = {
jti: string;
tenantId: string;
messageId: string;
rawMessageHash: string;
};
export type ValidatedGrant = {
jti: string;
tenantId: string;
action: string;
expiresAt: Date;
/** Recognized issuing business bound at control time; null when unrecognized. */
businessId: string | null;
};
export type GrantValidationResult = {
valid: true;
grant: ValidatedGrant;
} | {
valid: false;
reason: typeof IngestReasonCode.GRANT_INVALID | typeof IngestReasonCode.TENANT_MISMATCH;
};
export declare class EmailIngestionControlProvider {
private dbProvider;
constructor(dbProvider: DBProvider);
/**
* Resolve a recipient alias to the owning tenant.
* Bypasses RLS via raw pool: alias lookup is a bootstrap step that runs
* before any tenant context is known, so TenantAwareDBClient would throw
* UNAUTHENTICATED. The alias_routing table has FOR SELECT USING (TRUE) to
* explicitly allow cross-tenant reads at the DB policy level.
*/
resolveAlias(alias: string): Promise<AliasResolutionResult>;
/**
* Issue a short-lived, single-use ingest grant for the given tenant and message.
* Returns the persisted grant together with decision/audit metadata.
* The INSERT runs under the tenant's RLS context (see {@link withTenantContext}):
* the grants table uses FORCE ROW LEVEL SECURITY with a tenant_isolation
* WITH CHECK policy, so the raw pool cannot bypass it — the owner_id parameter
* and the pinned business context must agree.
*/
issueGrant(input: IssueGrantInput): Promise<IssuedGrant>;
/**
* Recognize the issuing business behind an incoming email and load its
* email-processing config. Runs on a client pinned to the resolved tenant so
* the businesses RLS policy scopes the lookup to that tenant; returns a null
* businessId (and empty config) when no email evidence is available or no
* business matches, in which case the gateway applies default treatment.
*/
recognizeBusiness(tenantId: string, issuerEmail: string | null): Promise<BusinessRecognitionResult>;
/**
* Validate a presented grant against the stored record and atomically consume it.
* Checks: existence, expiry, consumed state, action scope, tenant binding, and message binding.
* The consume UPDATE (SET consumed_at = NOW() WHERE consumed_at IS NULL) is atomic —
* if a concurrent request consumed the grant first the UPDATE returns 0 rows and
* the method returns GRANT_INVALID, preventing double-use.
* Runs under the claimed tenant's RLS context: the grants table uses FORCE ROW
* LEVEL SECURITY, so the raw pool cannot read/update it without a pinned
* business context. Pinning to input.tenantId means a grant owned by another
* tenant is filtered out by the USING policy and surfaces as GRANT_INVALID;
* the explicit owner_id check below remains as defense-in-depth.
*/
validateAndConsumeGrant(input: ValidateGrantInput): Promise<GrantValidationResult>;
}