# Query rules

> Runtime checks that catch unbounded reads, missing tenants, sensitive columns and admin keys in the browser.

Source: https://bettersupabase.com/docs/plugins/rules

`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.

```ts
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 [#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:

```ts
rules({
  rules: {
    ...recommended(),
    maxLimit: ["error", 200],
    noUnboundedFindMany: "off",
  },
});
```

## Rules [#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`](/docs/cli/config) without `sensitive: true` on the call                                                                                                                  |                                                |
| `noDeleteManyWithoutWhere` | A delete without `where`                                                                                                                                                                                          |                                                |
| `requireMaxAffected`       | `updateMany` or `deleteMany` without [`maxAffected`](/docs/repository/writing#limiting-bulk-writes), 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`](/docs/platform/storage#path-columns), 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:

```ts
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](/docs/plugins/lint) 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 [#reporting]

```ts
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 [#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.