Query rules
Runtime checks that catch unbounded reads, missing tenants, sensitive columns and admin keys in the browser.
rules() checks every query before it's sent and reports what looks wrong.
It runs before every other plugin, pre ones included and whatever the use()
order, so it sees the query you wrote, before the tenant or soft-delete
plugins add their filters.
import { recommended, rules } from "better-supabase/plugins/rules";
export const betterSupabase = defineSupabase(schema).use(
rules({ rules: recommended() }),
);A warn violation goes to console.warn and the query runs. An error
violation fails the call with invalid_request, and nothing is sent.
Presets
| Preset | Contents |
|---|---|
safe() | Data exposure and correctness as errors: noAdminInBrowser, noDeleteManyWithoutWhere, noSensitiveSelect, requireTenantContext |
recommended() | safe() plus the performance rules and storagePathColumns as warnings (the default) |
strict() | Everything as an error, plus requireMaxAffected |
Spread a preset to change single rules:
rules({
rules: {
...recommended(),
maxLimit: ["error", 200],
noUnboundedFindMany: "off",
},
});Rules
| Rule | Flags | Option |
|---|---|---|
noUnboundedFindMany | findMany without limit | |
maxLimit | limit above the maximum | maximum, default 1000 |
requireOrderByForCursor | offset, or limit above 1, without orderBy: pages overlap or skip rows. findMany orders by the primary key by default, so only views and keyless tables trigger it | |
maxIncludeDepth | Includes nested deeper than the maximum | maximum depth, default 3 |
requireTenantContext | A tenant table queried without context.tenant or the tenant claim. Service connections are exempt | claim, default claims.tenant ('tenant_id') |
noAdminInBrowser | A service-role connection where window and document exist | |
noSensitiveSelect | Reading a column listed in sensitive without sensitive: true on the call | |
noDeleteManyWithoutWhere | A delete without where | |
requireMaxAffected | updateMany or deleteMany without maxAffected, or with one above the maximum. Writes whose where pins the whole primary key are exempt | maximum, default 1000 |
storagePathColumns | A write to a *_url text column that is listed in storagePaths, or whose value is a Storage URL or matches a bucket's path template. Store the path in *_path instead |
noUnboundedFindMany skips aggregate(), and maxLimit doesn't count the
extra row paginate() reads to know whether there is a next page.
requireTenantContext reads the same claim paths as the tenant() plugin,
so a tenant in app_metadata counts. requireMaxAffected is only in
strict(); add it to another preset with requireMaxAffected: ["error", 500].
noSensitiveSelect looks at the columns actually selected by reads,
includes too. A select that leaves the column out passes; to read it on
purpose:
await db.contacts.findMany({
select: ["id", "email"],
limit: 50,
sensitive: true,
});Writes don't trip the rule: without a select, create, update and
upsert return every column except the sensitive ones. Name the column in
select, or pass sensitive: true, to get it back.
The repository already refuses deleteMany without where on its own, so
noDeleteManyWithoutWhere only catches deletes built by other plugins. The
lint rule of the same name catches the call in your
editor.
storagePathColumns only compares against bucket templates with a literal
part besides / ({organizationId}/{customerId}/logo/{version}.webp, not
{userId}/{file}), so a website_url holding example.com/about isn't
mistaken for an object.
Reporting
rules({
rules: recommended(),
report: (violation) => logger.warn(violation),
});A violation has rule, level, table, operation and message.
Errors still fail the call after report runs.
Skipping plugins
db.$withoutPlugins() returns the same connection with no plugins at all:
no rules, tenant scoping, soft-delete filters or executor wrappers. Use it for
migrations and admin scripts, never for user requests. Pass
{ keep: ['otel'] } to keep tracing (or any plugin by name); a name that
isn't installed throws, so a typo can't drop a plugin silently.
Last updated on