import type { OwnershipModel, SchemaSnapshot, TableInfo, TableOwnership } from "../types.js"; /** * Infer who is supposed to see what, from the schema alone. * * Two design decisions here do most of the work of keeping false positives near * zero, and they are structural rather than heuristic: * * 1. THE TWO PERSONAS ARE ALWAYS IN DISJOINT ORGS. We only ever assert the * invariant "two users with no relationship to each other cannot read each * other's rows". That is universally true of every multi-user app, so it * needs no knowledge of intent. Deciding whether two *teammates* should see * each other's data does require intent, and we deliberately do not test it. * * 2. THE SEEDER CONTROLS VISIBILITY FLAGS. A `posts` table with a `published` * column is legitimately world-readable for some rows, which would make a * cross-user read ambiguous. Rather than guess, we plant the canary row with * the *private* value of that column (see seed/values.ts). A row we * explicitly marked private leaking to another user is unambiguous. * * What remains genuinely uncertain is surfaced as low confidence and shown in * the confirmation step, rather than being silently guessed. */ /** * Column names are compared in one spelling, so a convention is recognised * however it was written. * * `userId` and `user_id` are the same column to everyone except a string * comparison. Prisma and Drizzle both default to camelCase field names, so a * schema generated from either produced `userId`, `organizationId`, `isPublic` * — and every list below is written in snake_case, so none of them matched. * * Ownership survived that, because it comes from foreign keys rather than from * names. Visibility did not: an unrecognised `isPublic` is not seeded to its * most private value, and a row that leaks can then be explained away as one * the developer published on purpose. That is design invariant 3 quietly * ceasing to hold on a whole family of schemas — a latent false positive, which * is the one failure this tool cannot ship. */ export declare function normalizeColumnName(name: string): string; /** Is this column one of the conventional names, in any spelling? */ export declare function matchesColumn(name: string, list: readonly string[]): boolean; /** Position in a preference-ordered list, or -1. Spelling-insensitive. */ export declare function columnRank(name: string, list: readonly string[]): number; /** * Conventional names for a column that ties a row to its owning user. Shared * with introspection, which uses "several tables point owner-shaped columns at * it" as the structural signal for finding the user table itself. * * Written in snake_case and compared through {@link normalizeColumnName}, so * `userId`, `user_id` and `UserId` are all the same entry. */ export declare const OWNER_COLUMNS: string[]; /** Columns whose value decides whether a row is world-readable. */ export declare const VISIBILITY_COLUMNS: string[]; export interface InferOptions { /** Developer corrections, keyed by table id. Always win over inference. */ overrides?: Record>; } export declare function inferOwnership(snapshot: SchemaSnapshot, opts?: InferOptions): OwnershipModel; /** * Tables whose primary key *is* a user id, and which therefore stand in for the * user table wherever one is named. * * Supabase's own recommended pattern puts a `profiles` table between the * application and `auth.users` — you cannot add columns to `auth.users`, so * essentially every serious Supabase app has one, and its roster then reads * `memberships.user_id → profiles.id` rather than pointing at the user table * directly. Requiring the roster's user foreign key to reference the user table * itself made the roster structurally invisible on that architecture, and the * whole org model collapsed behind it: on the real 41-table schema this was * measured against, no roster meant no org table, which left the tables an org * owns — `orgs` and `subscriptions` among them — classified as "shared * reference data, everyone may read", and left the rest inheriting ownership * from whatever nullable audit column happened to point at a person * (`redacted_by`, or `accepted_by`, which is NULL for every open invitation). * Zero findings, for the wrong reason. * * The rule is narrow on purpose. A table qualifies only when its entire * single-column primary key is a foreign key to a table that already qualifies * — the user table to begin with. Two facts follow from the catalog alone, with * no knowledge of intent: * * - at most one such row exists per user and it can never be NULL, so the row * extends exactly one user rather than being a thing that a user has; and * - the identifier *is* the user's identifier, value for value. That is what * makes substitution safe rather than merely tempting: the seeder writing a * persona's id into the roster, and a generated policy comparing that * column to `auth.uid()`, both stay correct without knowing an alias was * involved at all. * * The second fact is why this deliberately does not extend to a profile table * keyed by its own id with `UNIQUE (user_id)` beside it. Such a table is just as * one-to-one, but its keys are *different values* from the user's, so calling it * the user would have the fix emit `profile_id = auth.uid()` — a policy that * denies everybody, shipped with a verified tick. Where the schema cannot prove * the identifier is the same identifier, we infer no roster and say nothing, * which is the direction that fails visibly. * * Chains are followed because they preserve that property transitively: if * `employees.id → profiles.id → auth.users.id`, an employee id is a user id. * The rule is applied to fixpoint rather than in one pass, so which order the * catalogue happens to list the tables in does not decide the answer. */ export declare function findUserAliases(tables: TableInfo[], userTableId: string): Set; export declare function findVisibilityColumn(table: TableInfo): string | null;