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.
Files changed (172) hide show
  1. package/README.md +7 -6
  2. package/bin/better-supabase.js +4 -0
  3. package/dist/{base64-CQeB1eSf.js → base64-D2gsioCY.js} +2 -2
  4. package/dist/claims-BAf6c7is.js +48 -0
  5. package/dist/cli/_format-Cax5EsM9.js +101 -0
  6. package/dist/cli/bin.d.ts +1 -0
  7. package/dist/cli/bin.js +48 -0
  8. package/dist/cli/command-CK14fP91.js +502 -0
  9. package/dist/cli/compare-BmIWVEui.js +9 -0
  10. package/dist/cli/config-D9R0o1LH.js +1895 -0
  11. package/dist/cli/config-SSKSTUl2.js +2 -0
  12. package/dist/cli/db-DOBPt660.js +52 -0
  13. package/dist/cli/dist-B1MvLv-M.js +129 -0
  14. package/dist/cli/dist-CFgGknmZ.js +1112 -0
  15. package/dist/cli/dist-Du7VnEUG.js +621 -0
  16. package/dist/cli/doctor-BOym3mbL.js +2382 -0
  17. package/dist/cli/env-BZ0uy8XK.js +108 -0
  18. package/dist/cli/env-jIFUADLY.js +511 -0
  19. package/dist/cli/errors-4v4DTtda.js +56 -0
  20. package/dist/cli/fastest-levenshtein-CFN-lNDv.js +120 -0
  21. package/dist/cli/gen-Ccn_p90L.js +2008 -0
  22. package/dist/cli/gen-D5cLCULx.js +2 -0
  23. package/dist/cli/index.d.ts +493 -0
  24. package/dist/cli/index.js +184 -0
  25. package/dist/cli/init-ClnxRYBF.js +550 -0
  26. package/dist/cli/introspect-DxqfkVIv.js +81 -0
  27. package/dist/cli/io-k41_z-jT.js +24 -0
  28. package/dist/cli/json5-ChRycquj.js +787 -0
  29. package/dist/cli/jsonc-BUniUpOk.js +2 -0
  30. package/dist/cli/jsonc-D5Fu67jw.js +45 -0
  31. package/dist/cli/keys-ByKHnvP9.js +83 -0
  32. package/dist/cli/ohash-D7bG_EOb.js +277 -0
  33. package/dist/cli/openapi-8WHjxiTJ.js +66 -0
  34. package/dist/cli/project-B_GeXyHh.js +70 -0
  35. package/dist/cli/prompts-BNcNfAve.js +1142 -0
  36. package/dist/cli/prompts-BTYQGunc.js +2 -0
  37. package/dist/cli/read-sets-CMa29t9a.js +971 -0
  38. package/dist/cli/rolldown-runtime-BPOCksWG.js +24 -0
  39. package/dist/cli/run-CP20LKk_.js +347 -0
  40. package/dist/cli/seed-5J6eu_Xg.js +98 -0
  41. package/dist/cli/skills-LJJIhLFR.js +137 -0
  42. package/dist/cli/sql-D4VbqmXi.js +199 -0
  43. package/dist/cli/style-C0NFdJRL.js +29 -0
  44. package/dist/cli/toml-DB-MNnFs.js +424 -0
  45. package/dist/cli/version-5mW_8sY1.js +4 -0
  46. package/dist/cli/yaml-CYCbJ6et.js +2252 -0
  47. package/dist/client/index.d.ts +2 -2
  48. package/dist/client/index.js +13 -13
  49. package/dist/{compiler-2zqGzxVZ.d.ts → compiler-DB3aSFOA.d.ts} +2 -2
  50. package/dist/config/index.js +4 -4
  51. package/dist/{config-Be4GwsqI.js → config-BpxDfnbg.js} +1 -1
  52. package/dist/{config-DOsGYSiT.js → config-CbvFmGD8.js} +1 -1
  53. package/dist/{define-DF341GDj.js → define-Bh0AqTik.js} +160 -79
  54. package/dist/{define-DUbydmJa.d.ts → define-CRS2MRV3.d.ts} +32 -28
  55. package/dist/edge/index.d.ts +8 -12
  56. package/dist/edge/index.js +50 -24
  57. package/dist/{entitlements-DvJXoI02.d.ts → entitlements-Dm-FHpNH.d.ts} +2 -2
  58. package/dist/{errors-Bl3rkOzo.js → errors-L0xK41y_.js} +1 -1
  59. package/dist/events/index.d.ts +1 -1
  60. package/dist/events/index.js +4 -4
  61. package/dist/{events-Do8jXh4D.d.ts → events-B21UOhvP.d.ts} +4 -4
  62. package/dist/{executor-DynmIWhq.d.ts → executor-B5IoUxLE.d.ts} +1 -1
  63. package/dist/{executor-C57jx2pn.js → executor-CVt6dD0B.js} +3 -3
  64. package/dist/{executor-B26lgbHD.d.ts → executor-CZ7ImRiD.d.ts} +2 -2
  65. package/dist/hono/index.d.ts +16 -23
  66. package/dist/hono/index.js +10 -10
  67. package/dist/{index-CTTMS6_1.d.ts → index-528Ugmyp.d.ts} +12 -3
  68. package/dist/{index-CADlNHK-.d.ts → index-BAy4tsAP.d.ts} +5 -5
  69. package/dist/{index-BNsABq7P.d.ts → index-BBdvo-5d.d.ts} +23 -25
  70. package/dist/{index-qriRM9cZ.d.ts → index-BhHEC04r.d.ts} +3 -3
  71. package/dist/{index-GV77NpRP.d.ts → index-D5IfUIDh.d.ts} +3 -3
  72. package/dist/{index-CriFFJ_p.d.ts → index-DFmOpBGC.d.ts} +11 -11
  73. package/dist/{index-UJ-LUxtI.d.ts → index-RlLXd8dL.d.ts} +12 -12
  74. package/dist/index.d.ts +15 -15
  75. package/dist/index.js +13 -13
  76. package/dist/{invalidate-DbxuL2jL.js → invalidate-CgxmRamI.js} +7 -1
  77. package/dist/jobs/index.d.ts +10 -4
  78. package/dist/jobs/index.js +32 -16
  79. package/dist/{json-schema--aOXAGfU.js → json-schema-DP6V7cbr.js} +1 -1
  80. package/dist/list/index.d.ts +1 -1
  81. package/dist/list/index.js +3 -3
  82. package/dist/{live-QE3UPbGm.d.ts → live-BHakSWWo.d.ts} +12 -12
  83. package/dist/{live-D8rV1Ldw.js → live-CdJBOc4f.js} +13 -9
  84. package/dist/lookup-BaP5bI27.js +18 -0
  85. package/dist/mcp/index.d.ts +18 -18
  86. package/dist/mcp/index.js +23 -18
  87. package/dist/{mfa-x3V3vGh-.d.ts → mfa-C7MNQ1Ob.d.ts} +1 -1
  88. package/dist/{mfa-BxoR1RkN.js → mfa-byfjruR9.js} +118 -29
  89. package/dist/next/image/index.js +6 -3
  90. package/dist/next/index.d.ts +38 -35
  91. package/dist/next/index.js +41 -33
  92. package/dist/openapi/index.d.ts +1 -1
  93. package/dist/openapi/index.js +6 -6
  94. package/dist/orpc/index.d.ts +17 -20
  95. package/dist/orpc/index.js +11 -11
  96. package/dist/otel/index.d.ts +5 -5
  97. package/dist/otel/index.js +81 -62
  98. package/dist/{permdock-sql-Bta_G0v-.js → permdock-sql-C4KFzLc9.js} +1 -1
  99. package/dist/{plugin-DVRB8qNE.d.ts → plugin-bNhvJwRh.d.ts} +2 -2
  100. package/dist/plugins/actor/index.d.ts +1 -1
  101. package/dist/plugins/actor/index.js +1 -1
  102. package/dist/plugins/rules/index.d.ts +2 -2
  103. package/dist/plugins/rules/index.js +42 -12
  104. package/dist/plugins/soft-delete/index.d.ts +2 -2
  105. package/dist/plugins/soft-delete/index.js +2 -2
  106. package/dist/plugins/tenant/index.d.ts +2 -2
  107. package/dist/plugins/tenant/index.js +10 -4
  108. package/dist/plugins/timestamps/index.d.ts +1 -1
  109. package/dist/plugins/timestamps/index.js +1 -1
  110. package/dist/plugins/validation/index.d.ts +2 -2
  111. package/dist/plugins/validation/index.js +3 -3
  112. package/dist/{pool-B9qsLyFR.d.ts → pool-DFwEQwmN.d.ts} +11 -4
  113. package/dist/postgres/index.d.ts +5 -5
  114. package/dist/postgres/index.js +3 -2
  115. package/dist/{postgrest-DmBlevh-.d.ts → postgrest-Bc-0Xw62.d.ts} +1 -1
  116. package/dist/{problem-CGt1ADix.js → problem-BihjPR0t.js} +1 -1
  117. package/dist/{problem-Z2RTQjZD.d.ts → problem-U8UEKccv.d.ts} +1 -1
  118. package/dist/query/index.d.ts +2 -2
  119. package/dist/query/index.js +2 -2
  120. package/dist/{query-BdKLTPlj.js → query-BMMSWbI7.js} +18 -18
  121. package/dist/react/index.d.ts +6 -6
  122. package/dist/react/index.js +1 -1
  123. package/dist/react/server.d.ts +5 -5
  124. package/dist/react/server.js +3 -3
  125. package/dist/react/session.d.ts +4 -4
  126. package/dist/react/session.js +2 -2
  127. package/dist/{react-DNBYNazu.js → react-CQHMaESR.js} +39 -39
  128. package/dist/{read-set-D0qBcboM.js → read-set-FWK8qQ58.js} +50 -15
  129. package/dist/realtime/index.d.ts +1 -1
  130. package/dist/realtime/index.js +13 -12
  131. package/dist/{index-kpgopLNv.d.ts → refresh-BwYkyNOQ.d.ts} +6 -3
  132. package/dist/{resolve-BRciX2jK.d.ts → resolve-BAqQcwMW.d.ts} +15 -10
  133. package/dist/{resource-CwVgiI5q.d.ts → resource-BJcaIfxi.d.ts} +5 -5
  134. package/dist/{resource-D30rp0GN.js → resource-My_y-Syh.js} +6 -6
  135. package/dist/{respond-DAkPIdTH.d.ts → respond-DgIbdrt8.d.ts} +31 -12
  136. package/dist/{result-DWXatkd6.js → result-Bj6yt33D.js} +2 -2
  137. package/dist/{result-c2OYgCV2.d.ts → result-CqEsbgYs.d.ts} +3 -3
  138. package/dist/server/index.d.ts +14 -14
  139. package/dist/server/index.js +12 -12
  140. package/dist/{respond-C44EZAJo.js → server-BA75B0mT.js} +139 -100
  141. package/dist/{shared-_6-uPL7E.js → shared-DnoCdMDr.js} +4 -3
  142. package/dist/{simplify-LakqK1vH.js → simplify-Cqvfr9dj.js} +67 -24
  143. package/dist/sql/index.d.ts +1 -1
  144. package/dist/sql/index.js +7 -7
  145. package/dist/{sql-BIgk12ew.js → sql-B34HU2DI.js} +3 -2
  146. package/dist/ssr/index.d.ts +4 -4
  147. package/dist/ssr/index.js +2 -2
  148. package/dist/{standard-FUi3Z3-7.js → standard-BRyBGTtp.js} +2 -2
  149. package/dist/{standard-eudBiRk1.d.ts → standard-BsaCYQJc.d.ts} +1 -1
  150. package/dist/{stats-D78UQ8Yu.js → stats-C_YxA2r1.js} +33 -8
  151. package/dist/storage/index.d.ts +2 -2
  152. package/dist/storage/index.js +26 -8
  153. package/dist/{tables-Dhha3BXM.js → tables-Cb-3_VWc.js} +14 -0
  154. package/dist/{temporal-required-CMxeSVPo.js → temporal-required-CBNHT6ni.js} +1 -1
  155. package/dist/testing/index.d.ts +20 -21
  156. package/dist/testing/index.js +45 -45
  157. package/dist/{version-BfpG2gks.js → version-Bmf7_FzM.js} +1 -1
  158. package/dist/{view-3Po_PVlr.d.ts → view-B64o81wp.d.ts} +5 -5
  159. package/dist/{view-wZ-9qIUz.js → view-BrJI2LKp.js} +1 -1
  160. package/dist/webhooks/index.d.ts +3 -3
  161. package/dist/webhooks/index.js +1 -1
  162. package/dist/{webhooks-CAIFQSTM.js → webhooks-Bh_Jf_-l.js} +37 -18
  163. package/package.json +36 -4
  164. package/skills/better-supabase/SKILL.md +57 -10
  165. package/skills/better-supabase/references/plugins.md +7 -11
  166. package/skills/better-supabase/references/troubleshooting.md +24 -4
  167. package/skills/better-supabase-api/SKILL.md +66 -18
  168. package/skills/better-supabase-api/references/adapters.md +44 -26
  169. package/skills/better-supabase-auth/SKILL.md +146 -0
  170. package/skills/better-supabase-auth/references/permdock.md +55 -0
  171. package/skills/better-supabase-testing/SKILL.md +58 -4
  172. package/dist/claims-CoTobNC-.js +0 -25
@@ -1,56 +1,57 @@
1
1
  # Adapter setup
2
2
 
3
- Each adapter wraps the same `sb` from `src/lib/supabase.ts`:
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 "./supabase/generated";
8
+ import { schema } from "./generated";
9
9
 
10
- export type { Functions, Models } from "./supabase/generated";
10
+ export type { Functions, Models } from "./generated";
11
11
 
12
- export const sb = defineSupabase(schema);
12
+ export const betterSupabase = defineSupabase(schema);
13
13
  ```
14
14
 
15
15
  ## Next.js
16
16
 
17
- ```ts title="src/lib/supabase.server.ts"
17
+ ```ts title="src/lib/supabase/server.ts"
18
18
  import { createNext } from "better-supabase/next";
19
19
 
20
- import { sb } from "./supabase";
20
+ import { betterSupabase } from "./index";
21
21
 
22
- export const next = createNext(sb);
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 { next } from "./lib/supabase.server";
28
+ import { bs } from "./lib/supabase/server";
29
29
 
30
- export const proxy = (request: NextRequest) => next.proxy(request);
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 next.server();
38
- export const GET = next.route((request, { db }) =>
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 BetterEnv, createHono } from "better-supabase/hono";
47
+ import { type HonoEnv, createHono } from "better-supabase/hono";
47
48
  import { Hono } from "hono";
48
49
 
49
- import { type Functions, type Models, sb } from "./lib/supabase";
50
+ import { type Functions, type Models, betterSupabase } from "./lib/supabase";
50
51
 
51
- const bs = createHono(sb);
52
+ const bs = createHono(betterSupabase);
52
53
 
53
- const app = new Hono<BetterEnv<Models, Functions, unknown>>()
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 { sb } from "./lib/supabase";
72
+ import { betterSupabase } from "./lib/supabase";
72
73
 
73
- export const bs = createOrpc(sb);
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 { sb } from "../_shared/supabase.ts";
89
+ import { betterSupabase } from "../_shared/supabase.ts";
89
90
 
90
- const bs = createEdge(sb, { cors: true });
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 { sb } from "../_shared/supabase.ts";
104
+ import { betterSupabase } from "../_shared/supabase.ts";
103
105
 
104
- const mcp = createMcp(sb, {
106
+ const bs = createMcp(betterSupabase, {
105
107
  name: "crm",
106
108
  version: "0.1.0",
107
- resources: { customers: { select: ["id", "name"] } },
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(mcp.fetch);
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 auth.
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 { sb } from "../src/lib/supabase.ts";
34
+ import { betterSupabase } from "../src/lib/supabase/index.ts";
35
35
 
36
- export const seed = defineSeed(sb, {
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(sb, { sub: aliceId, tenant_id: ACME }, { postgres });
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`,
@@ -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 };