import type pg from "pg"; import type { OwnershipModel, Persona, PersonaName, SchemaSnapshot, SeedResult, SeededRow, TableInfo } from "../types.js"; /** * Plant known data for two unrelated users. * * This is the keystone of the whole design. Because we create the rows, we know * exactly which primary keys and which canary strings belong to whom — so * "did Alice just read Bob's data?" is an exact identifier match rather than a * judgement call about an unfamiliar response body. That is what keeps the * false-positive rate near zero, and it is why we seed rather than probe * whatever data happens to be lying around. * * Alice and Bob are deliberately placed in *separate* orgs. The only invariant * we assert is that two users with no relationship cannot reach each other's * rows, which is true of every multi-user application without us having to * understand its sharing rules. * * Three ways of planting a row, cheapest and least invasive first. * **Synthesis** builds one out of nothing and is the only thing that works on * an empty CI database. Where the schema enforces a rule we cannot satisfy by * guessing — a format check, a trigger, a business invariant — **cloning** * copies a row the database already accepted and re-points it at our persona, * which is what makes a developer's own stack or a staging branch checkable. * See `./clone.ts` for what that refuses to do. Where the table is empty, as * every table in a CI database built from migrations is, **suspension** lifts * the offending CHECK constraint or trigger for exactly as long as it takes to * insert the row and puts it back before anything is probed. See * `./suspend.ts`, and in particular what it can never touch. */ export interface SeedOptions { /** * `read_only` plants only rows we invented, and only where the schema as * written accepts them. Cloning copies real data and suspension changes the * schema, however briefly, so under that flag neither is attempted at all and * the table is named as skipped instead. */ mode?: "read_only" | "full"; /** * Personas whose ids were decided somewhere else, and have to be used here. * * Set only where an identity provider assigns the user id — Clerk, Auth0, * Cognito, Firebase Auth. Those users are created before this runs precisely * so that the rows planted below belong to the account the run will be signed * in as; generating fresh ids here would leave the session owning nothing, * every probe empty, and empty is what a correctly secured application * returns. See `src/api/provider.ts`. */ personas?: Record; } export declare function seed(client: pg.PoolClient, snapshot: SchemaSnapshot, model: OwnershipModel, options?: SeedOptions): Promise; export interface InsertContext { persona: Persona; ownerColumn: string | null; orgColumn: string | null; userTableId: string | null; orgTableId: string | null; planted: Map; } export type InsertPlan = { ok: true; cols: string[]; vals: unknown[]; canary: string; canaryColumn: string | null; } | { ok: false; reason: string; }; /** * Work out a row this persona could legitimately own. * * Shared between seeding and the insert-forgery probe: the probe needs a row * that is valid in every respect *except* that an attacker is claiming it on * someone else's behalf, so that a rejection can only be attributed to * authorization rather than to a constraint violation. */ export declare function buildInsertPlan(table: TableInfo, ctx: InsertContext): InsertPlan; /** * Order tables so that a table's foreign key targets are seeded before it. * Cycles are broken arbitrarily; the nullable-FK path in insertRow copes, and a * genuinely required cycle is reported as a skipped table. */ export declare function topoSort(tables: TableInfo[], snapshot: SchemaSnapshot): TableInfo[];