better-supabase 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/bin/better-supabase.js +4 -0
- package/dist/{base64-CQeB1eSf.js → base64-D2gsioCY.js} +2 -2
- package/dist/channel-FQW-CnGR.d.ts +70 -0
- package/dist/claims-BAf6c7is.js +48 -0
- package/dist/cli/_format-Cax5EsM9.js +101 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +48 -0
- package/dist/cli/codemod-DqV7612m.js +388 -0
- package/dist/cli/command-CK14fP91.js +502 -0
- package/dist/cli/compare-BmIWVEui.js +9 -0
- package/dist/cli/config-CPVpXP5I.js +1965 -0
- package/dist/cli/config-dtoekM0v.js +2 -0
- package/dist/cli/db-DOBPt660.js +52 -0
- package/dist/cli/diff-SjkO5rNr.js +752 -0
- package/dist/cli/dist-DBPdlPYK.js +1112 -0
- package/dist/cli/dist-DreZ24Sz.js +129 -0
- package/dist/cli/dist-xn4wlboc.js +732 -0
- package/dist/cli/doctor-L7Z8mYTx.js +2863 -0
- package/dist/cli/env-DmKv_G4A.js +108 -0
- package/dist/cli/env-jIFUADLY.js +511 -0
- package/dist/cli/errors-4v4DTtda.js +56 -0
- package/dist/cli/fastest-levenshtein-CFN-lNDv.js +120 -0
- package/dist/cli/gen-BJc9vdyu.js +2 -0
- package/dist/cli/gen-C2osRcr5.js +2204 -0
- package/dist/cli/index.d.ts +493 -0
- package/dist/cli/index.js +184 -0
- package/dist/cli/init-Saf5-R9T.js +550 -0
- package/dist/cli/introspect--H7oGmcd.js +81 -0
- package/dist/cli/io-k41_z-jT.js +24 -0
- package/dist/cli/json5-ChRycquj.js +787 -0
- package/dist/cli/jsonc-BUniUpOk.js +2 -0
- package/dist/cli/jsonc-D5Fu67jw.js +45 -0
- package/dist/cli/keys-ByKHnvP9.js +83 -0
- package/dist/cli/ohash-D7bG_EOb.js +277 -0
- package/dist/cli/openapi-Cp5Fyta-.js +66 -0
- package/dist/cli/project-B_GeXyHh.js +70 -0
- package/dist/cli/prompts-BNcNfAve.js +1142 -0
- package/dist/cli/prompts-BTYQGunc.js +2 -0
- package/dist/cli/read-sets-BfMQcKy8.js +289 -0
- package/dist/cli/rolldown-runtime-BPOCksWG.js +24 -0
- package/dist/cli/run-BKR2W_79.js +351 -0
- package/dist/cli/seed-CUlqWh-8.js +98 -0
- package/dist/cli/skills-LJJIhLFR.js +137 -0
- package/dist/cli/sql-DzPbfngx.js +348 -0
- package/dist/cli/style-C0NFdJRL.js +29 -0
- package/dist/cli/toml-DB-MNnFs.js +424 -0
- package/dist/cli/version-BDJuO0UR.js +4 -0
- package/dist/cli/yaml-CYCbJ6et.js +2252 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +15 -22
- package/dist/clone-BfgmLU90.js +21 -0
- package/dist/{compiler-2zqGzxVZ.d.ts → compiler-BXH_aZ7N.d.ts} +2 -2
- package/dist/config/index.d.ts +4 -4
- package/dist/config/index.js +5 -5
- package/dist/{config-DOsGYSiT.js → config-DreERlKo.js} +1 -1
- package/dist/{config-C1CYcm6o.d.ts → config-T_ejwyd3.d.ts} +176 -11
- package/dist/{define-DUbydmJa.d.ts → define-Bp_BZa5h.d.ts} +37 -29
- package/dist/{define-DF341GDj.js → define-cNKzqwL_.js} +356 -89
- package/dist/edge/index.d.ts +20 -13
- package/dist/edge/index.js +55 -26
- package/dist/{entitlements-DvJXoI02.d.ts → entitlements-DUxU4oHe.d.ts} +2 -2
- package/dist/env/index.d.ts +2 -2
- package/dist/env/index.js +34 -1
- package/dist/{errors-Bl3rkOzo.js → errors-DtwXa6b8.js} +2 -1
- package/dist/events/index.d.ts +3 -2
- package/dist/events/index.js +69 -16
- package/dist/events-aIO7vsIk.d.ts +230 -0
- package/dist/executor-BunPOO-k.d.ts +402 -0
- package/dist/{executor-B26lgbHD.d.ts → executor-Cghdstwu.d.ts} +1 -2
- package/dist/{executor-C57jx2pn.js → executor-XycESQPH.js} +3 -3
- package/dist/hono/index.d.ts +27 -23
- package/dist/hono/index.js +46 -11
- package/dist/impersonation-VfaGc4Q_.js +41 -0
- package/dist/{index-BNsABq7P.d.ts → index-BGdgBPdf.d.ts} +66 -25
- package/dist/{index-qriRM9cZ.d.ts → index-C7f8cOBM.d.ts} +4 -4
- package/dist/{index-Dy6kuF0P.d.ts → index-CLg87OBW.d.ts} +6 -1
- package/dist/{index-GV77NpRP.d.ts → index-DOESKG27.d.ts} +34 -9
- package/dist/{index-CriFFJ_p.d.ts → index-DgILJRXj.d.ts} +43 -17
- package/dist/{index-UJ-LUxtI.d.ts → index-DmXSxbLi.d.ts} +14 -14
- package/dist/{index-Cf-D2jk1.d.ts → index-Dxn2a5cw.d.ts} +2 -2
- package/dist/{index-CTTMS6_1.d.ts → index-SaTsEfGP.d.ts} +86 -9
- package/dist/{index-CADlNHK-.d.ts → index-fag8VJZ2.d.ts} +5 -6
- package/dist/index.d.ts +34 -17
- package/dist/index.js +15 -14
- package/dist/jobs/index.d.ts +147 -95
- package/dist/jobs/index.js +773 -250
- package/dist/{json-schema--aOXAGfU.js → json-schema-m1fiwKgN.js} +23 -2
- package/dist/kit-events-CTFDfSwb.js +60 -0
- package/dist/kit-transport-B82k1djG.d.ts +33 -0
- package/dist/kit-transport-DJlHQUBU.js +55 -0
- package/dist/{config-Be4GwsqI.js → kits-CGLLrNPd.js} +38 -10
- package/dist/list/index.d.ts +1 -1
- package/dist/list/index.js +3 -3
- package/dist/{live-D8rV1Ldw.js → live-CdJBOc4f.js} +13 -9
- package/dist/{live-QE3UPbGm.d.ts → live-D9W2M5aO.d.ts} +16 -15
- package/dist/lookup-BaP5bI27.js +18 -0
- package/dist/mcp/index.d.ts +39 -22
- package/dist/mcp/index.js +39 -25
- package/dist/{mfa-x3V3vGh-.d.ts → mfa-CusDgPaD.d.ts} +5 -1
- package/dist/next/image/index.js +6 -3
- package/dist/next/index.d.ts +60 -36
- package/dist/next/index.js +167 -50
- package/dist/notifications/index.d.ts +162 -0
- package/dist/notifications/index.js +264 -0
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +6 -6
- package/dist/orgs/index.d.ts +132 -0
- package/dist/orgs/index.js +245 -0
- package/dist/orpc/index.d.ts +37 -20
- package/dist/orpc/index.js +24 -11
- package/dist/otel/index.d.ts +24 -5
- package/dist/otel/index.js +124 -64
- package/dist/permdock-sql-C2Inc6Xc.d.ts +29 -0
- package/dist/permdock-sql-DgOfeixi.js +51 -0
- package/dist/plugin-DwARMuQp.js +59 -0
- package/dist/{plugin-DVRB8qNE.d.ts → plugin-o144f322.d.ts} +17 -5
- package/dist/plugins/actor/index.d.ts +5 -2
- package/dist/plugins/actor/index.js +22 -8
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +52 -17
- package/dist/plugins/soft-delete/index.d.ts +3 -3
- package/dist/plugins/soft-delete/index.js +14 -5
- package/dist/plugins/tenant/index.d.ts +3 -3
- package/dist/plugins/tenant/index.js +45 -21
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +4 -3
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +26 -6
- package/dist/{pool-B9qsLyFR.d.ts → pool-C9voM4Kw.d.ts} +24 -6
- package/dist/postgres/index.d.ts +5 -5
- package/dist/postgres/index.js +20 -10
- package/dist/{postgrest-DmBlevh-.d.ts → postgrest-CruYKAvV.d.ts} +1 -1
- package/dist/{problem-CGt1ADix.js → problem-BW1KhzSG.js} +2 -1
- package/dist/{problem-Z2RTQjZD.d.ts → problem-Blxe8S31.d.ts} +1 -1
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +3 -3
- package/dist/{query-BdKLTPlj.js → query-CCbo44lG.js} +18 -18
- package/dist/queue-BYUCjr59.d.ts +252 -0
- package/dist/react/index.d.ts +8 -7
- package/dist/react/index.js +4 -3
- package/dist/react/server.d.ts +16 -5
- package/dist/react/server.js +12 -4
- package/dist/react/session.d.ts +10 -4
- package/dist/react/session.js +11 -3
- package/dist/{react-DNBYNazu.js → react-C_s7kiNz.js} +138 -46
- package/dist/{read-set-D0qBcboM.js → read-set-DQFt1N_Y.js} +71 -15
- package/dist/realtime/index.d.ts +3 -3
- package/dist/realtime/index.js +14 -12
- package/dist/{index-kpgopLNv.d.ts → refresh-BTrQ_ZTp.d.ts} +6 -3
- package/dist/{resolve-BRciX2jK.d.ts → resolve-C15TJq77.d.ts} +22 -11
- package/dist/{resource-D30rp0GN.js → resource-CRxxwkQq.js} +24 -9
- package/dist/{resource-CwVgiI5q.d.ts → resource-D8iqWMr-.d.ts} +6 -6
- package/dist/respond-D2OQl62-.d.ts +371 -0
- package/dist/{result-DWXatkd6.js → result-BKiliPY7.js} +2 -2
- package/dist/server/index.d.ts +17 -15
- package/dist/server/index.js +16 -22
- package/dist/server-Csx2sRjg.js +655 -0
- package/dist/{shared-_6-uPL7E.js → shared-D1dRUrAg.js} +19 -4
- package/dist/signers-Cc6r98SW.d.ts +162 -0
- package/dist/{simplify-LakqK1vH.js → simplify-_0oUKqCd.js} +27 -95
- package/dist/sql/index.d.ts +247 -10
- package/dist/sql/index.js +8591 -780
- package/dist/{sql-BIgk12ew.js → sql-Coh6n4cC.js} +11 -3
- package/dist/ssr/index.d.ts +4 -4
- package/dist/ssr/index.js +1 -2
- package/dist/{standard-FUi3Z3-7.js → standard-D7r6dMUu.js} +6 -3
- package/dist/{standard-eudBiRk1.d.ts → standard-J4YgMOLm.d.ts} +1 -1
- package/dist/{stats-D78UQ8Yu.js → stats-Bqob3WqT.js} +34 -9
- package/dist/storage/index.d.ts +4 -4
- package/dist/storage/index.js +174 -27
- package/dist/support-DT-OhSxD.d.ts +88 -0
- package/dist/support-Dk6vdmEw.js +291 -0
- package/dist/support-view-Da5peKFg.d.ts +17 -0
- package/dist/support-view-g4YLBeqX.js +18 -0
- package/dist/{tables-Dhha3BXM.js → tables-Cb-3_VWc.js} +14 -0
- package/dist/{permdock-sql-Bta_G0v-.js → template-TxrGI5jR.js} +2 -31
- package/dist/{temporal-BwshQk0W.js → temporal-CVlIV2DZ.js} +17 -4
- package/dist/{temporal-required-CMxeSVPo.js → temporal-required-CAt4Dc9Z.js} +2 -2
- package/dist/testing/index.d.ts +113 -24
- package/dist/testing/index.js +375 -47
- package/dist/{types-DONv9SHN.d.ts → types-Dnf_wRDz.d.ts} +26 -3
- package/dist/user-change-CHFEUro1.js +49 -0
- package/dist/{webhooks-CAIFQSTM.js → verify-Ce49ETPz.js} +38 -19
- package/dist/{version-BfpG2gks.js → version-DM_Roffb.js} +1 -1
- package/dist/{view-3Po_PVlr.d.ts → view-BEf0diC5.d.ts} +10 -5
- package/dist/{mfa-BxoR1RkN.js → view-Bq64ki8-.js} +210 -64
- package/dist/webhooks/index.d.ts +136 -5
- package/dist/webhooks/index.js +588 -2
- package/dist/wire-D2dT86vi.js +35 -0
- package/package.json +50 -10
- package/schemas/config-v1.json +131 -1
- package/schemas/doctor-report-v1.json +10 -0
- package/skills/better-supabase/SKILL.md +60 -10
- package/skills/better-supabase/references/plugins.md +13 -14
- package/skills/better-supabase/references/troubleshooting.md +25 -4
- package/skills/better-supabase-api/SKILL.md +74 -19
- package/skills/better-supabase-api/references/adapters.md +48 -27
- package/skills/better-supabase-auth/SKILL.md +149 -0
- package/skills/better-supabase-auth/references/permdock.md +100 -0
- package/skills/better-supabase-testing/SKILL.md +58 -4
- package/dist/claims-CoTobNC-.js +0 -25
- package/dist/events-Do8jXh4D.d.ts +0 -84
- package/dist/executor-DynmIWhq.d.ts +0 -195
- package/dist/invalidate-DbxuL2jL.js +0 -18
- package/dist/permdock-sql-DImJULY-.d.ts +0 -17
- package/dist/plugin-BqR6wKMB.js +0 -23
- package/dist/respond-C44EZAJo.js +0 -399
- package/dist/respond-DAkPIdTH.d.ts +0 -175
- package/dist/result-c2OYgCV2.d.ts +0 -203
- package/dist/view-wZ-9qIUz.js +0 -45
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: better-supabase-api
|
|
3
|
-
description: Build HTTP APIs, Edge Functions and MCP servers on Supabase with better-supabase adapters. Use when adding a route, REST resource, oRPC procedure, Edge Function, MCP tool, webhook, background job or idempotent endpoint.
|
|
3
|
+
description: Build HTTP APIs, Edge Functions and MCP servers on Supabase with better-supabase adapters. Use when adding a route, REST resource, oRPC procedure, Edge Function, MCP tool, OAuth or agent scopes, webhook, background job, cleanup cron or idempotent endpoint.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# APIs with better-supabase
|
|
7
7
|
|
|
8
8
|
Every adapter resolves the caller once and hands you `{ auth, db, supabase }`.
|
|
9
|
-
`auth.kind` is `user`, `anon`, `service` or `invalid`.
|
|
9
|
+
`auth.kind` is `user`, `anon`, `service` or `invalid`. For sessions, claims
|
|
10
|
+
and the OAuth client or agent behind a token, see the `better-supabase-auth`
|
|
11
|
+
skill.
|
|
10
12
|
|
|
11
13
|
## Workflow: add an endpoint
|
|
12
14
|
|
|
13
15
|
1. Pick the adapter for the runtime (table below). Setup code for each is in
|
|
14
16
|
[references/adapters.md](references/adapters.md).
|
|
15
|
-
2. Decide who may call it with `allow
|
|
17
|
+
2. Decide who may call it with `allow`, and which scopes a delegated token
|
|
18
|
+
needs with `scopes` (see below).
|
|
16
19
|
3. Use `db` from the handler context and return its `Result`, a plain value
|
|
17
20
|
or a `Response`.
|
|
18
21
|
4. For a table exposed as REST, prefer `bs.resource(...)` over hand-written
|
|
19
22
|
routes, and regenerate `openapi.json` with `better-supabase openapi emit`.
|
|
20
|
-
5. Add an API test that calls the endpoint as a user and as another tenant
|
|
21
|
-
|
|
23
|
+
5. Add an API test that calls the endpoint as a user and as another tenant,
|
|
24
|
+
and with a delegated token that lacks the scope (the
|
|
25
|
+
`better-supabase-testing` skill).
|
|
22
26
|
|
|
23
|
-
Done when the endpoint rejects callers outside `allow
|
|
24
|
-
Problem Details (no hand-built error JSON), and
|
|
25
|
-
if the project has an OpenAPI file.
|
|
27
|
+
Done when the endpoint rejects callers outside `allow` and tokens without its
|
|
28
|
+
`scopes`, errors come back as Problem Details (no hand-built error JSON), and
|
|
29
|
+
`openapi emit --check` passes if the project has an OpenAPI file.
|
|
26
30
|
|
|
27
31
|
## Who gets in
|
|
28
32
|
|
|
@@ -30,15 +34,36 @@ if the project has an OpenAPI file.
|
|
|
30
34
|
and `['service']` for machine callers. Rejected callers get a 401 with a
|
|
31
35
|
`WWW-Authenticate` header, or a 403, as Problem Details.
|
|
32
36
|
|
|
37
|
+
`scopes` limits what a delegated token may do: a token from the Supabase
|
|
38
|
+
OAuth server (`client_id` and `scope`) or one exchanged for an agent (an
|
|
39
|
+
RFC 8693 `act` chain). The guard options of `bs.route`, `bs.action`,
|
|
40
|
+
`bs.handler` (edge) and `bs.middleware` (Hono, oRPC) take it, next to
|
|
41
|
+
`allow`:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export const GET = bs.route(
|
|
45
|
+
(request, { db }) => db.customers.findMany({ limit: 20 }),
|
|
46
|
+
{ scopes: ["customers:read"] },
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
With `.claims(schema)` on the definition, `auth.claims` is typed in every
|
|
51
|
+
adapter (`c.var.auth`, `context.auth`, the edge handler's `auth`, MCP's
|
|
52
|
+
`ctx.auth`). Read roles from it; don't parse the claims again.
|
|
53
|
+
|
|
54
|
+
A token without the scope gets a 403 with an `insufficient_scope`
|
|
55
|
+
challenge. The user's own session holds no `client_id` or `act`, so `scopes`
|
|
56
|
+
never limits it. RLS still decides the rows: the token's `sub` is the user.
|
|
57
|
+
|
|
33
58
|
## Adapters
|
|
34
59
|
|
|
35
|
-
| Where | Setup
|
|
36
|
-
| -------------- |
|
|
37
|
-
| Next.js | `createNext(
|
|
38
|
-
| Hono | `createHono(
|
|
39
|
-
| oRPC | `createOrpc(
|
|
40
|
-
| Edge Functions | `createEdge(
|
|
41
|
-
| MCP | `createMcp(
|
|
60
|
+
| Where | Setup | Handler |
|
|
61
|
+
| -------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
62
|
+
| Next.js | `createNext(betterSupabase)` in `lib/supabase/server.ts` | `bs.route((req, { db }) => ...)`, `bs.action({ input: schema }, (input, { db }) => ...)` |
|
|
63
|
+
| Hono | `createHono(betterSupabase)`, `.use('/api/*', bs.middleware())` | `c.var.db`; `bs.resource('customers', {...})` for REST |
|
|
64
|
+
| oRPC | `createOrpc(betterSupabase)`, `base.use(bs.middleware())` | `bs.unwrap(context.db.customers.findMany(...))` |
|
|
65
|
+
| Edge Functions | `createEdge(betterSupabase, { cors: true })` | `Deno.serve(bs.handler((req, { db }) => ...))` |
|
|
66
|
+
| MCP | `createMcp(betterSupabase, { name, version, resources })` | `.tool({ name, input, run: (args, { db }) => ... })` |
|
|
42
67
|
|
|
43
68
|
Don't build error JSON by hand; errors become Problem Details.
|
|
44
69
|
|
|
@@ -46,17 +71,45 @@ Don't build error JSON by hand; errors become Problem Details.
|
|
|
46
71
|
|
|
47
72
|
`defineResource` / `bs.resource(table, { operations, list, input })` gives you
|
|
48
73
|
list, get, create, update and delete, with validation and paging (`{ items, page }`).
|
|
49
|
-
`createOpenApi(
|
|
74
|
+
`createOpenApi(betterSupabase, { resources })` describes the same routes, and
|
|
50
75
|
`better-supabase openapi emit --check` keeps `openapi.json` in sync.
|
|
51
76
|
|
|
77
|
+
Pass `pagination: "cursor"` to a resource, `defineListQuery` or an MCP table
|
|
78
|
+
for lists that clients read page by page: the list takes `after` instead of
|
|
79
|
+
`page`, returns `nextCursor` and `hasMore`, and `size` stays capped by
|
|
80
|
+
`maxPageSize`. The OpenAPI document and the JSON Schema describe the cursor.
|
|
81
|
+
|
|
82
|
+
## MCP servers
|
|
83
|
+
|
|
84
|
+
- Tools run as the calling user, so RLS applies to every call.
|
|
85
|
+
- `authorize(ctx, tool, args)` refuses a call (`{ allowed: false, reason, scopes }`; with `scopes` it is a 403 `insufficient_scope`). `visible(ctx, tool)` hides tools from `tools/list`. Put a permission or a label in a tool's `meta`; clients never see it.
|
|
86
|
+
- `scopes` on `createMcp` only advertises scopes. Refuse calls in `authorize`, reading `toSession(ctx.auth).delegation?.scopes`.
|
|
87
|
+
- Set `allowedOrigins` and `allowedHosts` (every host the server answers on, previews and local included) against DNS rebinding, and `resourceDocumentation` to a page that explains how to connect.
|
|
88
|
+
|
|
52
89
|
## Background work (`better-supabase/jobs`, needs `sql add jobs idempotency webhook-inbox`)
|
|
53
90
|
|
|
54
|
-
- `createJobs(postgres.admin, { queue_name: zodSchema })` runs on Supabase Queues (pgmq): `enqueue` in the request, then `work` or `drain` in a worker. The handler throws to retry. `schedule(name, cron, queue, payload)` uses pg_cron. Queue names are lowercase letters, digits and underscores. With a service-role Supabase client instead of SQL, it uses the `pgmq_public` RPCs (no dedupe or schedules).
|
|
91
|
+
- `createJobs(postgres.admin, { queue_name: zodSchema })` runs on Supabase Queues (pgmq): `enqueue` in the request, then `work` or `drain` in a worker. The handler throws to retry. `schedule(name, cron, queue, payload, { timeZone })` uses pg_cron, or with `kits.jobs.options.scheduler: "drain"` a `jobs.drainRoute({ secret: process.env.CRON_SECRET, handlers })` route that Vercel Cron calls; `kits.jobs.options.backend: "table"` runs without pgmq. Queue names are lowercase letters, digits and underscores. With a service-role Supabase client instead of SQL, it uses the `pgmq_public` RPCs (no dedupe or schedules).
|
|
55
92
|
- `createIdempotency(postgres.admin).handle(request, handler)` for POST endpoints that clients retry.
|
|
56
93
|
- `createInbox(postgres.admin, { source, secrets }).receive(request)` for webhooks; `process(handler)` later.
|
|
94
|
+
- Times are `Temporal.Instant`: `EnqueueOptions.runAt`, `Job.enqueuedAt`, `Job.visibleUntil`, `InboxMessage.receivedAt` and webhook timestamps.
|
|
95
|
+
- Schedule cleanup with pg_cron at a quiet hour: `better_supabase.purge_job_archive('<queue>')`, `purge_webhooks()`, `purge_audit_log()`, `purge_idempotency_keys()`, `purge_rate_limits()`, `purge_outbox()`, `purge_notifications()` and `purge_webhook_deliveries()`, for the modules you installed. Only `service_role` can execute them.
|
|
96
|
+
- Audit a table with `better_supabase.audit('public.t', redact => '{secret}', event_prefix => 't')`; record non-row events with `better_supabase.audit_event(event_type, ...)` and an `idempotency_key`. For per-tenant retention, write an `audit_retention(tenant)` SQL function or call `purgeAuditLog(sql, { retention })` from `better-supabase/jobs`.
|
|
97
|
+
- For a "view as user" support mode, add the `support-sessions` SQL module, pass `support: supportSessions({ store: sqlSupportStore(postgres) })` to `createServer`, call `bs.startSupport({ targetUserId, reason })` and `bs.stopSupport()` from server actions, and show a banner with `useSupportSession()`. Sessions are read-only by default; grant admins the `support.start` platform permission.
|
|
98
|
+
- For teams, add the `organizations` and `invitations` SQL modules and call them through `createOrgs({ transport: sqlTransport(postgres.asUser(claims)), events: betterSupabase.events, onInvite })` from `better-supabase/orgs`. Send the invitation email in `onInvite`, show `previewInvitation(token)` on the accept page, and switch with `orgs.switch(id)`, refreshing the session when it returns `refresh: true`. Errors carry codes such as `ORG_SLUG_TAKEN` in `hint`. Existing tables work with `mode: 'adopt'`.
|
|
99
|
+
- For user profiles, add the `profiles` SQL module: sign-up creates the row from auth metadata with a unique username and mirrors the email; users update only the `updatable` columns. Adopt an existing table with `mode: 'adopt'` and `syncTrigger: false` when your own trigger creates profiles. Use `avatarBucket()` and `orgLogoBucket()` from `better-supabase/storage` for uploads; `policy: { access: { read, write } }` checks any bucket through the access contract.
|
|
100
|
+
- For events other systems must see, add the `outbox` SQL module and write events with `emit_event(type, payload, subject, tenant, key)` inside the same transaction as the change (the kit modules emit theirs automatically). Relay them with `createOutbox(postgres.admin, { source }).relayRoute({ secret, consumers: { name: httpSink(url) } })` from `better-supabase/jobs` after `outbox.register(name, { types })`. Delivery is at least once; receivers deduplicate on the CloudEvent `id`.
|
|
101
|
+
- For in-app notifications, add the `notifications` SQL module and send with `createNotifications({ transport: sqlTransport(postgres.asUser(claims)), types })` from `better-supabase/notifications`, where `types` maps each notification type to a Standard Schema for its data. Pass a `key` so retries don't notify twice. Show them with `useNotifications({ topic, load })` from `better-supabase/react`; the topic defaults to `notifications:{userId}`. For email or push, pass `channels` and call `notifications.deliver()` from a cron route with a service connection.
|
|
102
|
+
- For webhooks to customers' endpoints, add the `webhooks-out` SQL module and publish with `createWebhooks({ transport: sqlTransport(postgres.admin) }).publish({ type, data, tenant, id })` from `better-supabase/webhooks`. Pass an `id` so retries don't send twice. Send with `webhooks.deliverRoute({ secret: process.env.CRON_SECRET })` from a cron route; deliveries are signed with Standard Webhooks and only go to public HTTPS URLs by default.
|
|
57
103
|
|
|
58
104
|
Done when a failing job is retried and then archived after `maxAttempts`,
|
|
59
|
-
|
|
105
|
+
a replayed webhook or POST doesn't run twice, and each kit table has a purge
|
|
106
|
+
schedule.
|
|
107
|
+
|
|
108
|
+
## Direct Postgres on the server
|
|
109
|
+
|
|
110
|
+
- `createPostgres()` opens a pool; `createPostgres({ pool })` runs on an existing `pg.Pool`. `asUser` and `anon` transactions get Supabase's role timeouts (8 s and 3 s); `statementTimeout` takes a number or `{ admin, authenticated, anon }`.
|
|
111
|
+
- `createMany` and `upsertMany` split inserts past 65,535 bind parameters into several statements in one transaction, so large batches don't need manual chunking.
|
|
112
|
+
- In serverless functions, connect through the transaction pooler (port 6543), not a direct connection.
|
|
60
113
|
|
|
61
114
|
## Testing
|
|
62
115
|
|
|
@@ -64,4 +117,6 @@ Sign tokens with `signLocalJwt` or `asUser` from `better-supabase/testing`;
|
|
|
64
117
|
the adapter verifies them against the local JWKS. See the `better-supabase-testing` skill.
|
|
65
118
|
|
|
66
119
|
Docs: https://bettersupabase.com/docs/frameworks/hono.md (and `next`,
|
|
67
|
-
`orpc`, `edge`, `mcp` under `/docs/frameworks/`)
|
|
120
|
+
`orpc`, `edge`, `mcp` under `/docs/frameworks/`),
|
|
121
|
+
https://bettersupabase.com/docs/kits/jobs.md and
|
|
122
|
+
https://bettersupabase.com/docs/auth/postgres.md.
|
|
@@ -1,57 +1,59 @@
|
|
|
1
1
|
# Adapter setup
|
|
2
2
|
|
|
3
|
-
Each adapter wraps the same `
|
|
3
|
+
Each adapter wraps the same `betterSupabase` from `src/lib/supabase/index.ts`:
|
|
4
4
|
|
|
5
|
-
```ts title="src/lib/supabase.ts"
|
|
5
|
+
```ts title="src/lib/supabase/index.ts"
|
|
6
6
|
import { defineSupabase } from "better-supabase";
|
|
7
7
|
|
|
8
|
-
import { schema } from "./
|
|
8
|
+
import { schema } from "./generated";
|
|
9
9
|
|
|
10
|
-
export type { Functions, Models } from "./
|
|
10
|
+
export type { Functions, Models } from "./generated";
|
|
11
11
|
|
|
12
|
-
export const
|
|
12
|
+
export const betterSupabase = defineSupabase(schema);
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## Next.js
|
|
16
16
|
|
|
17
|
-
```ts title="src/lib/supabase
|
|
17
|
+
```ts title="src/lib/supabase/server.ts"
|
|
18
|
+
import "server-only";
|
|
18
19
|
import { createNext } from "better-supabase/next";
|
|
19
20
|
|
|
20
|
-
import {
|
|
21
|
+
import { betterSupabase } from "./index";
|
|
21
22
|
|
|
22
|
-
export const
|
|
23
|
+
export const bs = createNext(betterSupabase);
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
```ts title="src/proxy.ts"
|
|
26
27
|
import type { NextRequest } from "next/server";
|
|
27
28
|
|
|
28
|
-
import {
|
|
29
|
+
import { bs } from "./lib/supabase/server";
|
|
29
30
|
|
|
30
|
-
export const proxy = (request: NextRequest) =>
|
|
31
|
+
export const proxy = (request: NextRequest) => bs.proxy(request);
|
|
31
32
|
```
|
|
32
33
|
|
|
33
34
|
The proxy is the only place that refreshes sessions. Server Components,
|
|
34
35
|
route handlers and actions read the verified token:
|
|
35
36
|
|
|
36
37
|
```ts
|
|
37
|
-
const { db } = await
|
|
38
|
-
export const GET =
|
|
39
|
-
db.customers.findMany({ limit: 20 }),
|
|
38
|
+
const { db } = await bs.context();
|
|
39
|
+
export const GET = bs.route(
|
|
40
|
+
(request, { db }) => db.customers.findMany({ limit: 20 }),
|
|
41
|
+
{ scopes: ["customers:read"] }, // only limits OAuth clients and agents
|
|
40
42
|
);
|
|
41
43
|
```
|
|
42
44
|
|
|
43
45
|
## Hono
|
|
44
46
|
|
|
45
47
|
```ts title="src/server.ts"
|
|
46
|
-
import {
|
|
47
|
-
import { Hono } from "hono";
|
|
48
|
+
import { createHono } from "better-supabase/hono";
|
|
48
49
|
|
|
49
|
-
import {
|
|
50
|
+
import { betterSupabase } from "./lib/supabase";
|
|
50
51
|
|
|
51
|
-
const bs = createHono(
|
|
52
|
+
const bs = createHono(betterSupabase);
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
// bs.app() is new Hono<typeof bs.Env>() with bs.onError installed.
|
|
55
|
+
const app = bs
|
|
56
|
+
.app()
|
|
55
57
|
.use("/api/*", bs.middleware())
|
|
56
58
|
.get("/api/me", (c) => c.json({ kind: c.var.auth.kind }))
|
|
57
59
|
.route(
|
|
@@ -68,9 +70,9 @@ export default app;
|
|
|
68
70
|
import { os } from "@orpc/server";
|
|
69
71
|
import { createOrpc, type OrpcRequestContext } from "better-supabase/orpc";
|
|
70
72
|
|
|
71
|
-
import {
|
|
73
|
+
import { betterSupabase } from "./lib/supabase";
|
|
72
74
|
|
|
73
|
-
export const bs = createOrpc(
|
|
75
|
+
export const bs = createOrpc(betterSupabase);
|
|
74
76
|
const authed = os.$context<OrpcRequestContext>().use(bs.middleware());
|
|
75
77
|
|
|
76
78
|
export const router = {
|
|
@@ -85,9 +87,9 @@ export const router = {
|
|
|
85
87
|
```ts title="supabase/functions/api/index.ts"
|
|
86
88
|
import { createEdge } from "better-supabase/edge";
|
|
87
89
|
|
|
88
|
-
import {
|
|
90
|
+
import { betterSupabase } from "../_shared/supabase.ts";
|
|
89
91
|
|
|
90
|
-
const bs = createEdge(
|
|
92
|
+
const bs = createEdge(betterSupabase, { cors: true });
|
|
91
93
|
|
|
92
94
|
Deno.serve(
|
|
93
95
|
bs.resources({ customers: { select: ["id", "name"] } }, { basePath: "/api" }),
|
|
@@ -98,16 +100,35 @@ Deno.serve(
|
|
|
98
100
|
|
|
99
101
|
```ts title="supabase/functions/mcp/index.ts"
|
|
100
102
|
import { createMcp } from "better-supabase/mcp";
|
|
103
|
+
import { toSession } from "better-supabase/server";
|
|
101
104
|
|
|
102
|
-
import {
|
|
105
|
+
import { betterSupabase } from "../_shared/supabase.ts";
|
|
103
106
|
|
|
104
|
-
const
|
|
107
|
+
const bs = createMcp(betterSupabase, {
|
|
105
108
|
name: "crm",
|
|
106
109
|
version: "0.1.0",
|
|
107
|
-
|
|
110
|
+
advertisedScopes: ["openid", "crm.read"],
|
|
111
|
+
allowedOrigins: ["https://claude.ai"],
|
|
112
|
+
allowedHosts: ["crm.example.com", "localhost"],
|
|
113
|
+
resourceDocumentation: "https://crm.example.com/docs/mcp",
|
|
114
|
+
resources: {
|
|
115
|
+
customers: { select: ["id", "name"], pagination: "cursor" },
|
|
116
|
+
},
|
|
117
|
+
authorize: (ctx) => {
|
|
118
|
+
const session = toSession(ctx.auth);
|
|
119
|
+
const granted =
|
|
120
|
+
session.kind === "user" ? session.delegation?.scopes : undefined;
|
|
121
|
+
return !granted || granted.includes("crm.read")
|
|
122
|
+
? { allowed: true }
|
|
123
|
+
: { allowed: false, reason: "Needs crm.read", scopes: ["crm.read"] };
|
|
124
|
+
},
|
|
108
125
|
});
|
|
109
126
|
|
|
110
|
-
Deno.serve(
|
|
127
|
+
Deno.serve(bs.fetch);
|
|
111
128
|
```
|
|
112
129
|
|
|
113
130
|
Tools run as the calling user, so RLS applies to every tool call.
|
|
131
|
+
`advertisedScopes` only advertises scopes; `requiredScopes` refuses delegated
|
|
132
|
+
tokens that lack one, and `authorize` refuses a single call. The session comes
|
|
133
|
+
from the `Authorization` header only, never from a cookie. A
|
|
134
|
+
`delegation` that is unset means the user's own token, which no scope limits.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: better-supabase-auth
|
|
3
|
+
description: Read and check the caller in better-supabase apps, including sessions, typed claims, user profiles, OAuth clients and agents acting for a user, scopes, ended sessions and PermDock. Use when code reads auth, session or claims, when adding a custom access token hook, an OAuth or agent integration, sign-out, account deletion or role changes, or when the project has a permdock.config.ts.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Auth with better-supabase
|
|
7
|
+
|
|
8
|
+
Every adapter resolves the caller once. It verifies the access token locally
|
|
9
|
+
against the project's JWKS, so a valid token costs no network call, and only
|
|
10
|
+
the Next.js proxy (`bs.proxy`) refreshes sessions. Don't call
|
|
11
|
+
`supabase.auth.getUser()` per request, and don't read cookies yourself.
|
|
12
|
+
|
|
13
|
+
## Workflow: read the caller
|
|
14
|
+
|
|
15
|
+
1. Take `auth` from the handler context (`ctx.auth`, `c.var.auth`,
|
|
16
|
+
`context.auth`), or a token-free `AuthSession` from `await bs.session()`
|
|
17
|
+
or `toSession(ctx.auth)`.
|
|
18
|
+
2. Branch on `kind` with an exhaustive `switch` that ends in a `never` check:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
switch (auth.kind) {
|
|
22
|
+
case "user": // auth.user.id, auth.claims
|
|
23
|
+
break;
|
|
24
|
+
case "service": // a secret key
|
|
25
|
+
break;
|
|
26
|
+
case "anon": // auth.reason: 'none' | 'expired' | 'signed_out' | 'refresh_failed'
|
|
27
|
+
break;
|
|
28
|
+
case "invalid": // auth.reason: 'token' | 'claims' | 'actor', answer 401
|
|
29
|
+
break;
|
|
30
|
+
default: {
|
|
31
|
+
const unreachable: never = auth;
|
|
32
|
+
return unreachable;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
3. Prefer the adapter's `allow` option (`['user']` by default) over checking
|
|
38
|
+
`kind` by hand; it answers 401 or 403 as Problem Details.
|
|
39
|
+
|
|
40
|
+
Done when every `invalid` reason, `actor` included, ends in a 401 and no code
|
|
41
|
+
path treats `invalid` as `anon`.
|
|
42
|
+
|
|
43
|
+
## Workflow: type the claims
|
|
44
|
+
|
|
45
|
+
1. Describe the claims your access token hook adds with any Standard Schema
|
|
46
|
+
and pass it to `betterSupabase.claims(schema)`. Use a loose object (`v.looseObject`,
|
|
47
|
+
`z.looseObject`) so claims the schema doesn't list stay on the session.
|
|
48
|
+
Make anything the hook doesn't always set optional or give it a default.
|
|
49
|
+
2. For display data users edit themselves (name, avatar), use
|
|
50
|
+
`betterSupabase.userMetadata(schema)` and read `session.profile`.
|
|
51
|
+
3. Read roles, memberships, the tenant and entitlements from the verified
|
|
52
|
+
claims only. Never from `user_metadata`, `session.profile`, a URL or a
|
|
53
|
+
request body.
|
|
54
|
+
|
|
55
|
+
Done when `session.claims` is typed without casts, and a token whose claims
|
|
56
|
+
fail the schema gets a 401 with code `CLAIMS_INVALID`.
|
|
57
|
+
|
|
58
|
+
## Bearer callers: OAuth clients and agents
|
|
59
|
+
|
|
60
|
+
A token from the Supabase OAuth server carries `client_id` and `scope`; a
|
|
61
|
+
token exchanged for an agent carries an RFC 8693 `act` chain. The session
|
|
62
|
+
names who acts for the user:
|
|
63
|
+
|
|
64
|
+
| Token | `session.actor` | `session.delegation` |
|
|
65
|
+
| ----------------------------------------------- | ------------------------------------ | -------------------- |
|
|
66
|
+
| The user's own | not set | not set |
|
|
67
|
+
| `client_id: "c1"`, `scope: "openid crm.read"` | `{ id: "c1", kind: "oauth-client" }` | `{ scopes: [...] }` |
|
|
68
|
+
| `act: { sub: "agent", act: { sub: "mcp-42" } }` | `{ id: "agent", chain, ... }` | `{ scopes, chain }` |
|
|
69
|
+
|
|
70
|
+
- Limit what delegated tokens may do with the `scopes` guard option on
|
|
71
|
+
`bs.route`, `bs.action`, `bs.handler` and `bs.middleware`. A missing
|
|
72
|
+
scope answers 403 with an `insufficient_scope` challenge. The user's own
|
|
73
|
+
token is never limited by scopes.
|
|
74
|
+
- In MCP servers, refuse calls in `authorize` by reading
|
|
75
|
+
`toSession(ctx.auth).delegation?.scopes` (the `better-supabase-api` skill).
|
|
76
|
+
- RLS still decides the rows: `sub` is the user, so a client sees at most
|
|
77
|
+
what the user sees. Record `session.actor` (and `chain`) in audit data when
|
|
78
|
+
a change was made by a client or an agent.
|
|
79
|
+
|
|
80
|
+
## Workflow: irreversible actions
|
|
81
|
+
|
|
82
|
+
Tokens stay valid until they expire (an hour by default), even after the user
|
|
83
|
+
signs out everywhere or an admin ends the session. Before an action you can't
|
|
84
|
+
undo (deleting the account, transferring ownership, rotating keys), check
|
|
85
|
+
that the session still exists:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { err } from "better-supabase";
|
|
89
|
+
import { checkSession } from "better-supabase/server";
|
|
90
|
+
|
|
91
|
+
const ended = await checkSession(postgres.admin, auth);
|
|
92
|
+
if (ended) return err(ended); // unauthorized, code SESSION_REVOKED
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
It needs a connection that can read `auth.sessions` (`createPostgres().admin`).
|
|
96
|
+
Nothing calls it by default; keep it to the actions that need it.
|
|
97
|
+
|
|
98
|
+
Done when the irreversible action returns `SESSION_REVOKED` for a signed-out
|
|
99
|
+
session in a test.
|
|
100
|
+
|
|
101
|
+
## When claims change
|
|
102
|
+
|
|
103
|
+
A token carries the claims from when Auth issued it, so a revoked role or
|
|
104
|
+
membership lasts until the next refresh. When that window matters:
|
|
105
|
+
|
|
106
|
+
- Write policies that read the source of truth, for example
|
|
107
|
+
`organization_id in (select better_supabase.member_org_ids())`, instead of
|
|
108
|
+
the claim.
|
|
109
|
+
- For signed-out, banned or deleted users, add the `sessions` SQL module and a
|
|
110
|
+
restrictive policy with `using ((select better_supabase.session_active()))`
|
|
111
|
+
on the tables that guard sensitive data.
|
|
112
|
+
- After a change the user made themselves (joining an organization), call
|
|
113
|
+
`supabase.auth.refreshSession()` in the browser.
|
|
114
|
+
- In Next.js, drop the user's cached views with
|
|
115
|
+
`bs.invalidateSession(userId, { tags })`.
|
|
116
|
+
|
|
117
|
+
## Next.js caching
|
|
118
|
+
|
|
119
|
+
- Per-user data goes through `bs.cached()` inside your own
|
|
120
|
+
`'use cache: private'` function. It tags the entry `bs:session:<user id>`
|
|
121
|
+
and caps its lifetime at the token's.
|
|
122
|
+
- `bs.cached({ tags, life: { stale } })` adds tags and caps the stale time
|
|
123
|
+
further, for example for a PermDock snapshot.
|
|
124
|
+
- Keep layouts synchronous: pass the `bs.session()` promise to
|
|
125
|
+
`<SessionProvider>` and read it with `useSession()`.
|
|
126
|
+
|
|
127
|
+
## PermDock
|
|
128
|
+
|
|
129
|
+
When the project has a `permdock.config.ts`, PermDock owns the access token
|
|
130
|
+
hook, roles, memberships and the tenant claim. Read
|
|
131
|
+
[references/permdock.md](references/permdock.md) before touching the hook,
|
|
132
|
+
claims, tenant or entitlements.
|
|
133
|
+
|
|
134
|
+
## Don't
|
|
135
|
+
|
|
136
|
+
- Don't refresh sessions outside the proxy, and don't verify tokens by
|
|
137
|
+
calling the Auth server.
|
|
138
|
+
- Don't use `bs.admin()` or the service role for a user's request.
|
|
139
|
+
- Don't downgrade an `invalid` caller to `anon`.
|
|
140
|
+
- Don't build 401 or 403 JSON by hand; return the `DbError` or use `allow`
|
|
141
|
+
and `scopes`.
|
|
142
|
+
|
|
143
|
+
## Docs
|
|
144
|
+
|
|
145
|
+
https://bettersupabase.com/docs/auth.md,
|
|
146
|
+
https://bettersupabase.com/docs/frameworks/next.md (bearer callers),
|
|
147
|
+
https://bettersupabase.com/docs/auth/account-deletion.md,
|
|
148
|
+
https://bettersupabase.com/docs/frameworks/next-cache-components.md and
|
|
149
|
+
https://bettersupabase.com/docs/auth/permdock.md.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# better-supabase next to PermDock
|
|
2
|
+
|
|
3
|
+
PermDock handles authorization and better-supabase handles tokens and data.
|
|
4
|
+
They share one access token hook and one claim contract, and neither imports
|
|
5
|
+
the other.
|
|
6
|
+
|
|
7
|
+
## Who owns what
|
|
8
|
+
|
|
9
|
+
| Area | better-supabase | PermDock |
|
|
10
|
+
| -------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
11
|
+
| Access token hook | none | `permdock supabase hook generate`, the only hook |
|
|
12
|
+
| Claims | `features`, through `supabase.hook.claims` | `user_role`, `roles`, `memberships`, the tenant claim, `attrs`, `authz_ver` |
|
|
13
|
+
| SQL helpers | the SQL kit (`better_supabase.*`) | `permdock rls generate`: `permdock_has`, `permitted_<scope>_ids`, `member_<scope>_ids` |
|
|
14
|
+
| Storage and Realtime | `defineBucket`, `defineTopic` with a `permdock` policy mode | the helpers those policies call |
|
|
15
|
+
| MCP | `createMcp` with `authorize` and `visible` | the permissions in each tool's `meta` |
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
- Don't run `better-supabase sql add tenant` (it stops without `--force`),
|
|
20
|
+
don't call `membership_claims()` from a hook, and don't write `roles`,
|
|
21
|
+
`user_role`, `memberships` or the tenant claim yourself. Doctor reports
|
|
22
|
+
BS407 when a hook does.
|
|
23
|
+
- `sql add entitlements` is fine: `features` is not a PermDock claim. Add
|
|
24
|
+
`claims: { features: 'better_supabase.feature_claims' }` to `supabase.hook`
|
|
25
|
+
in `permdock.config.ts`, run `permdock supabase hook generate`, and
|
|
26
|
+
`hasEntitlement(session, ...)` works.
|
|
27
|
+
- Run `permdock supabase inspect --out` before `sql add entitlements`. With
|
|
28
|
+
`permdock.manifest.json` present, the module reads PermDock's
|
|
29
|
+
`member_<scope>_ids` helpers and doesn't add `tenant`. The scope is the
|
|
30
|
+
manifest's root scope (the `rls.scopes` entry without `within`); set
|
|
31
|
+
`entitlements.permdock: { scope }` in `better-supabase.config.ts` for
|
|
32
|
+
another one.
|
|
33
|
+
- Name the same tenant claim on both sides. A non-default `claims.tenant` in
|
|
34
|
+
`better-supabase.config.ts` must also be PermDock's `rls.tenantClaim` and
|
|
35
|
+
go to `subjectFromSupabase` or `subjectFromSupabaseSession` as `{ tenant }`.
|
|
36
|
+
- Validate claims with PermDock's schema: `betterSupabase.claims(supabaseClaims().extend(appClaims))`.
|
|
37
|
+
- The helpers live in PermDock's `rls.schema` (`permdock` by default). Buckets
|
|
38
|
+
and topics in `permdock` mode call them in `permdock` unless their `schema`
|
|
39
|
+
option names another schema; it must equal `rls.schema`.
|
|
40
|
+
- Use the scope names the manifest declares (`organization`, not PermDock's
|
|
41
|
+
`tenant` alias): the helpers are named after them, and doctor reports a
|
|
42
|
+
key checked at another scope than its catalog entry.
|
|
43
|
+
- The entitlements module needs PermDock's hook to fill `claims.features`
|
|
44
|
+
from `better_supabase.feature_claims`
|
|
45
|
+
(`supabase.hook.claims: { features: 'better_supabase.feature_claims' }`).
|
|
46
|
+
- The `permdock` policy mode checks role and scope only. Use it only for
|
|
47
|
+
permissions whose `rowConditions` is `false` in `permissions.catalog.json`.
|
|
48
|
+
A missing flag or a key the catalog doesn't list is unknown and refused;
|
|
49
|
+
regenerate the catalog with a current `permdock catalog`.
|
|
50
|
+
Leave permissions with row conditions to the policies
|
|
51
|
+
`permdock rls generate` writes; doctor reports BS214 and `permdock doctor`
|
|
52
|
+
PD037 otherwise.
|
|
53
|
+
- `permdock` mode compares ids as text, so a path or topic segment must be
|
|
54
|
+
the id's lowercase form.
|
|
55
|
+
- In MCP servers, narrow `tool.meta` with PermDock's `isPermission`, answer
|
|
56
|
+
`visible` with `mayUse` and `authorize` with `can`. A tool without a
|
|
57
|
+
PermDock permission in `meta` is denied: `visible` returns `false` and
|
|
58
|
+
`authorize` returns `{ allowed: false }`. Use `decide` instead of `can` to
|
|
59
|
+
put the denial reason in the refusal. `can` decides without a row, so check
|
|
60
|
+
row-conditioned permissions inside `run` or rely on RLS.
|
|
61
|
+
- In oRPC and Hono, pass the adapter's auth state to PermDock:
|
|
62
|
+
`createPermDock(policy, { subject: ({ context }) => subjectFromSupabaseSession(context.auth) })`
|
|
63
|
+
from `permdock/orpc`, or `(c) => subjectFromSupabaseSession(c.get("auth"))`
|
|
64
|
+
from `permdock/hono`. Fail closed: export one builder that takes a
|
|
65
|
+
permission (`(permission) => authed.use(pd.protect(permission))`, or the
|
|
66
|
+
route helper `[bs.middleware(), pd.protect(permission)]` in Hono), and don't
|
|
67
|
+
install `bs.middleware()` app-wide, so a route without a permission has no
|
|
68
|
+
`db`.
|
|
69
|
+
- In Next.js, cache the permission snapshot in a `'use cache: private'`
|
|
70
|
+
loader. The user id and the snapshot only exist after `bs.cached()`
|
|
71
|
+
returns, so pass it the static tags only, then tag and time the entry from
|
|
72
|
+
inside the loader with Next.js's `cacheTag` and `cacheLife`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { cacheLife, cacheTag } from "next/cache";
|
|
76
|
+
import { snapshotFor } from "permdock";
|
|
77
|
+
import { cacheLifeFor, snapshotTag } from "permdock/next";
|
|
78
|
+
import { subjectFromSupabaseSession } from "permdock/supabase";
|
|
79
|
+
|
|
80
|
+
export async function loadSnapshot(orgId: string) {
|
|
81
|
+
"use cache: private";
|
|
82
|
+
const { session } = await bs.cached({ tags: [`org:${orgId}`] });
|
|
83
|
+
const snapshot = snapshotFor(policy, subjectFromSupabaseSession(session), {
|
|
84
|
+
tenant: orgId,
|
|
85
|
+
});
|
|
86
|
+
if (session.kind === "user") cacheTag(snapshotTag(session.user.id));
|
|
87
|
+
cacheLife(cacheLifeFor(snapshot));
|
|
88
|
+
return snapshot;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`bs.cached()` already caps the stale time at the token's expiry, and Next.js
|
|
93
|
+
keeps the smaller `stale` of the two `cacheLife` calls. When a role or plan
|
|
94
|
+
changes, drop the user's entries and the snapshot with
|
|
95
|
+
`bs.invalidateSession(userId, { tags: [snapshotTag(userId)] })`.
|
|
96
|
+
|
|
97
|
+
- Doctor BS405 measures `memberships` plus `attrs` against PermDock's 1 KB
|
|
98
|
+
budget and the whole token against 2 KB; run `doctor --as <user id>`.
|
|
99
|
+
|
|
100
|
+
Docs: https://bettersupabase.com/docs/auth/permdock.md
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: better-supabase-testing
|
|
3
|
-
description: Test Supabase RLS policies, APIs and SQL against the local stack with better-supabase/testing, typed seeds and pgTAP. Use when writing or fixing tests that touch the database, policies or
|
|
3
|
+
description: Test Supabase RLS policies, APIs and SQL against the local stack with better-supabase/testing, typed seeds and pgTAP. Use when writing or fixing tests that touch the database, policies, auth, OAuth or agent scopes, or Temporal time values.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Testing with better-supabase
|
|
@@ -31,9 +31,9 @@ again once you restore it.
|
|
|
31
31
|
|
|
32
32
|
```ts
|
|
33
33
|
import { defineSeed } from "better-supabase/testing";
|
|
34
|
-
import {
|
|
34
|
+
import { betterSupabase } from "../src/lib/supabase/index.ts";
|
|
35
35
|
|
|
36
|
-
export const seed = defineSeed(
|
|
36
|
+
export const seed = defineSeed(betterSupabase, {
|
|
37
37
|
organizations: { acme: { id: ACME, name: "Acme" } },
|
|
38
38
|
customers: { first: { id: FIRST, organizationId: ACME, name: "First" } },
|
|
39
39
|
});
|
|
@@ -45,7 +45,11 @@ tests import the same rows (`seed.rows.customers.first.id`).
|
|
|
45
45
|
## RLS tests
|
|
46
46
|
|
|
47
47
|
```ts
|
|
48
|
-
const alice = await asUser(
|
|
48
|
+
const alice = await asUser(
|
|
49
|
+
betterSupabase,
|
|
50
|
+
{ sub: aliceId, tenant_id: ACME },
|
|
51
|
+
{ postgres },
|
|
52
|
+
);
|
|
49
53
|
expect(await alice.db.customers.count().orThrow()).toBe(1);
|
|
50
54
|
expect(await alice.db.customers.findById(OTHER_ORG_CUSTOMER)).toMatchObject({
|
|
51
55
|
ok: false,
|
|
@@ -61,6 +65,56 @@ expect(await alice.db.customers.findById(OTHER_ORG_CUSTOMER)).toMatchObject({
|
|
|
61
65
|
Send `authorization: Bearer ${alice.token}` (or a token from `signLocalJwt`).
|
|
62
66
|
The adapter verifies it against the local JWKS; no test-only resolver is needed.
|
|
63
67
|
|
|
68
|
+
For a route with `scopes`, sign a delegated token the way the Supabase OAuth
|
|
69
|
+
server or an agent token exchange would, and assert both sides:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const client = await signLocalJwt({
|
|
73
|
+
sub: aliceId,
|
|
74
|
+
tenant_id: ACME,
|
|
75
|
+
client_id: "c1",
|
|
76
|
+
scope: "openid", // add "customers:read" to pass
|
|
77
|
+
});
|
|
78
|
+
const response = await app.request("/api/customers", {
|
|
79
|
+
headers: { authorization: `Bearer ${client}` },
|
|
80
|
+
});
|
|
81
|
+
expect(response.status).toBe(403);
|
|
82
|
+
expect(response.headers.get("www-authenticate")).toContain(
|
|
83
|
+
'error="insufficient_scope"',
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The user's own token (no `client_id`, no `act`) must still pass, and a
|
|
88
|
+
malformed `act` claim (`act: { sub: "" }`) must get a 401.
|
|
89
|
+
|
|
90
|
+
## Time values
|
|
91
|
+
|
|
92
|
+
With `codecs.timestamptz: 'instant'`, rows hold `Temporal` values. Import
|
|
93
|
+
`temporal-polyfill/global` in the test setup on Node 24, and compare with
|
|
94
|
+
`.equals()` or an equality tester, because `toEqual` treats any two instants
|
|
95
|
+
as equal:
|
|
96
|
+
|
|
97
|
+
```ts title="tests/setup.ts"
|
|
98
|
+
import "temporal-polyfill/global";
|
|
99
|
+
import { expect } from "vitest";
|
|
100
|
+
|
|
101
|
+
expect.addEqualityTesters([
|
|
102
|
+
(a, b) =>
|
|
103
|
+
a instanceof Temporal.Instant && b instanceof Temporal.Instant
|
|
104
|
+
? a.equals(b)
|
|
105
|
+
: undefined,
|
|
106
|
+
]);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
With the polyfill, `vi.useFakeTimers({ now })` drives `Temporal.Now` too.
|
|
110
|
+
|
|
111
|
+
## Doctor in CI
|
|
112
|
+
|
|
113
|
+
Run `pnpm better-supabase doctor` against the reset stack and fail the job on
|
|
114
|
+
a nonzero exit. `--json` prints the report with each finding's code,
|
|
115
|
+
`--format github` annotates the pull request, and
|
|
116
|
+
`--format sarif --out doctor.sarif` feeds code scanning.
|
|
117
|
+
|
|
64
118
|
## pgTAP
|
|
65
119
|
|
|
66
120
|
`better-supabase sql add pgtap` installs `tests.create_user`,
|
package/dist/claims-CoTobNC-.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
//#region src/core/claims.ts
|
|
2
|
-
const DEFAULT_CLAIMS = {
|
|
3
|
-
tenant: "tenant_id",
|
|
4
|
-
scope: "tenant",
|
|
5
|
-
features: "features"
|
|
6
|
-
};
|
|
7
|
-
/** The configured claim names, with defaults for the ones codegen left out. */
|
|
8
|
-
function claimsOf(meta) {
|
|
9
|
-
return {
|
|
10
|
-
...DEFAULT_CLAIMS,
|
|
11
|
-
...meta?.claims
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
/** Where the active tenant is read from: the top-level claim, then `app_metadata`. */
|
|
15
|
-
function tenantClaimPaths(tenant = DEFAULT_CLAIMS.tenant) {
|
|
16
|
-
return [tenant, `app_metadata.${tenant}`];
|
|
17
|
-
}
|
|
18
|
-
/** The string at a dotted claim path, if any. */
|
|
19
|
-
function claimAt(claims, path) {
|
|
20
|
-
let value = claims;
|
|
21
|
-
for (const segment of path.split(".")) value = typeof value === "object" && value !== null ? value[segment] : void 0;
|
|
22
|
-
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
23
|
-
}
|
|
24
|
-
//#endregion
|
|
25
|
-
export { tenantClaimPaths as i, claimAt as n, claimsOf as r, DEFAULT_CLAIMS as t };
|