PermDock
CLI

doctor

Diagnose a PermDock installation: server-only imports in client entries, unknown references, ungranted permissions, stale catalogs, roles or tenants read from unverified claims, missing skills and the TypeScript version.

permdock doctor runs every check PermDock knows how to make about a repository and prints a report with a fix for each finding. It is the first command an agent runs after wiring PermDock and the last one before opening a pull request. Findings have stable codes so skills and CI can reference them.

Usage

permdock doctor                # all checks
permdock doctor --json         # report with $schema, for agents
permdock doctor --only imports,references
permdock doctor --fix          # apply safe fixes (install skills, regenerate the catalog)

Exit code 1 on any error, 0 otherwise; warnings only fail with --strict.

Without --fix, doctor only reads: it never writes the catalog or any other file. On a terminal, --fix asks before it writes; answering no prints the report without fixing, and cancelling exits 2. In CI, in a pipe or with --json it fixes without asking.

Where doctor reads source files

The checks over source files (PD001, PD002, PD007 to PD015, PD044 and the others that read code) scan doctor.srcPath, an array of directories or globs relative to the project root. It defaults to collect.srcPath, then ./src. Set it when the catalog should come from one folder and the checks should cover more, such as client components outside the folder that defines the permissions:

permdock.config.ts
export default defineConfig({
  permissions: "./src/permdock/permissions.ts",
  collect: { srcPath: ["./src/permdock"] },
  doctor: { srcPath: ["./src", "./app", "./components"] },
});

PD004 compares the catalog on disk with the one collect would write from collect.srcPath, so a wider doctor.srcPath never reports catalog drift. PD002 and PD003 count the references under doctor.srcPath.

Checks

CodeCheckSeverity
PD001Server-only imports in client entrieserror
PD002Unknown permission referenceserror
PD003Permissions used but never grantedwarning
PD004Stale catalogerror
PD005Missing or outdated Agent Skillswarning
PD006Unsupported TypeScript versionerror
PD007Policy validation mode never with an HTTP, MCP or agent adapter presentwarning
PD008Reserved identifier used for a public exportwarning
PD009Duplicate permdock copies in the dependency treeerror
PD010Roles or tenant read from an unverified or user-editable claimerror
PD011Tenant read from an optional claim under a multi-tenant issuerwarning
PD012OpenAPI document or Overlay pinned to a draft revision the installed CLI does not emitwarning
PD013Polymorphic EdDSA in a JWT algorithms list instead of Ed25519warning
PD014discovery issuer mismatch, plain-HTTP issuer, or jwks without issuererror
PD015Token verification that does not check typ, or accept: 'id-token' on an API routewarning
PD016Opaque RLS grants, or sqlFunction grants with no verify fixtures, under an rls configwarning
PD017An allow on a sensitive verb (approve, pay, settle, submit, transfer, refund, disburse, or doctor.sensitiveActions) has no approval and no deny on the same leafwarning
PD018exclusiveWith names an undeclared role (error), or a doctor.memberships fixture / custom role holds two exclusive roles (warning)error / warning
PD019rls.rbac.authorize is jwt, [auth] jwt_expiry in supabase/config.toml exceeds 3600 seconds, and the policy grants a sensitive verbwarning
PD020A permission the policy marks hostable sits on a resource the rls config compileswarning
PD021The policy lists hostable permissions but PERMDOCK_CLOUD_URL or PERMDOCK_CLOUD_KEY is unsetwarning
PD022A SQL migration creates a view without security_invoker, so it reads past row level securitywarning
PD023A custom role in the doctor.memberships fixture names a permission or include its ceiling dropswarning
PD024An allow sets approval: { distinct: false }, so the requester can approve their own requestwarning
PD025A membership in the doctor.memberships fixture grants nothing under the policy's named scopeswarning
PD026No role on a scope sets min, so every instance of it can lose its last managerwarning
PD027Under an rls config, a grant's where or check reads context.*, which RLS cannot evaluatewarning
PD028supabase.hook.attrs lists user_metadata or another value the hook cannot use (error), or an attrs column or a membership source's deciding column that your migrations let anon or authenticated insert or update (warning)error, warning
PD029An API key in the doctor.credentials fixture never expires or is not a valid v1 credential, or a tenant's settings allow keys without expirywarning
PD030Under an rls config, a read grant limits fields, but the table still returns those columns to a direct readwarning
PD031A resource parents itself but no through: 'parent' grant walks it, or declares restricted but no graph grant reaches its rowswarning
PD032Under an rls config, a graph resource's snake*case name matches a scope or another resource, or is not a SQL name, so rls generate cannot name its permitted*<resource>\_ids helpererror
PD033A role activation has no maxDuration (the elevation never expires on its own), or an activation role is held standing by a doctor.memberships fixturewarning
PD034Under an rls config, a breakGlass grant would be compiled: RLS never compiles a break-glass override, so it reads through the generated permdock_break_glass_<resource> function insteadwarning
PD035A supportAccess role has actorRequired: false, so a support session runs as the tenant, unattributedwarning
PD036A protect(permission) with no row loader on a route whose path names an id (/posts/:id, /posts/{id}, a [id] folder), so the check never sees the row (BOLA)warning
PD037A migration's policy on storage.objects or realtime.messages calls permdock_has or permitted_<scope>_ids with a permission whose grants carry row conditions, which the helpers do not check; the permission-key forms are not flaggederror
PD038Under an rls or supabase.hook config, a subjectFromSupabase or subjectFromSupabaseSession call reads the tenant from a different claim than rls.tenantClaim, the claim the hook writes and the helpers compare withwarning
PD039Under a supabase.hook config, the helper schema has no generated permdock_has, permitted_<scope>_ids or member_<scope>_ids, a supabase.hook.claims entry in the doctor.claims fixture is larger than the memberships budget, or a sample has a memberships entry subjectFromSupabase dropswarning
PD040A SQL migration calls auth.role(), which Supabase deprecatedwarning
PD041An exchangeCapability call signs with alg: 'HS256', the project's shared JWT secretwarning
PD042The token hook is declared under supabase/schemas, and no migration from the one that creates it on grants it to supabase_auth_admin (not under pg-delta)error
PD043schema_paths in supabase/config.toml applies a file that calls the SQL helpers before the file that defines them, or leaves that file out (not under pg-delta)warning
PD044usePermission reads a permission whose grant the snapshot cannot answer, and there is no app/**/api/permdock/route.ts using permdockHandler and no endpointwarning
PD045supabase/config.toml is not valid TOMLwarning
PD046The PermDock helper schema (rls.schema) is listed in [api] schemas, so the Data API serves the security definer helpers as RPCswarning
PD047A SQL migration reads raw_user_meta_data or user_metadata, which a user can set for themselveswarning
PD048A SQL function sets no search_pathwarning
PD049A security definer function whose execute is not revoked from public (and, in the public schema, from anon)warning
PD050A migration creates a table in public without enabling row level securityerror
PD051A policy calls auth.uid() or auth.jwt() bare instead of (select auth.uid()), so Postgres calls it once per rowwarning
PD052An update policy has using and no with checkwarning
PD053A foreign key column no index starts withwarning
PD054The last role_permissions seeds in the migrations differ from the rows the policy compiles towarning
PD055A custom role in the doctor.memberships fixture stores a permission under a key it was renamed fromwarning
PD056A migration still calls an rls.migrate helper by its legacy namewarning
PD057rls.anonymousSignIns is 'deny', but a subjectFromSupabase or subjectFromSupabaseSession call does not pass anonymousSignIns: 'deny'warning
PD058sync-config.yaml or the powersync.manifest file is missing or differs from what the policy compiles to, or the config does not compilewarning
PD059The configured policy module throws or exports no policy, so every check that reads the policy is skippederror
PD060A source file under collect.srcPath has a syntax error, so the catalog and the usage report miss what it referenceswarning
PD061rls.suspension.scopes.<scope>.keep or rls.suspension.memberships.keep lets suspended members keep permissions: the kept keys, and any key the definitions do not declarewarning
PD062A migration reads or sets a legacy request.jwt.claim.<name> settingwarning
PD063A migration changes or drops a reserved Supabase role, or grants a reserved membership, which supautils rejectserror
PD064rls.assignments guards a table (a membership table, the global-roles table or a listed table) that no permdock_assignment trigger is created on in the migrations or the rls generate outputwarning
PD065A file that imports permdock/react-native reads a permission whose grant the snapshot cannot answer, so the device denies it offlinewarning
PD066An rls.migrate.tables table that the migrations still create, with what still reads it or a note that rls migrate --retire-out can drop itwarning

PD001: server-only imports in client entries

Policy modules, createPermDock from permdock/next and any file that imports from permdock/hono, permdock/mcp or a provider must never be reachable from a client bundle. doctor reads every file under doctor.srcPath (default collect.srcPath), treats a file as a client entry when it carries 'use client', has .client. in its name, or matches doctor.clientEntries, and reports each client entry that imports a server-only entry directly. Frameworks with no client marker (a Vite SPA, an Expo app, a Chrome extension) list their client code in doctor.clientEntries, an array of directories or globs relative to the project root:

permdock.config.ts
export default defineConfig({
  permissions: "./src/permissions.ts",
  doctor: { clientEntries: ["src/**/*.tsx", "app"] },
});

doctor also reports a client entry that imports the module policy names in permdock.config.ts. It resolves each value import, re-export and literal dynamic import() of the client entry from that file, as the bundler would: relative paths with or without an extension, the paths of the nearest tsconfig.json, and package names through node_modules and the package's exports. A monorepo that exposes the policy as @acme/access/permdock/policy from a workspace package is therefore caught in the app that imports it. A type-only import is not reported, because the bundler erases it.

This is the permix issue #49 class of bug (server code leaking into client bundles), caught before the bundler does something surprising. The bundle check in tests/bundle covers what a transitive import would reach.

PD002: unknown permission references

Member accesses rooted at a definition export that do not resolve to a leaf (permissions.post.archive when archive is not an action), and findPermission(permissions, 'literal') calls with a literal that no key matches. These are type errors in TypeScript projects; doctor catches them in JavaScript files, MDX and generated code too.

A member access counts only when its root identifier is bound to a permission tree where it is read: an import of permissions or of any definePermissions export, a default import named permissions, or a definePermissions(...) declaration. A parameter, local variable, catch binding or module variable with the same name shadows it, so function grant(permissions: string[]) { return permissions.length } is not a reference. An identifier no scope declares still matches by name, as in generated code. The same rule decides the references collect and usage record.

PD003: used but ungranted

The same analysis as usage, reported here at warning level so a single doctor run covers it.

PD004: stale catalog

Runs collect --check in memory. A stale permissions.catalog.json is an error because downstream tools (OpenAPI emission, MCP descriptions, the docs) read the file, not the code.

PD005: skills

Checks that the PermDock skills are installed (it looks for the permdock skill) for the agents configured in the repository (.agents/skills, .claude/skills, .cursor/skills) and that their version matches the installed permdock. It looks in the working directory and every directory above it up to the workspace root (the first with pnpm-workspace.yaml, a package.json that declares workspaces, or .git), so skills installed once at a monorepo's root count for a package that runs doctor. The lock is .permdock/skills-lock.json, or a skills-lock.json from the skills CLI that lists the permdock skill. --fix runs permdock skills install. See skills.

PD006: TypeScript version

Reads the resolved typescript version and compares it with the supported matrix (5.9, 6, 7). Warns on 7.0 when a tool that needs the compiler API is detected, because TS 7.0 does not ship a stable one. Also verifies that isolatedDeclarations is not required of the consumer: PermDock enables it for itself, apps do not have to.

PD007: validation mode

validate: 'never' is a documented footgun when untrusted input reaches can(). If the policy sets it and the repository imports an HTTP, MCP, AI SDK or Claude Agent adapter, doctor says so and links to Validation.

PD008: reserved identifiers

Public exports named dock, ability, a can that defines rather than checks, or $-prefixed members in modules that import permdock. Enforces the naming convention in userland code where a skill would otherwise learn the wrong names.

PD009: duplicate copies

Two versions of permdock in node_modules are not a correctness problem for references (identity is by key, permissions) but are for types and bundle size; reported with the two paths.

PD010: roles or tenant from an unverified claim

Reads the claims option of every subjectFromJwt and createJwtSubjectResolver call and the field accesses in definePolicy's subject function, and reports a roles or tenant source that is one of: email or anything derived from it (a .split('@') on email is the common form), name, preferred_username, picture, or a bucket the provider documents as user-editable (user_metadata, Clerk unsafeMetadata, Stytch untrusted_metadata, Stack Auth clientMetadata, Ory traits, Cognito custom:* attributes without a write restriction). These are the escalation paths in the threat model: the token is genuine, the claim is not authoritative. The fix names the server-set counterpart for the detected provider (app_metadata, publicMetadata, trusted_metadata, serverMetadata, metadata_admin) or, for roles, points at a database lookup in context (claim trust rules).

PD011: tenant from an optional claim

Fires when claims.tenant names a claim the issuer emits only for some accounts and the issuer is a multi-tenant one: Google's hd under accounts.google.com (absent for consumer accounts), Entra tid under the common or organizations endpoint (personal accounts arrive with the consumers tenant), Okta groups-derived tenants without a filter. A missing tenant yields a subject without one, which tenant-scoped conditions deny, so this is a warning rather than an error; it exists because the usual next step is a default tenant in the resolver, which is the bug. The fix is to compare the claim against onboarded tenants in the resolver, or to restrict the issuer (login.microsoftonline.com/<tenant>/v2.0 instead of common, the hd authorization parameter at Google) so the IdP filters first (single sign-on).

PD012: stale draft pin

Reads x-permdock-catalog.drafts from every OpenAPI document and Overlay permdock openapi has written in the repository (found through the openapi section of the config or the paths passed to --doc) and compares each pin with the revision the installed CLI emits (OpenAPI 3.3, watch list). A mismatch means the document carries an older draft shape than a regeneration would produce, which is a warning here and an error in permdock openapi emit --check; the fix is to regenerate. Documents and Overlays without drafts (targets 3.1 and 3.2, Overlay 1.1) are not checked; a --overlay 1.2 Overlay carries drafts.overlay (OpenAPI Overlay).

PD013: polymorphic EdDSA

Reads the algorithms option of every subjectFromJwt, createJwtSubjectResolver, joseTokenVerifier and joseTokenSigner call and warns when it lists EdDSA. RFC 9864 registers the fully-specified Ed25519 (and Ed448) and deprecates the polymorphic name because a verifier cannot tell from alg alone which curve it is committing to (JOSE). The runtime accepts EdDSA only for a crv: Ed25519 key, so this is a warning; the fix is to write Ed25519. none or RSA1_5 in the list is an error under the same check, since the runtime refuses them regardless.

PD014: discovery and issuer configuration

Reads the options of PermDock calls only: an object literal passed to a function imported from a permdock entry (joseTokenVerifier, subjectFromJwt, createJwtSubjectResolver, an adapter's createPermDock), an object literal nested in one, or a const holding an object literal that such a call names. An object with a jwks key that never reaches a PermDock call, such as another library's environment object, is not checked. Errors when a discovery value is not an https: URL, when discovery and jwks or issuer are both set (the document supplies both and a hand-set issuer that differs is the misconfiguration Discovery section 4.3 exists to catch), or when jwks is configured without issuer. With network access (--online) it fetches the Discovery document once and reports an issuer that does not equal the configured one byte for byte, a missing jwks_uri, or a jwks_uri on a different origin than the issuer, each of which the runtime would treat as discovery-mismatch and resolve every token to anonymous (OpenID Connect).

PD015: token type

Warns when a custom TokenVerifier passed as verifier does not declare typ handling (the conformance runner testTokenVerifier covers the runtime side; this is the static hint), and when accept: 'id-token' appears in a resolver used by an HTTP, MCP or agent adapter rather than a BFF session layer, because an ID token is not an access token and never carries delegation (JWT adapter). It also reports a resolver whose profile: 'fapi2' is paired with algorithms that include RS256, which the profile forbids.

PD016: RLS opaque grants and unverified twins

Warns when permdock.config.ts has an rls block whose policy still contains opaque conditions (they deny in memory; map the helper through rls.functions or rewrite as sqlFunction) or sqlFunction grants with no rls.fixtures.json (or rls.fixtures) so permdock rls verify --db cannot prove the twin. See conditions and rls.

Example report

permdock doctor

  ✖ PD001  app/(marketing)/pricing/page.tsx imports permdock/next
           fix: move the check into a Server Component or import from 'permdock/react'
  ✖ PD004  permissions.catalog.json is stale (post.publish added)
           fix: pnpm exec permdock collect
  ⚠ PD005  skill permdock@0.1.0 installed, 0.1.1 available
           fix: pnpm exec permdock skills install

  2 errors, 1 warning

The Unicode markers are replaced with error / warn under --no-color or when stdout is not a TTY.

PD017: sensitive verbs without approval

Loads the policy and warns on each allow whose action is in the sensitive list and that carries neither approval nor a deny on the same leaf. Override the list with doctor.sensitiveActions in permdock.config.ts. The fix text points at approval: { by }.

PD018: static separation of duty

role(..., { exclusiveWith: ['approver'] }) is data only: evaluation does not change. Doctor errors when a named exclusive role is not declared. When doctor.memberships points at a JSON fixture (customRoles and memberships arrays), doctor warns if a custom role includes two exclusive roles or a membership holds both. Dynamic SoD (two roles not active in one session) is not planned.

PD019: JWT-mode authorize() with long-lived tokens

With rls.rbac.authorize: 'jwt', Postgres reads roles from the access token, so a revoked role keeps working until the token expires. The check reads [auth] jwt_expiry from supabase/config.toml (default 3600) and warns when it exceeds an hour while the policy grants a verb from doctor.sensitiveActions. Fix it with authorize: 'database' or a shorter expiry.

PD020: hostable permissions compiled into RLS

permdock rls compiles the code policy, so a hosted grant on a hostable permission widens the application's answer while Postgres keeps the code answer. With an rls config, the check lists every hostable key whose resource is compiled (every resource, or those in rls.tables when it is set). Fix it by removing the permission from hostable, or by dropping its table from rls.tables and enforcing it in the application (hostable permissions). --only hosted runs this check and PD021.

PD021: hostable permissions without Cloud variables

A policy with hostable permissions expects hosted grants, which need permdock cloud push to publish the catalog and cloud() to fetch the policy document. The check warns when PERMDOCK_CLOUD_URL or PERMDOCK_CLOUD_KEY is missing from the environment doctor runs in, and names the missing variable (cloud).

PD022: views without security_invoker

A Postgres view runs with its owner's rights unless it is created with (security_invoker = true), so a view over an RLS table returns every row to every caller. With an rls config or doctor.migrations, the check reads the .sql files under doctor.migrations (default supabase/migrations, supabase/schemas, migrations, drizzle, prisma/migrations, db/migrations) in name order and warns on each create view that neither sets security_invoker nor gets it from a later alter view ... set (security_invoker = true). Materialized views are skipped: Postgres does not apply RLS to them at read time. The <table>_visible views permdock rls generate --fields views writes are security_invoker and pass; the <table>_visible_fields companion --revoke-columns adds reads as its owner on purpose, and its comment on view ... is 'permdock:field-companion ...' exempts it. --only views runs this check.

PD023: custom-role keys outside the ceiling

When doctor.memberships points at a JSON fixture with a customRoles array, the check runs validateCustomRole on each custom role and warns once per dropped entry: a permission no assignable declared role of the role's scope allows (outside-ceiling), an unknown permission key or include (unknown-permission, unknown-role), a grant carrying a condition (condition-not-allowed), or a level the resource does not declare (unknown-level). Evaluation already drops these, so the warning is about intent: the tenant admin expected the key to stick. Fix it by granting the permission to a declared assignable role, or by removing it from the custom role (custom roles). --only custom-roles runs this check.

PD024: approvals the requester can give

Every approval refuses the request's principal as approver, so a user cannot approve their own request. approval: { distinct: false } lifts that for one grant, which is right when the product wants a user to confirm their own agent's call and wrong everywhere else. The check loads the policy and warns once per allow that sets it, naming the permission and the role, so each opt-out is a visible decision in review. requireDistinctApprover on approvalsHandler overrides every opt-out at runtime (approval security). --only self-approval runs this check.

PD025: memberships the named scopes drop

When doctor.memberships points at a JSON fixture with a memberships array, the check normalises each entry against the policy's named scopes and warns on every entry evaluation would drop: a scope the policy does not declare, a nested membership whose within lacks an ancestor's id, or an entry mixing scope, tenant and on. Such a membership fails closed, so the warning is about a provider mapping or seed that silently grants nothing. --only scopes runs this check.

PD026: scopes nobody has to keep

The check loads the policy and warns once per scope that has roles held on it but where none sets min: every organization can then lose its last owner, and nobody is left who may assign roles there. Set min: 1 on the role that manages the scope (generated RLS then refuses to commit the removal of its last holder), or min: 0 on any of the scope's roles to record that no holder must stay, as for a customer scope whose contacts come and go (ownership). --only ownership runs this check.

PD027: request context in an RLS policy

A condition that reads context.* (where: { region: context.region }) is evaluated by can and compiled by the ORM adapters, which bind the request context. Postgres never sees that context: it is not in the JWT, so permdock rls generate refuses the grant (or skips it under --skip-closures) and the database cannot enforce the rule. With an rls config, the check names each such grant and the refs it reads. Fix it by moving the value into a server-set claim and comparing with principal.claims.<name>, which compiles, or by keeping the check in the application only (subject attributes in RLS). --only context-refs (or --only rls) runs this check.

PD028: attributes and memberships a user could set

The Supabase token hook copies supabase.hook.attrs into the attrs claim, which attribute conditions and RLS read (Supabase token hook). An error names an entry generate refuses: user_metadata or raw_user_meta_data, an auth.users column, a prototype key or a duplicate. A warning names listed columns that the SQL files under doctor.migrations (default supabase/migrations, supabase/schemas, migrations, drizzle, prisma/migrations, db/migrations) grant insert, update or all on to anon, authenticated or public, table-wide or by column, net of later revokes. Fix it by revoking those grants and granting column-level update on non-attribute columns only; the generated migration refuses to install until you do. The same warning covers every fromTable / fromJunction source in supabase.hook.memberships (or rls.membershipSources): its user, scope, id, within, role, via and expiry columns decide who holds which membership. The grant model is Postgres's: a column-level revoke leaves a table-level grant in place, so revoke update (user_id) on contacts after grant update on contacts still warns (sources). With a supabase config, a table created in public starts with Supabase's default privileges, which grant anon and authenticated everything, until a migration revokes them on the table or with alter default privileges in schema public revoke ... on tables. insert and update are tracked apart, so revoke update alone still leaves the default insert. --only attrs (or --only supabase) runs this check.

PD029: API keys that never expire

When doctor.credentials points at a JSON fixture { credentials?, settings? } (a sample of the API-key records the application stores, and per-tenant settings in the memorySettings shape), the check warns on every credential without expiresAt, every record parseCredential rejects (it would verify to no subject), and every tenant whose credentials settings set allowNoExpiry. A key that never expires stays live after it leaks until someone revokes it, so each opt-in should be deliberate (API keys). --only credentials runs this check.

PD030: field-limited columns the database still returns

fields on a read grant redact in the application (pick), but a row policy returns every column of the rows it admits. With an rls config, the check lists, per resource, the columns a read allow's fields leave out or a read deny's fields name (the row key excepted). Without rls.fields: 'views' it warns that RLS is row-level and any direct read of the table returns them; with field views but without rls.revokeColumns: true it warns that <table>_visible masks them while the table still returns them. Fix it with permdock rls generate --fields views --revoke-columns so clients read the columns only through the view, or keep reads of the table server-side and redact with pick (field security). --only fields runs this check.

PD031: graph declarations that do nothing

A self-parent is what makes a chain, and restricted is what stops one, but only a relation(..., { through: 'parent' }) grant reads either. The check names each self-parented resource no such grant walks (its ancestors grant nothing, and rls generate keeps no closure for it) and each restricted column on a resource no graph grant reaches. Grant through the chain, or remove the declaration (relationships). --only graph runs PD031 and PD032.

PD032: graph resources RLS cannot name

With an rls config, every resource a graph grant reads gets a permitted_<resource>_ids helper, named in snake*case: chatThread becomes permitted_chat_thread_ids, and its links and closure objects follow (permdock_link_chat_thread*<link>, permdock_closure_chat_thread). A resource whose snake_case name matches a scope (a teamresource for sub-teams next to theteam scope) would replace the scope helper of the same name and signature, two resources with one snake_case name (chatThreadandchat_thread) would share a helper, and a name with a hyphen is not a SQL name, so rls generate refuses all three; the check reports them before it runs. Rename the resource.

PD033: activation without a ceiling, or held standing

A role activation with no maxDuration mints an elevation that never expires on its own, so it leans entirely on a revocation the app might forget; set a maxDuration. The check also reads the doctor.memberships fixture and names any activation role a membership holds under roles rather than eligible: an activation role is eligible-only and must never be held directly (elevated access). --only activation runs PD033.

PD034: break-glass under an RLS config

A breakGlass grant is the one deny override, and it is non-portable on purpose: RLS never compiles it. Under an rls config the check names each break-glass permission so it is clear the database side reads through the generated permdock_break_glass_<resource> security definer function (which checks a signed break-glass session and writes an audit row), never a policy, so plain RLS keeps denying the restricted rows (elevated access). --only break-glass runs PD034.

PD035: support access without an actor

supportAccess is impersonation: a vendor acts inside a tenant. With actorRequired: false the session runs as the tenant with no act, so the audit log cannot tell the vendor apart from the customer. The check names each support role that omits it; set actorRequired: true (elevated access). --only support runs PD035.

PD036: id route without a row loader

Broken object level authorization (BOLA, OWASP API1) is a route that takes an object id and checks only that the caller holds the permission, so anyone who may update one post may update every post. protect(permission, loader) loads the row and decides against it; protect(permission) alone decides a collection-level question. The check reads each protect( call with a single argument and warns when the file sits under a dynamic route segment (app/posts/[id]/route.ts) or when a route literal with a parameter ('/posts/:id', '/posts/{id}') appears earlier in the same statement. It is a source scan, not a type check: a route that loads the row some other way and calls assert in the handler also passes, and can ignore the warning. --only bola runs PD036.

PD037: helper calls for row-conditioned permissions

The SQL helpers answer one question, whether the subject holds a role with the grant key in a scope; the row conditions of a grant stay in PermDock's own table policies (RLS). A Storage or Realtime policy written outside PermDock, such as a better-supabase bucket or topic access policy, that calls a helper for a permission with a where, check, closure, field list, purpose, break-glass override, validFrom / validUntil window, or a relation, plan, actor or assurance grantee would therefore grant more than the application does. The check reads create policy statements on storage.objects and realtime.messages from the migration directories (doctor.migrations) and errors on each grant-key helper call (permdock_has, permitted_<scope>_ids) whose key has rowConditions: true in the catalog. permdock_has_permission('<key>') and permitted_<scope>_ids_by_permission('<key>') count only the allows without a row condition, minus any deny, so they never grant more than the application does and are not flagged; permitted_<scope>_ids_by_permission('<key>', true) adds the conditioned allows and is. That is the fix for a permission that also has a relationship grant, such as a chat thread shared with one user: the topic policy calls the permission-key form and admits the members whose role reaches the thread's organization, while the share stays in the table policies. permdock rls verify --db runs the same check against pg_policies. --only supabase or --only row-conditions runs PD037.

PD038: tenant claim mismatch

The hook writes the active tenant to rls.tenantClaim (default supabaseTenantClaim, tenant_id), and the generated helpers compare against it; subjectFromSupabase and subjectFromSupabaseSession read it from their tenant option, with the same default. When the two differ, the subject has no active tenant while RLS still scopes rows to one, so can() and the database disagree. The check finds each call in the source scan and compares its tenant option, or the default when the call passes none, with rls.tenantClaim. A call whose options are a variable is skipped. --only supabase or --only tenant runs PD038.

PD039: Supabase hook setup

The hook writes claims that only the generated SQL helpers read. The check lists the helpers the policy's scopes need (permdock_has, permitted_<scope>_ids and member_<scope>_ids in rls.schema, plus member_<scope>_ids_for for each scope with a membership source) and warns on each one no SQL file defines: rls.out (else rls.sql; with a {part} placeholder, its helpers part), the SQL files in the hook file's folder, and the doctor.migrations folders, never the hook's own file. A project that writes the helpers with rls generate --split therefore passes when rls.out holds the same {part} path the command uses, or when the helpers part sits next to the hook. permdock supabase hook generate prints the same warning, and with --db looks in the database instead. When doctor.claims points at a JSON array of sample decoded tokens, the check also measures each supabase.hook.claims entry and warns when its largest sample is more bytes of JSON than the memberships budget: those claims sit outside the budget but still travel in the session cookie. It also lists each sample's memberships entries that subjectFromSupabase cannot read (the membership-dropped cause at runtime). --only supabase or --only helpers runs PD039.

PD040: auth.role() in a migration

Supabase deprecated auth.role(). The role a request runs under is already the Postgres role (anon or authenticated), so a policy names it with to authenticated instead of testing auth.role() = 'authenticated' in using. The check scans the doctor.migrations folders, skipping comments, and names each call by file and line. --only auth-role runs it.

PD041: HS256 capability tokens

exchangeCapability can sign with the project's legacy JWT secret (alg: 'HS256', key: { secret }). Whoever holds that secret can mint a token for any user, not just a link. The check warns on each call whose options are a literal with alg: 'HS256'; sign with ES256 and the private JWK of an asymmetric Supabase signing key instead. --only capabilities runs it. Token lifetime, refresh-token reuse and HS256 on the local stack are better-supabase's checks; PermDock keeps PD019 because it is about how stale the authorization claims may be.

PD042: hook grants missing from the migrations

supabase db diff writes migrations from supabase/schemas but drops function privileges, so a hook declared there lands without its supabase_auth_admin execute grant and its revoke: the auth server cannot call it, and Postgres's default execute for public lets clients call it. The check runs when a file under supabase/schemas starts with the hook marker. It finds the first migration under supabase/migrations that creates custom_access_token_hook and the last that drops it, and errors unless that migration or a later one grants execute on the hook to supabase_auth_admin. create or replace function keeps the grants, so later migrations that only change the body pass. Generate the grants file with --grants-out (declarative schemas). pg-delta's declarative sync keeps those grants, so the check is off when supabase/config.toml sets [experimental.pgdelta] enabled = true. --only declarative (or --only supabase) runs PD042.

PD043: helpers applied after their callers

db diff applies schema files in the order of the schema_paths globs in supabase/config.toml (default ./schemas/**/*.sql), sorted within each glob, so a policy that calls permdock_has, permitted_<scope>_ids or member_<scope>_ids before the file that defines permdock_has fails to apply. The check warns on each such file, and once when schema_paths leaves out the file that defines the helpers. pg-delta orders statements by their dependencies, so the check is off under [experimental.pgdelta]. --only declarative (or --only supabase) runs PD043.

PD044: server-only grants with no endpoint

A snapshot carries portable conditions, so usePermission answers a where on the client. A grant it cannot carry (a closure, a graph relation through parent or links, a relation with a period) is portable: false, and the hook asks the decision endpoint. The check loads the policy, lists the permissions with such an allow, and warns once per usePermission call on one of them in the source scan when no app/**/api/permdock/route.ts mentions permdockHandler and no source file passes an endpoint string. Without either, the client always answers denied with reason server-only. Fix it by adding the route, passing endpoint to createPermDock or the provider, or moving the check to the server with getPermission. A portable row condition never warns: the browser evaluates it from the snapshot. --only next or --only endpoint runs PD044.

PD045: unparseable config.toml

PD019 reads [auth] jwt_expiry and PD043 reads [db.migrations] schema_paths from supabase/config.toml with a TOML parser. When the file does not parse, both checks fall back to Supabase's defaults (3600 seconds and ./schemas/**/*.sql), and PD045 names the first parse error so a finding is never based on a misread file. --only supabase runs PD045.

PD046: helper schema exposed through the Data API

permdock rls writes its helpers, role_permissions, user_roles, authorize and the token hook into rls.schema, permdock by default, so the Data API never serves them. The security definer helpers answer for any user id they are given, so listing that schema in [api] schemas in supabase/config.toml turns each into an RPC that reports another user's memberships. The check runs when supabase/config.toml exists and warns when [api] schemas (default public, graphql_public) names the helper schema. Remove it from the list. --only sql (or --only supabase) runs PD046 to PD053, PD062 and PD063.

PD047: user metadata in SQL

raw_user_meta_data in auth.users and the user_metadata claim are written by the user through supabase.auth.updateUser, so a policy or function that decides on them lets the user decide. The check names each read in the migration folders by file and line. Read raw_app_meta_data / app_metadata, which only the service role writes, or a table the user cannot write.

PD048: functions without search_path

A function resolves unqualified names through the caller's search_path, so a caller can create an object that shadows one the function reads; for a security definer function that runs with the owner's rights. The check names each create function that neither sets search_path nor gets it from a later alter function ... set search_path, skipping language c and internal. Add set search_path = '' and qualify every name. Every function permdock rls writes sets it.

PD049: security definer functions clients can call

Postgres grants execute on every new function to public, and Supabase also grants it to anon in the public schema. The check names each security definer function, trigger functions excepted, that no later revoke execute (on the function, on all functions in its schema, or through alter default privileges) takes from public and, in public, from anon. Revoke both and grant execute to authenticated only where a client must call it, as the generated authorize does.

PD050: tables without row level security

Supabase's Data API serves every table in an exposed schema, and the default privileges let anon and authenticated read and write it, so a public table without row level security is open to anyone with the anon key. The check names each create table in public that no alter table ... enable row level security in the migrations follows and no later drop table removes. It is an error: there is no safe reading of an open table.

PD051: per-row auth.uid() in a policy

auth.uid() and auth.jwt() are functions, and a bare call in using or with check runs once for each row the query reads. Wrapped as (select auth.uid()), Postgres evaluates it once per statement as an init plan. The check names each policy with a bare call. The policies permdock rls writes already wrap them.

PD052: update policies without with check

Postgres checks the new row of an update against with check, falling back to using when there is none. The fallback is right only when the two conditions should be the same; a policy that lets a user update their own rows usually also needs to stop them moving a row to another tenant or owner. The check names each create policy ... for update without with check so the choice is explicit.

PD053: foreign keys without an index

Postgres indexes the referenced key but not the referencing column. A policy that joins on that column, and every delete or key update of the referenced row, then scans the table. The check reads references in create table and alter table ... add constraint, and names each foreign key column that no create index, primary key or unique constraint starts with. Add an index whose first column is the foreign key.

PD054: stale role_permissions seeds

The helpers answer from role_permissions, so a policy change reaches the database only when a migration seeds the new rows. With --split ...,seeds those rows are a versioned migration of their own, and a policy change without a new one leaves the helpers on the old grants. With an rls or supabase config, the check finds the last statement in the migration folders that seeds role_permissions in rls.schema (or clears it), compiles the policy's rows, and names how many are missing and how many are stale, with examples. Run permdock rls generate and apply the new seeds. --only seeds (or --only supabase) runs PD054.

PD055: custom roles stored under a former key

A key renamed with definePermissions(..., { renamed }) keeps resolving in stored custom roles, so nothing breaks the day it is renamed. Removing the alias does break them, and permdock diff reports that as alias-removed. The check runs validateCustomRole over the doctor.memberships fixture and prints, for each stored former key, the update that rewrites it in custom_role_permissions. Apply it, or the same rewrite in your own role storage, before removing the alias. --only renamed (or --only custom-roles) runs PD055.

PD056: callers of a legacy helper

With rls.migrate.helpers set, the check counts calls to each legacy helper name in the migration folders, leaving out the statements that create, grant or drop the helper itself. A remaining call keeps the legacy function, or its shim, in use. Run permdock rls migrate --write for the policies, rewrite function bodies, views and triggers onto the PermDock helpers by hand, then drop the function. --only shims (or --only sql) runs PD056.

PD057: anonymous sign-ins mapped as users

With rls.anonymousSignIns: 'deny', the generated policies refuse a signInAnonymously() token, but subjectFromSupabase maps it as a signed-in user unless it gets the same option, so the server and the database disagree. The check names each subjectFromSupabase or subjectFromSupabaseSession call whose options object does not set anonymousSignIns: 'deny'; options passed as a variable are not read. --only anonymous (or --only supabase) runs PD057.

PD058: stale Sync Streams

With powersync in the config, the check compiles the policy to Sync Streams and compares the result with powersync.out (default sync-config.yaml). A missing or different file means the PowerSync service syncs by an older policy: a revoked grant still syncs rows to devices. With powersync.manifest set, the manifest file is compared as JSON with localSnapshotManifest(policy): a stale one makes the device build snapshots by an older policy. A config that does not compile to Sync Streams is a finding with the compiler's message. Run permdock powersync generate and deploy the files. --only powersync runs PD058.

PD059: policy module did not load

Doctor loads the policy module once and every policy check reads that load. When the module throws, or exports no policy from definePolicy, those checks have nothing to read; without PD059 the report would be clean for the wrong reason. The finding names the path and the error. --only project runs PD059 and PD060 alone.

PD060: unparsable source file

collect, usage and doctor scan every file under collect.srcPath with oxc-parser. A file with a syntax error is scanned only as far as it parses, so a permission it references can be missing from the catalog. The finding names the file, the line and the parser's message.

PD061: permissions a suspended scope keeps

rls.suspension.scopes.<scope>.keep names permissions that members of a suspended instance, and of every instance nested in it, still hold, so a suspended organization can still restore itself or cancel its scheduled deletion (suspension). The check warns once per scope with the kept keys, so each one is a visible decision in review, and once more for a key the definitions do not declare (a former key from renamed is named with its current key): such a key never matches a check, so the suspended scope keeps nothing for it. rls.suspension.memberships.keep gets the same two warnings for what a suspended membership still holds (suspended memberships). Keep permission references rather than keys to have the type checker catch a rename. --only suspension (or --only rls) runs PD061.

PD062: legacy JWT claim settings

PostgREST 12 sets the verified claims as one JSON setting, request.jwt.claims; the per-claim request.jwt.claim.<name> settings are gone. withPostgresClient from @supabase/server, PermDock's withSubject and permdock rls verify set only request.jwt.claims too, so a policy or function that reads request.jwt.claim.sub gets null and denies. A test or job that sets it impersonates nobody. The check names each statement in the migration folders that uses a legacy setting; a function replaced by a later migration counts only by its last definition. Read the subject with (select permdock.permdock_user_id()) or (select auth.uid()), read another claim with (select auth.jwt()) ->> '<name>', and set request.jwt.claims in tests. --only sql (or --only supabase) runs PD062.

PD063: statements supautils rejects

Supabase runs the supautils extension, which refuses some role statements to postgres, the role that runs migrations, so supabase db push stops on them. The check reports each such statement in the migration folders as an error:

  • alter role or alter user on a reserved role: anon, authenticated, service_role, authenticator, dashboard_user, pgbouncer and the supabase_* roles. alter role … set and alter role … reset on anon, authenticated, service_role and authenticator are allowed.
  • drop role on a reserved role.
  • A grant of a reserved membership, through grant … to or create role … in role: authenticator, dashboard_user, pgbouncer, the supabase_* admin roles and pg_read_server_files, pg_write_server_files and pg_execute_server_program.

The lists are those of supautils 3.4.4 on supabase/postgres 17.11. Grant your own role to authenticator instead: grant app_reader to authenticator is allowed. permdock rls generate refuses to write a rejected statement. --only sql (or --only supabase) runs PD063.

PD064: assignment trigger missing

With rls.assignments and a role that declares assigns, every membership table rls.memberships maps, the global-roles table (rls.roles, else supabase.hook.roles) and every table in rls.assignments.tables should carry the permdock_assignment trigger permdock rls generate writes (assignment triggers). The check reads the .sql files under doctor.migrations and the rls.out file (its helpers and policies parts with {part}) and warns once per guarded table no create trigger "permdock_assignment" ... on <table> names: until a migration applies it, a client that may write the table may write any role there, its own included. Run permdock rls generate and apply its output, or point rls.out or doctor.migrations at where the output lives. --only assignments (or --only rls) runs PD064.

PD065: server-only grants in React Native code

A React Native app answers portable grants from the persisted snapshot, offline included. A grant the snapshot cannot carry (a closure, a graph relation through parent or links, a relation with a period) goes to the decision endpoint, so without a connection the hook answers denied with reason server-only. The check loads the policy, lists the permissions with such an allow, and warns once per usePermission call or permission reference on one of them in a source file that imports permdock/react-native, whether or not an endpoint is configured. Fix it by expressing the grant with a portable condition when the screen must work offline, or by showing the offline state for that check (React Native offline behaviour). --only react-native (or --only endpoint) runs PD065.

PD066: a materialised table still created

With rls.migrate.tables set, the check replays the migration folders the way rls migrate does and warns once per listed table that a migration still creates and none drops. While a policy on another table, a view or a function still reads it, the message counts the readers and names the first with its file and line; rewrite them onto the PermDock helpers. With no reader left, the message says so: run permdock rls migrate --retire-out and apply the migration it writes. --only shims (or --only sql) runs PD066.

Why

  • config.toml goes through a TOML parser. Line-by-line matching misreads comments, multi-line arrays and quoted section names, and a misread jwt_expiry would silence PD019, a security warning.
  • PD001 reads direct imports, not an import graph. A graph needs module resolution for every bundler's aliases, and a wrong resolution produces a false path to a server module. The bundle measurement already fails on a server import reachable from a client entry; doctor catches the common case, a client file importing a server entry, in milliseconds. doctor.clientEntries replaces guessing client roots from folder names.
  • security_invoker is a warning, not generated SQL. A view over application data is application SQL, and some views are meant to bypass RLS (a public aggregate), so the warning names the view and the choice stays explicit. The only views permdock rls writes are the opt-in field views, which are security_invoker, and their owner-rights companion, which is marked so the check can tell it apart.
  • PD030 is a warning, not an error. Many apps never expose a table to clients (a server-only connection, no Data API), and there pick is the whole control. Where clients do read the table, the warning names each column so the fix is one flag.
  • One project load per run. Doctor reads the config, the source files, the policy module and the collect --check scan once, on first use, and passes them to every check in a table of checks. Each check used to load the policy and rescan the sources itself; on a 2,000-file tree that halved the run, from 850 ms to 429 ms. A --only run still loads only what its checks read.
  • Docs drift is a maintainer check, not a doctor check. Whether the PermDock docs list every CLI flag and public export is a property of this repository; scripts/check-docs-drift.ts and a CLI test, which reads each command's flag definitions, run it in the PermDock CI, and doctor stays about the consumer's project.

Last updated on

On this page

UsageWhere doctor reads source filesChecksPD001: server-only imports in client entriesPD002: unknown permission referencesPD003: used but ungrantedPD004: stale catalogPD005: skillsPD006: TypeScript versionPD007: validation modePD008: reserved identifiersPD009: duplicate copiesPD010: roles or tenant from an unverified claimPD011: tenant from an optional claimPD012: stale draft pinPD013: polymorphic EdDSAPD014: discovery and issuer configurationPD015: token typePD016: RLS opaque grants and unverified twinsExample reportPD017: sensitive verbs without approvalPD018: static separation of dutyPD019: JWT-mode authorize() with long-lived tokensPD020: hostable permissions compiled into RLSPD021: hostable permissions without Cloud variablesPD022: views without security_invokerPD023: custom-role keys outside the ceilingPD024: approvals the requester can givePD025: memberships the named scopes dropPD026: scopes nobody has to keepPD027: request context in an RLS policyPD028: attributes and memberships a user could setPD029: API keys that never expirePD030: field-limited columns the database still returnsPD031: graph declarations that do nothingPD032: graph resources RLS cannot namePD033: activation without a ceiling, or held standingPD034: break-glass under an RLS configPD035: support access without an actorPD036: id route without a row loaderPD037: helper calls for row-conditioned permissionsPD038: tenant claim mismatchPD039: Supabase hook setupPD040: auth.role() in a migrationPD041: HS256 capability tokensPD042: hook grants missing from the migrationsPD043: helpers applied after their callersPD044: server-only grants with no endpointPD045: unparseable config.tomlPD046: helper schema exposed through the Data APIPD047: user metadata in SQLPD048: functions without search_pathPD049: security definer functions clients can callPD050: tables without row level securityPD051: per-row auth.uid() in a policyPD052: update policies without with checkPD053: foreign keys without an indexPD054: stale role_permissions seedsPD055: custom roles stored under a former keyPD056: callers of a legacy helperPD057: anonymous sign-ins mapped as usersPD058: stale Sync StreamsPD059: policy module did not loadPD060: unparsable source filePD061: permissions a suspended scope keepsPD062: legacy JWT claim settingsPD063: statements supautils rejectsPD064: assignment trigger missingPD065: server-only grants in React Native codePD066: a materialised table still createdWhyRelated