better-supabase 0.3.0 → 0.4.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 +7 -6
- package/bin/better-supabase.js +4 -0
- package/dist/{base64-CQeB1eSf.js → base64-D2gsioCY.js} +2 -2
- 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/command-CK14fP91.js +502 -0
- package/dist/cli/compare-BmIWVEui.js +9 -0
- package/dist/cli/config-D9R0o1LH.js +1895 -0
- package/dist/cli/config-SSKSTUl2.js +2 -0
- package/dist/cli/db-DOBPt660.js +52 -0
- package/dist/cli/dist-B1MvLv-M.js +129 -0
- package/dist/cli/dist-CFgGknmZ.js +1112 -0
- package/dist/cli/dist-Du7VnEUG.js +621 -0
- package/dist/cli/doctor-BOym3mbL.js +2382 -0
- package/dist/cli/env-BZ0uy8XK.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-Ccn_p90L.js +2008 -0
- package/dist/cli/gen-D5cLCULx.js +2 -0
- package/dist/cli/index.d.ts +493 -0
- package/dist/cli/index.js +184 -0
- package/dist/cli/init-ClnxRYBF.js +550 -0
- package/dist/cli/introspect-DxqfkVIv.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-8WHjxiTJ.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-CMa29t9a.js +971 -0
- package/dist/cli/rolldown-runtime-BPOCksWG.js +24 -0
- package/dist/cli/run-CP20LKk_.js +347 -0
- package/dist/cli/seed-5J6eu_Xg.js +98 -0
- package/dist/cli/skills-LJJIhLFR.js +137 -0
- package/dist/cli/sql-D4VbqmXi.js +199 -0
- package/dist/cli/style-C0NFdJRL.js +29 -0
- package/dist/cli/toml-DB-MNnFs.js +424 -0
- package/dist/cli/version-5mW_8sY1.js +4 -0
- package/dist/cli/yaml-CYCbJ6et.js +2252 -0
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +13 -13
- package/dist/{compiler-2zqGzxVZ.d.ts → compiler-DB3aSFOA.d.ts} +2 -2
- package/dist/config/index.js +4 -4
- package/dist/{config-Be4GwsqI.js → config-BpxDfnbg.js} +1 -1
- package/dist/{config-DOsGYSiT.js → config-CbvFmGD8.js} +1 -1
- package/dist/{define-DF341GDj.js → define-Bh0AqTik.js} +160 -79
- package/dist/{define-DUbydmJa.d.ts → define-CRS2MRV3.d.ts} +32 -28
- package/dist/edge/index.d.ts +8 -12
- package/dist/edge/index.js +50 -24
- package/dist/{entitlements-DvJXoI02.d.ts → entitlements-Dm-FHpNH.d.ts} +2 -2
- package/dist/{errors-Bl3rkOzo.js → errors-L0xK41y_.js} +1 -1
- package/dist/events/index.d.ts +1 -1
- package/dist/events/index.js +4 -4
- package/dist/{events-Do8jXh4D.d.ts → events-B21UOhvP.d.ts} +4 -4
- package/dist/{executor-DynmIWhq.d.ts → executor-B5IoUxLE.d.ts} +1 -1
- package/dist/{executor-C57jx2pn.js → executor-CVt6dD0B.js} +3 -3
- package/dist/{executor-B26lgbHD.d.ts → executor-CZ7ImRiD.d.ts} +2 -2
- package/dist/hono/index.d.ts +16 -23
- package/dist/hono/index.js +10 -10
- package/dist/{index-CTTMS6_1.d.ts → index-528Ugmyp.d.ts} +12 -3
- package/dist/{index-CADlNHK-.d.ts → index-BAy4tsAP.d.ts} +5 -5
- package/dist/{index-BNsABq7P.d.ts → index-BBdvo-5d.d.ts} +23 -25
- package/dist/{index-qriRM9cZ.d.ts → index-BhHEC04r.d.ts} +3 -3
- package/dist/{index-GV77NpRP.d.ts → index-D5IfUIDh.d.ts} +3 -3
- package/dist/{index-CriFFJ_p.d.ts → index-DFmOpBGC.d.ts} +11 -11
- package/dist/{index-UJ-LUxtI.d.ts → index-RlLXd8dL.d.ts} +12 -12
- package/dist/index.d.ts +15 -15
- package/dist/index.js +13 -13
- package/dist/{invalidate-DbxuL2jL.js → invalidate-CgxmRamI.js} +7 -1
- package/dist/jobs/index.d.ts +10 -4
- package/dist/jobs/index.js +32 -16
- package/dist/{json-schema--aOXAGfU.js → json-schema-DP6V7cbr.js} +1 -1
- package/dist/list/index.d.ts +1 -1
- package/dist/list/index.js +3 -3
- package/dist/{live-QE3UPbGm.d.ts → live-BHakSWWo.d.ts} +12 -12
- package/dist/{live-D8rV1Ldw.js → live-CdJBOc4f.js} +13 -9
- package/dist/lookup-BaP5bI27.js +18 -0
- package/dist/mcp/index.d.ts +18 -18
- package/dist/mcp/index.js +23 -18
- package/dist/{mfa-x3V3vGh-.d.ts → mfa-C7MNQ1Ob.d.ts} +1 -1
- package/dist/{mfa-BxoR1RkN.js → mfa-byfjruR9.js} +118 -29
- package/dist/next/image/index.js +6 -3
- package/dist/next/index.d.ts +38 -35
- package/dist/next/index.js +41 -33
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +6 -6
- package/dist/orpc/index.d.ts +17 -20
- package/dist/orpc/index.js +11 -11
- package/dist/otel/index.d.ts +5 -5
- package/dist/otel/index.js +81 -62
- package/dist/{permdock-sql-Bta_G0v-.js → permdock-sql-C4KFzLc9.js} +1 -1
- package/dist/{plugin-DVRB8qNE.d.ts → plugin-bNhvJwRh.d.ts} +2 -2
- package/dist/plugins/actor/index.d.ts +1 -1
- package/dist/plugins/actor/index.js +1 -1
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +42 -12
- package/dist/plugins/soft-delete/index.d.ts +2 -2
- package/dist/plugins/soft-delete/index.js +2 -2
- package/dist/plugins/tenant/index.d.ts +2 -2
- package/dist/plugins/tenant/index.js +10 -4
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +1 -1
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +3 -3
- package/dist/{pool-B9qsLyFR.d.ts → pool-DFwEQwmN.d.ts} +11 -4
- package/dist/postgres/index.d.ts +5 -5
- package/dist/postgres/index.js +3 -2
- package/dist/{postgrest-DmBlevh-.d.ts → postgrest-Bc-0Xw62.d.ts} +1 -1
- package/dist/{problem-CGt1ADix.js → problem-BihjPR0t.js} +1 -1
- package/dist/{problem-Z2RTQjZD.d.ts → problem-U8UEKccv.d.ts} +1 -1
- package/dist/query/index.d.ts +2 -2
- package/dist/query/index.js +2 -2
- package/dist/{query-BdKLTPlj.js → query-BMMSWbI7.js} +18 -18
- package/dist/react/index.d.ts +6 -6
- package/dist/react/index.js +1 -1
- package/dist/react/server.d.ts +5 -5
- package/dist/react/server.js +3 -3
- package/dist/react/session.d.ts +4 -4
- package/dist/react/session.js +2 -2
- package/dist/{react-DNBYNazu.js → react-CQHMaESR.js} +39 -39
- package/dist/{read-set-D0qBcboM.js → read-set-FWK8qQ58.js} +50 -15
- package/dist/realtime/index.d.ts +1 -1
- package/dist/realtime/index.js +13 -12
- package/dist/{index-kpgopLNv.d.ts → refresh-BwYkyNOQ.d.ts} +6 -3
- package/dist/{resolve-BRciX2jK.d.ts → resolve-BAqQcwMW.d.ts} +15 -10
- package/dist/{resource-CwVgiI5q.d.ts → resource-BJcaIfxi.d.ts} +5 -5
- package/dist/{resource-D30rp0GN.js → resource-My_y-Syh.js} +6 -6
- package/dist/{respond-DAkPIdTH.d.ts → respond-DgIbdrt8.d.ts} +31 -12
- package/dist/{result-DWXatkd6.js → result-Bj6yt33D.js} +2 -2
- package/dist/{result-c2OYgCV2.d.ts → result-CqEsbgYs.d.ts} +3 -3
- package/dist/server/index.d.ts +14 -14
- package/dist/server/index.js +12 -12
- package/dist/{respond-C44EZAJo.js → server-BA75B0mT.js} +139 -100
- package/dist/{shared-_6-uPL7E.js → shared-DnoCdMDr.js} +4 -3
- package/dist/{simplify-LakqK1vH.js → simplify-Cqvfr9dj.js} +67 -24
- package/dist/sql/index.d.ts +1 -1
- package/dist/sql/index.js +7 -7
- package/dist/{sql-BIgk12ew.js → sql-B34HU2DI.js} +3 -2
- package/dist/ssr/index.d.ts +4 -4
- package/dist/ssr/index.js +2 -2
- package/dist/{standard-FUi3Z3-7.js → standard-BRyBGTtp.js} +2 -2
- package/dist/{standard-eudBiRk1.d.ts → standard-BsaCYQJc.d.ts} +1 -1
- package/dist/{stats-D78UQ8Yu.js → stats-C_YxA2r1.js} +33 -8
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +26 -8
- package/dist/{tables-Dhha3BXM.js → tables-Cb-3_VWc.js} +14 -0
- package/dist/{temporal-required-CMxeSVPo.js → temporal-required-CBNHT6ni.js} +1 -1
- package/dist/testing/index.d.ts +20 -21
- package/dist/testing/index.js +45 -45
- package/dist/{version-BfpG2gks.js → version-Bmf7_FzM.js} +1 -1
- package/dist/{view-3Po_PVlr.d.ts → view-B64o81wp.d.ts} +5 -5
- package/dist/{view-wZ-9qIUz.js → view-BrJI2LKp.js} +1 -1
- package/dist/webhooks/index.d.ts +3 -3
- package/dist/webhooks/index.js +1 -1
- package/dist/{webhooks-CAIFQSTM.js → webhooks-Bh_Jf_-l.js} +37 -18
- package/package.json +36 -4
- package/skills/better-supabase/SKILL.md +57 -10
- package/skills/better-supabase/references/plugins.md +7 -11
- package/skills/better-supabase/references/troubleshooting.md +24 -4
- package/skills/better-supabase-api/SKILL.md +66 -18
- package/skills/better-supabase-api/references/adapters.md +44 -26
- package/skills/better-supabase-auth/SKILL.md +146 -0
- package/skills/better-supabase-auth/references/permdock.md +55 -0
- package/skills/better-supabase-testing/SKILL.md +58 -4
- package/dist/claims-CoTobNC-.js +0 -25
|
@@ -1,56 +1,57 @@
|
|
|
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
18
|
import { createNext } from "better-supabase/next";
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import { betterSupabase } from "./index";
|
|
21
21
|
|
|
22
|
-
export const
|
|
22
|
+
export const bs = createNext(betterSupabase);
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
```ts title="src/proxy.ts"
|
|
26
26
|
import type { NextRequest } from "next/server";
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { bs } from "./lib/supabase/server";
|
|
29
29
|
|
|
30
|
-
export const proxy = (request: NextRequest) =>
|
|
30
|
+
export const proxy = (request: NextRequest) => bs.proxy(request);
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
The proxy is the only place that refreshes sessions. Server Components,
|
|
34
34
|
route handlers and actions read the verified token:
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
|
-
const { db } = await
|
|
38
|
-
export const GET =
|
|
39
|
-
db.customers.findMany({ limit: 20 }),
|
|
37
|
+
const { db } = await bs.context();
|
|
38
|
+
export const GET = bs.route(
|
|
39
|
+
(request, { db }) => db.customers.findMany({ limit: 20 }),
|
|
40
|
+
{ scopes: ["customers:read"] }, // only limits OAuth clients and agents
|
|
40
41
|
);
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
## Hono
|
|
44
45
|
|
|
45
46
|
```ts title="src/server.ts"
|
|
46
|
-
import { type
|
|
47
|
+
import { type HonoEnv, createHono } from "better-supabase/hono";
|
|
47
48
|
import { Hono } from "hono";
|
|
48
49
|
|
|
49
|
-
import { type Functions, type Models,
|
|
50
|
+
import { type Functions, type Models, betterSupabase } from "./lib/supabase";
|
|
50
51
|
|
|
51
|
-
const bs = createHono(
|
|
52
|
+
const bs = createHono(betterSupabase);
|
|
52
53
|
|
|
53
|
-
const app = new Hono<
|
|
54
|
+
const app = new Hono<HonoEnv<Models, Functions, unknown>>()
|
|
54
55
|
.onError(bs.onError)
|
|
55
56
|
.use("/api/*", bs.middleware())
|
|
56
57
|
.get("/api/me", (c) => c.json({ kind: c.var.auth.kind }))
|
|
@@ -68,9 +69,9 @@ export default app;
|
|
|
68
69
|
import { os } from "@orpc/server";
|
|
69
70
|
import { createOrpc, type OrpcRequestContext } from "better-supabase/orpc";
|
|
70
71
|
|
|
71
|
-
import {
|
|
72
|
+
import { betterSupabase } from "./lib/supabase";
|
|
72
73
|
|
|
73
|
-
export const bs = createOrpc(
|
|
74
|
+
export const bs = createOrpc(betterSupabase);
|
|
74
75
|
const authed = os.$context<OrpcRequestContext>().use(bs.middleware());
|
|
75
76
|
|
|
76
77
|
export const router = {
|
|
@@ -85,9 +86,9 @@ export const router = {
|
|
|
85
86
|
```ts title="supabase/functions/api/index.ts"
|
|
86
87
|
import { createEdge } from "better-supabase/edge";
|
|
87
88
|
|
|
88
|
-
import {
|
|
89
|
+
import { betterSupabase } from "../_shared/supabase.ts";
|
|
89
90
|
|
|
90
|
-
const bs = createEdge(
|
|
91
|
+
const bs = createEdge(betterSupabase, { cors: true });
|
|
91
92
|
|
|
92
93
|
Deno.serve(
|
|
93
94
|
bs.resources({ customers: { select: ["id", "name"] } }, { basePath: "/api" }),
|
|
@@ -98,16 +99,33 @@ Deno.serve(
|
|
|
98
99
|
|
|
99
100
|
```ts title="supabase/functions/mcp/index.ts"
|
|
100
101
|
import { createMcp } from "better-supabase/mcp";
|
|
102
|
+
import { toSession } from "better-supabase/server";
|
|
101
103
|
|
|
102
|
-
import {
|
|
104
|
+
import { betterSupabase } from "../_shared/supabase.ts";
|
|
103
105
|
|
|
104
|
-
const
|
|
106
|
+
const bs = createMcp(betterSupabase, {
|
|
105
107
|
name: "crm",
|
|
106
108
|
version: "0.1.0",
|
|
107
|
-
|
|
109
|
+
scopes: ["openid", "crm.read"],
|
|
110
|
+
allowedOrigins: ["https://claude.ai"],
|
|
111
|
+
allowedHosts: ["crm.example.com", "localhost"],
|
|
112
|
+
resourceDocumentation: "https://crm.example.com/docs/mcp",
|
|
113
|
+
resources: {
|
|
114
|
+
customers: { select: ["id", "name"], pagination: "cursor" },
|
|
115
|
+
},
|
|
116
|
+
authorize: (ctx) => {
|
|
117
|
+
const session = toSession(ctx.auth);
|
|
118
|
+
const granted =
|
|
119
|
+
session.kind === "user" ? session.delegation?.scopes : undefined;
|
|
120
|
+
return !granted || granted.includes("crm.read")
|
|
121
|
+
? { allowed: true }
|
|
122
|
+
: { allowed: false, reason: "Needs crm.read", scopes: ["crm.read"] };
|
|
123
|
+
},
|
|
108
124
|
});
|
|
109
125
|
|
|
110
|
-
Deno.serve(
|
|
126
|
+
Deno.serve(bs.fetch);
|
|
111
127
|
```
|
|
112
128
|
|
|
113
|
-
Tools run as the calling user, so RLS applies to every tool call.
|
|
129
|
+
Tools run as the calling user, so RLS applies to every tool call. `scopes`
|
|
130
|
+
on `createMcp` only advertises scopes; `authorize` refuses the call. A
|
|
131
|
+
`delegation` that is unset means the user's own token, which no scope limits.
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
- After a change the user made themselves (joining an organization), call
|
|
110
|
+
`supabase.auth.refreshSession()` in the browser.
|
|
111
|
+
- In Next.js, drop the user's cached views with
|
|
112
|
+
`bs.invalidateSession(userId, { tags })`.
|
|
113
|
+
|
|
114
|
+
## Next.js caching
|
|
115
|
+
|
|
116
|
+
- Per-user data goes through `bs.cached()` inside your own
|
|
117
|
+
`'use cache: private'` function. It tags the entry `bs:session:<user id>`
|
|
118
|
+
and caps its lifetime at the token's.
|
|
119
|
+
- `bs.cached({ tags, life: { stale } })` adds tags and caps the stale time
|
|
120
|
+
further, for example for a PermDock snapshot.
|
|
121
|
+
- Keep layouts synchronous: pass the `bs.session()` promise to
|
|
122
|
+
`<SessionProvider>` and read it with `useSession()`.
|
|
123
|
+
|
|
124
|
+
## PermDock
|
|
125
|
+
|
|
126
|
+
When the project has a `permdock.config.ts`, PermDock owns the access token
|
|
127
|
+
hook, roles, memberships and the tenant claim. Read
|
|
128
|
+
[references/permdock.md](references/permdock.md) before touching the hook,
|
|
129
|
+
claims, tenant or entitlements.
|
|
130
|
+
|
|
131
|
+
## Don't
|
|
132
|
+
|
|
133
|
+
- Don't refresh sessions outside the proxy, and don't verify tokens by
|
|
134
|
+
calling the Auth server.
|
|
135
|
+
- Don't use `bs.admin()` or the service role for a user's request.
|
|
136
|
+
- Don't downgrade an `invalid` caller to `anon`.
|
|
137
|
+
- Don't build 401 or 403 JSON by hand; return the `DbError` or use `allow`
|
|
138
|
+
and `scopes`.
|
|
139
|
+
|
|
140
|
+
## Docs
|
|
141
|
+
|
|
142
|
+
https://bettersupabase.com/docs/auth.md,
|
|
143
|
+
https://bettersupabase.com/docs/frameworks/next.md (bearer callers),
|
|
144
|
+
https://bettersupabase.com/docs/auth/account-deletion.md,
|
|
145
|
+
https://bettersupabase.com/docs/frameworks/next-cache-components.md and
|
|
146
|
+
https://bettersupabase.com/docs/auth/permdock.md.
|
|
@@ -0,0 +1,55 @@
|
|
|
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`. Set
|
|
30
|
+
`entitlements.permdock: { scope }` in `better-supabase.config.ts` for a
|
|
31
|
+
scope other than `organization`.
|
|
32
|
+
- Name the same tenant claim on both sides. A non-default `claims.tenant` in
|
|
33
|
+
`better-supabase.config.ts` must also be PermDock's `rls.tenantClaim` and
|
|
34
|
+
go to `subjectFromSupabase` or `subjectFromSupabaseSession` as `{ tenant }`.
|
|
35
|
+
- Validate claims with PermDock's schema: `betterSupabase.claims(supabaseClaims().extend(appClaims))`.
|
|
36
|
+
- The helpers live in PermDock's `rls.schema`, which must equal the `schema`
|
|
37
|
+
option of buckets and topics in `permdock` mode.
|
|
38
|
+
- The `permdock` policy mode checks role and scope only. Use it only for
|
|
39
|
+
permissions whose `rowConditions` is `false` in `permissions.catalog.json`.
|
|
40
|
+
Leave permissions with row conditions to the policies
|
|
41
|
+
`permdock rls generate` writes; doctor reports BS214 and `permdock doctor`
|
|
42
|
+
PD037 otherwise.
|
|
43
|
+
- `permdock` mode compares ids as text, so a path or topic segment must be
|
|
44
|
+
the id's lowercase form.
|
|
45
|
+
- In MCP servers, narrow `tool.meta` with PermDock's `isPermission`, answer
|
|
46
|
+
`visible` with `mayUse` and `authorize` with `can`. `can` decides without
|
|
47
|
+
a row, so check row-conditioned permissions inside `run` or rely on RLS.
|
|
48
|
+
- In Next.js, cache the permission snapshot with
|
|
49
|
+
`bs.cached({ tags: [snapshotTag(userId)], life: cacheLifeFor(snapshot) })`
|
|
50
|
+
and drop it with `bs.invalidateSession(userId, { tags: [snapshotTag(userId)] })`
|
|
51
|
+
when a role or plan changes.
|
|
52
|
+
- Doctor BS405 measures `memberships` plus `attrs` against PermDock's 1 KB
|
|
53
|
+
budget and the whole token against 2 KB; run `doctor --as <user id>`.
|
|
54
|
+
|
|
55
|
+
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 };
|