PermDock
Adapters

better-supabase

The permdock/better-supabase entry fills better-supabase's authorization slots with PermDock, an authorization provider for its SQL modules and doctor, bucket and topic policies, API keys, MCP tool hooks, a credential guard, and a subject from its session.

permdock/better-supabase connects PermDock to better-supabase. better-supabase verifies tokens, builds sessions and owns the data layer; PermDock owns roles, permissions, the claim contract and the one token hook. better-supabase never imports PermDock: it reads a versioned AuthorizationProvider object from its config, and this entry builds that object and the other values its slots take from the files PermDock already generates.

The entry needs better-supabase 0.6 or later as a peer. Everything on the Supabase page still applies: the hook, the helpers and the claims are the same with or without better-supabase.

Why

  • better-supabase has slots, not a PermDock mode. Its config takes an authorization provider, a bucket or topic takes an access policy, and its API key block takes a claim and a verifier. Each slot is a neutral, versioned contract (apiVersion: 1), so PermDock fills it the way permdock/better-auth fills Better Auth's role source, and any other authorization package could fill it too.
  • The provider is data built from the manifest. permdock supabase inspect --out already writes permdock.manifest.json with the helper schema, the scopes, the membership tables and the hook's claims. authorizationProvider maps that file to the contract, so better-supabase's SQL and doctor follow the helpers permdock rls generate wrote without reading a PermDock file themselves.
  • permdock/supabase stays plain Supabase. It names no better-supabase identifier, and an app on @supabase/supabase-js or @supabase/server alone never loads this entry.

Who owns what

ConcernOwner
Token verification (@supabase/server), sessions, refresh and the AuthSession shapebetter-supabase
Typed clients, repositories and typegenbetter-supabase
Storage and Realtime plumbing (defineBucket, defineTopic), jobs and webhooksbetter-supabase
MCP protected-resource metadata and the scope guards (createMcp, createMcpAuth)better-supabase
Token lifetime, refresh-token reuse, signing keys, splinter and claim size lintsbetter-supabase
The claim contract: user_role, roles, memberships, the active-tenant claim, attrs, authz_ver, memberships_truncatedPermDock
The one token hook (permdock supabase hook generate)PermDock
role_permissions, authorize(), the SQL helpers and the table policiesPermDock
Tool and route authorizationPermDock

API

import {
  authorizationProvider,
  bucketPolicy,
  topicPolicy,
  apiKeyVerifier,
  apiKeyClaimOptions,
  subjectFromBetterSupabase,
  toolPolicy,
  credentialGuard,
} from "permdock/better-supabase";
  • authorizationProvider({ manifest, catalog?, scope?, approver? }) returns better-supabase's AuthorizationProvider (apiVersion: 1, name: "PermDock"). manifest is permdock.manifest.json, parsed or as JSON text; catalog is permissions.catalog.json. Without a catalog the provider lists no permissions, so better-supabase refuses every key a bucket, topic or module checks, and problems asks for it. scope names the tenant scope and defaults to the manifest's one root scope.
  • Its functions call permitted_<scope>_ids_by_permission, member_<scope>_ids, permdock_has_permission and their _for forms in rls.schema. These take a permission key and subtract the instances a deny of it reaches, so a permission split into #n grant keys still matches. permissionsFor calls permitted_<scope>_permission_keys_for, so better-supabase's member_permissions lists a member's keys; like the other _for templates it is set only when the manifest lists the helper, which rls.mode: 'database' writes.
  • canAssign and canAssignFor call permdock_can_assign and permdock_can_assign_for. When the manifest's rls.customRoles is set and the tenant scope is a root scope, they call permdock_can_assign_any and permdock_can_assign_any_for at the tenant scope instead, so better-supabase's can_assign and can_assign_as also answer for a custom role.
  • approver is a permission. With it, canApprove lets a member who holds that permission in the tenant approve an AI tool call (decide_ai_tool_approval), and approvals.distinctApprover is true, so the requester never approves their own call. A key the catalog lacks is a problem.
  • scopes carry each scope's idType: uuid, text, bigint or integer, with int8, int and int4 normalised. Any other id type is a problem.
  • requires lists each helper with the role that must execute it; memberships, suspension and roleSources come from rls; tokenHook names the hook function and the claims it owns. A manifest that cannot fill a field is recorded in problems, which better-supabase's doctor reports, and an invalid manifest throws a TypeError.
  • bucketPolicy(access, options) and topicPolicy(access, options) return the access policy for defineBucket and defineTopic. access maps the operations (read, list, write, delete for a bucket; receive, send for a topic) to permissions, and options is { manifest, catalog, scope, segment? } or { policy, schema?, scope, segment? }. With policy, the definePolicy result the app already imports, nothing is read at runtime: the scopes, the permissions with row conditions and the scopes each permission is granted at come from the definitions, and schema names the helper schema (rls.schema, default permdock). The checks below are the same either way. They call permitted_<scope>_ids_by_permission and permdock_has_permission, which check role and scope only and subtract denies, so a permission the catalog marks rowConditions: true (or a conditioned grant in policy), or does not grant at scope, throws. scope: "platform" checks a permission granted globally.
  • apiKeyVerifier({ keys, manifest?, serviceRoles?, allPermissions? }) is a CredentialVerifier over better-supabase's createApiKeys(), for subjectFromApiKey. A personal key is a user credential acting as its user, a tenant key a service credential holding serviceRoles in its tenant, and the key's scopes are the credential's permissions. serviceRoles defaults to the manifest's rls.apiKeys.serviceRoles. A rotated key in its grace period verifies, and its credential's expiresAt is the end of the grace period when that comes before the key's own expiry. An invalid, revoked, expired or rate-limited key, and a failed lookup, verify to null.
  • apiKeyClaimOptions(manifest) returns the claim and tenantClaim options of better-supabase's apiKeyClaims() and apiKeyResolver(), from the manifest's rls.apiKeys, so a key's token carries the scopes ceiling, tenant and roles the helpers read. It throws when the manifest has no rls.apiKeys.
  • subjectFromBetterSupabase(session, options?) is subjectFromSupabaseSession with better-supabase's defaults: memberships for the memberships and the entitlements module's features claim for principal.plans. A user session maps through subjectFromSupabase. With apiKeys: { permissions, manifest?, serviceRoles? }, an apiKey session maps like apiKeyVerifier's credential; without it, and for anon, service and invalid sessions, the subject is anonymous. plans: { claim?, keys? } decodes the short codes entitlements.claim.keys writes back to feature keys. Other options override the defaults.
  • toolPolicy({ permdock, data? }) returns the authorize and visible hooks of createMcp for tools whose meta is a permission (MCP tools).
  • credentialGuard(provider, { permdock, use, revoke? }) wraps a CredentialProvider so PermDock decides each token use (Credentials).

Setup

Generate the helpers, the hook and the manifest first, as on the Supabase page:

permdock rls generate --target sql --out supabase/migrations/<n>_permdock_rls.sql
permdock supabase hook generate
permdock supabase inspect --out
permdock catalog --out permissions.catalog.json

Then hand the provider to better-supabase. The config runs in Node, so it reads the two files directly:

better-supabase.config.ts
import { readFileSync } from "node:fs";
import { defineConfig } from "better-supabase/config";
import { authorizationProvider } from "permdock/better-supabase";

const read = (file: string) =>
  readFileSync(new URL(file, import.meta.url), "utf8");

export default defineConfig({
  authorization: authorizationProvider({
    manifest: read("permdock.manifest.json"),
    catalog: read("permissions.catalog.json"),
  }),
  sql: { modules: { access: { model: "provider" }, organizations: {} } },
});

Run better-supabase sql sync and better-supabase gen after a change to the policy, so the rendered SQL and the provider agree. Rerun permdock supabase inspect --out first.

Claims

Validate sessions with the claim contract's Standard Schema, extended with the app's own claims:

src/lib/supabase/index.ts
import { defineSupabase } from "better-supabase";
import { supabaseClaims } from "permdock/supabase";
import { z } from "zod";
import { schema } from "./generated";

export const betterSupabase = defineSupabase(schema).claims(
  supabaseClaims().extend(
    z.object({ datetime_preferences: z.object({ timezone: z.string() }) }),
  ),
);

A token whose claims fail either schema resolves to an invalid session. Pass supabaseClaims({ tenantClaim }) when rls.tenantClaim is not tenant_id, and set the same name in better-supabase's claims.tenant.

Memberships

PermDock owns the memberships. Keep them in the app's own tables and describe them with PermDock sources. Don't run better-supabase sql add tenant, whose membership_claims() would write a second set of tenant claims: it refuses while the provider's tokenHook.ownedClaims lists memberships, and better-supabase's doctor reports BS407 for a hook that writes them next to PermDock's.

The entitlements module's features claim goes through supabase.hook.claims:

permdock.config.ts
import { fromJunction } from "permdock/supabase";

export default {
  supabase: {
    hook: {
      memberships: [
        fromJunction({
          table: "app.memberships",
          scope: "organization",
          id: "organization_id",
          roles: "role",
        }),
      ],
      claims: { features: "public.feature_claims" },
    },
  },
};

With the provider set, the entitlements module reads membership through member_<scope>_ids and its _for form, so an expired membership or a suspended organization loses its features in the same token that loses the membership (claims other packages own).

Server code

src/lib/access.ts
import { createPermDock } from "permdock";
import { subjectFromBetterSupabase } from "permdock/better-supabase";
import { policy } from "../policy";
import { bs } from "./supabase/server";

export async function permdockFor() {
  const session = await bs.session();
  return createPermDock(policy, subjectFromBetterSupabase(session));
}

The same subject works in the Hono, oRPC and @supabase/middleware adapters: map c.get("auth"), context.auth or the session the middleware put on the context.

Storage and Realtime

import { defineBucket } from "better-supabase/storage";
import { bucketPolicy } from "permdock/better-supabase";

export const documents = defineBucket({
  id: "documents",
  path: "{organizationId}/{name}",
  policy: bucketPolicy(
    { read: permissions.documents.browse, write: permissions.documents.upload },
    { policy, scope: "organization" }, // or { manifest, catalog, scope }
  ),
});

Pass policy when the bucket file can import the app's policy: no JSON file is read at runtime, and bucketPolicy fails at definition time on a key with a conditioned grant. Pass manifest and catalog when the file cannot import the policy, for example because the policy module does not load where better-supabase reads its config.

A permission's row conditions (such as ownerId = principal.id) are applied by the table policies, not by the helpers, so bucketPolicy refuses a key the catalog marks rowConditions: true. Grant browse-style permissions per scope with no row condition for objects. permdock doctor PD037 reports a storage.objects or realtime.messages policy that calls a helper for such a key.

API keys

PermDock parses better-supabase keys only when the block uses PermDock's prefix: pass prefix: "pdk" to createApiKeys. The format is the same, checksum included. The credential's id is the key's public id, the part between the prefix and the secret.

Use apiKeyClaimOptions where better-supabase turns a key into a token, so the token carries the claims the helpers read:

import { apiKeyResolver, createApiKeys } from "better-supabase/blocks/api-keys";
import { apiKeyClaimOptions } from "permdock/better-supabase";

const keys = createApiKeys({ transport, prefix: "pdk" });
const resolver = apiKeyResolver({
  keys,
  ...apiKeyClaimOptions(manifest),
  serviceRoles: () => ["integration"],
});

Use apiKeyVerifier where PermDock reads the key itself, for example a route outside better-supabase's middleware:

import { apiKeyVerifier } from "permdock/better-supabase";
import { subjectFromApiKey } from "permdock/server";

const resolveKey = subjectFromApiKey({
  verifier: apiKeyVerifier({
    keys,
    manifest,
    allPermissions: Object.values(permissions),
  }),
  permissions,
});
const subject = await resolveKey(request.headers.get("x-api-key"));

permdock/server exports an apiKeyVerifier too, over PermDock's own key store. This one reads better-supabase's api_keys table instead; import the one that matches where the keys live.

When better-supabase's middleware already verified the key, the session is kind: "apiKey". Pass apiKeys to subjectFromBetterSupabase to map it the same way:

const subject = subjectFromBetterSupabase(session, {
  apiKeys: { permissions, manifest },
});

MCP tools

permdock/mcp wraps the official MCP SDK's server, so it cannot wrap better-supabase's createMcp. With createMcp, put the permission in each tool's meta and spread toolPolicy into the options. A tool without a permission is hidden and refused, and a check that throws refuses:

supabase/functions/mcp/index.ts
import { createMcp } from "better-supabase/mcp";
import { createPermDock } from "permdock";
import {
  subjectFromBetterSupabase,
  toolPolicy,
} from "permdock/better-supabase";
import { betterSupabase } from "../_shared/supabase.ts";
import { permissions, policy } from "../_shared/policy.ts";

type Auth = Parameters<typeof subjectFromBetterSupabase>[0];
type Instance = Awaited<ReturnType<typeof createPermDock>>;

// One PermDock instance per request: `visible` runs once per tool.
const instances = new WeakMap<object, Promise<Instance>>();
function permdockFor(ctx: { auth: Auth }): Promise<Instance> {
  let permdock = instances.get(ctx.auth);
  if (!permdock) {
    permdock = createPermDock(policy, subjectFromBetterSupabase(ctx.auth));
    instances.set(ctx.auth, permdock);
  }
  return permdock;
}

const server = createMcp(betterSupabase, {
  name: "crm",
  version: "1.0.0",
  advertisedScopes: ["openid"],
  ...toolPolicy({ permdock: permdockFor }),
}).tool({
  name: "export_customers",
  description: "Export the organisation's customers as CSV.",
  meta: permissions.customers.export,
  run: (_args, { db }) => db.customers.findMany(),
});

visible uses mayUse, so a tool is listed when some grant could apply, including one with a row condition. authorize decides with decide on the row data(ctx, tool, args) returns, or on no row, which a row-scoped permission denies; check those inside run or rely on RLS. A permission that needs approval is refused, because createMcp has no approval store: serve those tools through permdock/mcp on the official SDK, with better-supabase's createMcpAuth from better-supabase/mcp/sdk for the bearer token and metadata. tests/better-supabase/mcp.test-d.ts type-checks this recipe against better-supabase/mcp.

Credentials

credentialGuard wraps a better-supabase CredentialProvider, the interface its AI and integration blocks fetch third-party tokens through. Every getToken, startAuthorization and completeAuthorization is decided against use, and revoke against revoke or use, with the CredentialRef as the row. A denial or a failed check returns a forbidden error with hint PERMDOCK_DENIED; capabilities and verifyInbound pass through.

import { credentialGuard } from "permdock/better-supabase";

const credentials = credentialGuard(vault, {
  permdock: () => permdockFor(request),
  use: permissions.integrations.use,
  revoke: permissions.integrations.manage,
});

Build it per request, with that request's PermDock. testCredentialProvider from better-supabase/testing accepts the guarded provider.

Agents and tool approval

better-supabase's createAgentRuntime({ toolApproval }) from better-supabase/ai-sdk/agents takes an AI SDK tool-approval function and combines it with the tenant's ask tools; the stricter answer wins. toolApproval and composeToolApproval(...) from permdock/ai-sdk are such functions: a call PermDock answers approval-required asks for the user's approval, and a denied call is refused. tests/better-supabase/agents.test-d.ts type-checks the pairing.

Module permission keys

better-supabase's SQL modules check permission keys, such as audit.read, api_keys.manage and the AI, workflow and inbox modules' keys. Its doctor reports a key the provider's permissions lacks, so pass catalog and declare each key the installed modules check in the policy. Rename a key with sql.modules.<module>.permissions in better-supabase.config.ts.

Audit the PermDock tables

better-supabase's audit module registers a table with better_supabase.audit(target regclass, …), which creates the audit trigger and reads the table's primary key. rls.audit writes that call into the generated SQL for the custom-role tables, so every save through permdock_replace_custom_role_grants leaves an audit row:

permdock.config.ts
rls: {
  customRoles: true,
  audit: {
    function: "better_supabase.audit",
    tables: ["custom_role_permissions", "custom_role_includes"],
    args: { category: "permissions", tenant_column: "tenant_id", label_column: "role" },
  },
},

The arguments apply to every listed table, so user_roles, which has no tenant_id, needs a second call of your own or no tenant_column. Apply better-supabase's migrations before PermDock's, because the function must exist when the call runs.

When to use bucketPolicy and topicPolicy

bucketPolicy and topicPolicy decide in better-supabase's Storage and Realtime policies through the provider's helpers, for buckets and topics defined with defineBucket and defineTopic. PermDock's own rls.storage and rls.realtime write the policies on storage.objects and realtime.messages from permdock rls generate instead. Pick one per bucket or topic: both on the same one write two policies that Postgres combines with or.

Sessions and actors

  • better-supabase's checkSession(sql, auth) checks that a token's session is still in auth.sessions. Call it before a sensitive action and pass the result as liveSession (live sessions).
  • A support session and actingAs write act with kind: "support" or "impersonation", so the subject's actor has that kind and every call denies with no-delegation until the policy names the actor in a delegation (support and impersonation actors).
  • The scopes guard answers 403 insufficient_scope before PermDock runs; PermDock's delegation narrowing still applies after it.
  • Only allow: ["anonymous"] admits a signInAnonymously() user past a guard. Pair the default allow: ["user"] with anonymousSignIns: "deny" (anonymous sign-ins).

Testing

testAuthorizationProvider from better-supabase/testing checks a provider against the contract; tests/better-supabase/provider.test.ts runs it on the provider built from a fixture manifest. tests/integration/src/better-supabase-provider.test.ts applies the example's migrations to Postgres and checks that every requires entry exists with its grant and every template runs at every scope. tests/integration/src/better-supabase-assignments.test.ts runs the canAssign templates against PGlite for declared and custom roles. supabaseClaimFixtures.tenantPlans in permdock/testing is a token with features claims for subjectFromBetterSupabase.

Example app

apps/examples/next-better-supabase: better-supabase on Next.js 16.3 with Cache Components.

  • better-supabase.config.ts passes authorizationProvider the example's permdock.manifest.json and permissions.catalog.json.
  • src/lib/supabase/index.ts validates sessions with supabaseClaims().extend(appClaims), and src/lib/access.ts maps them with subjectFromBetterSupabase.
  • supabase/schemas/public/functions/feature_claims.sql adds public.feature_claims(user_id), which reads organization_features for the organizations permdock.member_organization_ids_for(user_id) returns. permdock.config.ts registers it as the features claim.
  • supabase/tests holds pgTAP tests over the seeded tenants, and tests/claims.test.ts maps every claim fixture through better-supabase's createServer.
  • Supabase: the claims, helpers and middleware this entry builds on.
  • Supabase token hook: the one hook and supabase.hook.claims.
  • RLS adapter: the SQL helper contract the provider's templates call.

Last updated on

On this page