better-supabase 0.1.0 → 0.2.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 (115) hide show
  1. package/bin/better-supabase.js +2 -1
  2. package/dist/claims-CoTobNC-.js +25 -0
  3. package/dist/cli/bin.js +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.js +1 -1
  6. package/dist/{cli-CoByWYja.js → cli-DrSgDOQC.js} +333 -159
  7. package/dist/client/index.d.ts +1 -1
  8. package/dist/{compiler-DL18ukPe.d.ts → compiler-CZ9nIuqy.d.ts} +2 -2
  9. package/dist/config/index.d.ts +3 -3
  10. package/dist/config/index.js +2 -2
  11. package/dist/{config-D-aR2ZoN.js → config-ClDXmVtY.js} +11 -10
  12. package/dist/{define-QGkq04oe.js → define-BXMYD9AY.js} +65 -62
  13. package/dist/{define-CTfWw-SS.d.ts → define-e3zI4_4g.d.ts} +27 -10
  14. package/dist/edge/index.d.ts +4 -4
  15. package/dist/edge/index.js +3 -3
  16. package/dist/entitlements-CTEBm7IB.js +20 -0
  17. package/dist/entitlements-Dng-ctuT.d.ts +42 -0
  18. package/dist/events/index.d.ts +1 -1
  19. package/dist/{events-2OJsVG_d.d.ts → events-DOt3nSO-.d.ts} +2 -2
  20. package/dist/{executor-Bje4suQF.d.ts → executor-BYXQ9AGn.d.ts} +1 -1
  21. package/dist/{executor-Bazhgsgq.d.ts → executor-CGdNQ27g.d.ts} +1 -1
  22. package/dist/{executor-Ceq60hPM.js → executor-DeppvirK.js} +1 -1
  23. package/dist/guards-BnzDvFrz.js +10 -0
  24. package/dist/hono/index.d.ts +5 -5
  25. package/dist/hono/index.js +3 -3
  26. package/dist/{index-JXHBuS53.d.ts → index-Bw1bwtC0.d.ts} +3 -3
  27. package/dist/{index-Hf8PP9sP.d.ts → index-CBDufH1M.d.ts} +15 -11
  28. package/dist/{index-cEeweTeM.d.ts → index-CG69s15v.d.ts} +3 -3
  29. package/dist/{index-DQVvHzjW.d.ts → index-Ciu5Xlml.d.ts} +3 -3
  30. package/dist/{index-Sw43J8HG.d.ts → index-D4v7sncu.d.ts} +6 -6
  31. package/dist/{index-BD6wKthp.d.ts → index-DOsjFvMf.d.ts} +28 -7
  32. package/dist/{index-DRid0qIv.d.ts → index-DbxFr7XG.d.ts} +2 -2
  33. package/dist/{index-Nxo3TJcm.d.ts → index-DuP14VHo.d.ts} +11 -3
  34. package/dist/{index-Dup-nSe9.d.ts → index-K-3WM2Hp.d.ts} +2 -2
  35. package/dist/index.d.ts +9 -9
  36. package/dist/index.js +7 -6
  37. package/dist/jobs/index.d.ts +1 -1
  38. package/dist/jobs/index.js +6 -5
  39. package/dist/{json-schema-BbwO2mZg.js → json-schema-BpYqkCKf.js} +2 -1
  40. package/dist/list/index.d.ts +1 -1
  41. package/dist/{live-6b9i72Ux.d.ts → live-DJyZMD6w.d.ts} +14 -3
  42. package/dist/logger-glM28NVN.js +21 -0
  43. package/dist/mcp/index.d.ts +41 -4
  44. package/dist/mcp/index.js +194 -61
  45. package/dist/{mfa-CpogP66z.js → mfa-CpLfJSde.js} +23 -3
  46. package/dist/next/index.d.ts +23 -23
  47. package/dist/next/index.js +9 -8
  48. package/dist/openapi/index.d.ts +1 -1
  49. package/dist/openapi/index.js +3 -3
  50. package/dist/orpc/index.d.ts +4 -4
  51. package/dist/orpc/index.js +2 -2
  52. package/dist/otel/index.d.ts +3 -3
  53. package/dist/otel/index.js +1 -1
  54. package/dist/{template-BeOGnynM.js → permdock-sql-CghlHPuH.js} +25 -1
  55. package/dist/{plugin-HvGqCurB.d.ts → plugin-C4TR9J0Z.d.ts} +2 -2
  56. package/dist/plugins/actor/index.d.ts +1 -1
  57. package/dist/plugins/rules/index.d.ts +4 -3
  58. package/dist/plugins/rules/index.js +4 -11
  59. package/dist/plugins/soft-delete/index.d.ts +2 -2
  60. package/dist/plugins/tenant/index.d.ts +10 -4
  61. package/dist/plugins/tenant/index.js +15 -13
  62. package/dist/plugins/timestamps/index.d.ts +1 -1
  63. package/dist/plugins/validation/index.d.ts +1 -1
  64. package/dist/{pool-BFeqzwQS.d.ts → pool-R2m_DkXi.d.ts} +1 -1
  65. package/dist/postgres/index.d.ts +4 -4
  66. package/dist/postgres/index.js +2 -2
  67. package/dist/{postgrest-Dm-vhY6R.d.ts → postgrest-cAEQ_IQN.d.ts} +1 -1
  68. package/dist/{problem-DPu6Fh-t.js → problem-BTkTtRV9.js} +4 -4
  69. package/dist/query/index.d.ts +1 -1
  70. package/dist/react/index.d.ts +6 -6
  71. package/dist/react/index.js +3 -3
  72. package/dist/react/server.d.ts +5 -5
  73. package/dist/react/server.js +1 -1
  74. package/dist/react/session.d.ts +1 -1
  75. package/dist/react/session.js +1 -1
  76. package/dist/{react-CrOvrnIP.js → react-C83BdBGt.js} +9 -8
  77. package/dist/{read-set-DhX2d04c.js → read-set-4lI3W_gZ.js} +1 -1
  78. package/dist/realtime/index.d.ts +3 -2
  79. package/dist/realtime/index.js +26 -11
  80. package/dist/{resolve-DW8h9NPm.d.ts → resolve-CjHILk2w.d.ts} +21 -6
  81. package/dist/{resource-CYiu6Ygi.js → resource-BHEWmQJK.js} +1 -1
  82. package/dist/{resource-SkPTRJDD.d.ts → resource-CgZa3YG_.d.ts} +4 -4
  83. package/dist/{respond-4-dhoV7x.js → respond-DMcbQwfN.js} +7 -4
  84. package/dist/{respond-PoTpy5DT.d.ts → respond-Dne1ARSa.d.ts} +14 -14
  85. package/dist/server/index.d.ts +7 -7
  86. package/dist/server/index.js +5 -5
  87. package/dist/{session-C3osL21k.d.ts → session-C3LxnQ0e.d.ts} +4 -3
  88. package/dist/{session-CD-3orO_.js → session-CqEwzVup.js} +2 -1
  89. package/dist/{simplify-oHiDKRVQ.js → simplify-bwJpLnaS.js} +12 -12
  90. package/dist/{spec-pins-t6sPaHvl.js → spec-pins-C3oc-Tdp.js} +1 -1
  91. package/dist/{spec-pins-CJPVghHy.d.ts → spec-pins-CqorWDnO.d.ts} +1 -1
  92. package/dist/{sql-M8l3fnS0.js → sql-DTjA1LPj.js} +1 -1
  93. package/dist/ssr/index.d.ts +5 -5
  94. package/dist/ssr/index.js +3 -3
  95. package/dist/{stats-3iy8u3_P.js → stats-C5W-rBfR.js} +1 -1
  96. package/dist/storage/index.d.ts +3 -2
  97. package/dist/storage/index.js +1 -1
  98. package/dist/{storage-BWXVUXGk.js → storage-DuGn38fr.js} +72 -6
  99. package/dist/testing/index.d.ts +46 -15
  100. package/dist/testing/index.js +111 -43
  101. package/dist/{types-C2fslP1z.d.ts → types-C83v5SQz.d.ts} +55 -2
  102. package/dist/{view-B0ltUmTi.d.ts → view-D1s0wa0T.d.ts} +9 -3
  103. package/dist/{view-B6F9QbG-.js → view-aOYizTeE.js} +2 -1
  104. package/dist/webhooks/index.d.ts +3 -3
  105. package/dist/webhooks/index.js +1 -1
  106. package/dist/{webhooks-DbMTJJX2.js → webhooks-DMipjhSP.js} +1 -1
  107. package/package.json +8 -4
  108. package/schemas/config-v1.json +49 -4
  109. package/schemas/doctor-report-v1.json +1 -0
  110. package/schemas/snapshot-v2.json +4 -0
  111. package/skills/better-supabase/references/plugins.md +9 -0
  112. package/skills/better-supabase-api/SKILL.md +2 -2
  113. package/skills/better-supabase-testing/SKILL.md +4 -3
  114. package/dist/entitlements-BBg61DQZ.d.ts +0 -20
  115. package/dist/entitlements-BC_khwRy.js +0 -17
@@ -1,12 +1,16 @@
1
1
  import { n as dbError } from "../errors-Bl3rkOzo.js";
2
2
  import { i as ok } from "../result-DWXatkd6.js";
3
- import { t as BetterSupabase } from "../define-QGkq04oe.js";
4
- import { r as resolveConfig } from "../config-D-aR2ZoN.js";
3
+ import { t as BetterSupabase } from "../define-BXMYD9AY.js";
4
+ import { t as isList } from "../guards-BnzDvFrz.js";
5
+ import { r as resolveConfig } from "../config-ClDXmVtY.js";
5
6
  import { t as ENV_VARIABLES } from "../env-BEToab0I.js";
6
- import { n as postgresExecutor } from "../executor-Ceq60hPM.js";
7
+ import { n as postgresExecutor } from "../executor-DeppvirK.js";
7
8
  import { o as toCloudEvents } from "../events-BuzTgn2D.js";
8
9
  import { n as isSeed, t as defineSeed } from "../seed-CzecODcJ.js";
9
10
  import { createClient } from "@supabase/supabase-js";
11
+ import { existsSync } from "node:fs";
12
+ import { readFile } from "node:fs/promises";
13
+ import { dirname, join, resolve } from "node:path";
10
14
  //#region src/testing/jwt.ts
11
15
  const encoder = new TextEncoder();
12
16
  function base64url(input) {
@@ -83,6 +87,40 @@ function localAuth(secret) {
83
87
  }
84
88
  };
85
89
  }
90
+ async function signEs256(key, kid, claims) {
91
+ const { expiresIn = 3600, ...rest } = claims;
92
+ const now = Math.floor(Date.now() / 1e3);
93
+ const payload = {
94
+ aud: "authenticated",
95
+ role: "authenticated",
96
+ aal: "aal1",
97
+ iat: now,
98
+ exp: now + expiresIn,
99
+ ...rest
100
+ };
101
+ const header = base64url(JSON.stringify({
102
+ alg: "ES256",
103
+ typ: "JWT",
104
+ kid
105
+ }));
106
+ const body = base64url(JSON.stringify(payload));
107
+ const signature = await crypto.subtle.sign({
108
+ name: "ECDSA",
109
+ hash: "SHA-256"
110
+ }, key, encoder.encode(`${header}.${body}`));
111
+ return `${header}.${body}.${base64url(new Uint8Array(signature))}`;
112
+ }
113
+ /**
114
+ * Signs an ES256 access token with a private JWK, the way the local stack
115
+ * signs them once `signing_keys_path` is set. Test-only.
116
+ */
117
+ async function signTestJwtWithKey(key, claims) {
118
+ const { kid, ...jwk } = key;
119
+ return signEs256(await crypto.subtle.importKey("jwk", jwk, {
120
+ name: "ECDSA",
121
+ namedCurve: "P-256"
122
+ }, false, ["sign"]), kid, claims);
123
+ }
86
124
  /** An in-memory ES256 key pair that signs tokens like Supabase Auth's asymmetric keys. */
87
125
  async function createTestSigner() {
88
126
  const pair = await crypto.subtle.generateKey({
@@ -97,32 +135,44 @@ async function createTestSigner() {
97
135
  alg: "ES256",
98
136
  use: "sig"
99
137
  }] },
100
- async sign(claims) {
101
- const { expiresIn = 3600, ...rest } = claims;
102
- const now = Math.floor(Date.now() / 1e3);
103
- const payload = {
104
- aud: "authenticated",
105
- role: "authenticated",
106
- aal: "aal1",
107
- iat: now,
108
- exp: now + expiresIn,
109
- ...rest
110
- };
111
- const header = base64url(JSON.stringify({
112
- alg: "ES256",
113
- typ: "JWT",
114
- kid
115
- }));
116
- const body = base64url(JSON.stringify(payload));
117
- const signature = await crypto.subtle.sign({
118
- name: "ECDSA",
119
- hash: "SHA-256"
120
- }, pair.privateKey, encoder.encode(`${header}.${body}`));
121
- return `${header}.${body}.${base64url(new Uint8Array(signature))}`;
122
- }
138
+ sign: (claims) => signEs256(pair.privateKey, kid, claims)
123
139
  };
124
140
  }
125
141
  //#endregion
142
+ //#region src/testing/local-key.ts
143
+ const SIGNING_KEYS_PATH = /^\s*signing_keys_path\s*=\s*"([^"]+)"/m;
144
+ /** `supabase/config.toml`'s `signing_keys_path`, else `supabase/signing_keys.json`, in `from` or a parent. */
145
+ async function findSigningKeys(from) {
146
+ for (let dir = resolve(from);; dir = dirname(dir)) {
147
+ const supabase = join(dir, "supabase");
148
+ const toml = join(supabase, "config.toml");
149
+ if (existsSync(toml)) {
150
+ const configured = SIGNING_KEYS_PATH.exec(await readFile(toml, "utf8"))?.[1];
151
+ const path = resolve(supabase, configured ?? "signing_keys.json");
152
+ if (existsSync(path)) return path;
153
+ }
154
+ if (dirname(dir) === dir) return void 0;
155
+ }
156
+ }
157
+ const isSigningKey = (value) => typeof value === "object" && value !== null && "kid" in value && typeof value.kid === "string" && "d" in value && typeof value.d === "string";
158
+ /**
159
+ * The key the local stack signs with: the first entry of the file
160
+ * `better-supabase keys` wrote. `path` defaults to `$SUPABASE_SIGNING_KEYS_PATH`,
161
+ * then `signing_keys_path` from the nearest `supabase/config.toml`.
162
+ */
163
+ async function localSigningKey(path) {
164
+ const file = path ?? process.env["SUPABASE_SIGNING_KEYS_PATH"] ?? await findSigningKeys(process.cwd());
165
+ if (!file || !existsSync(file)) throw new TypeError("No local signing key. Run `better-supabase keys`, set `signing_keys_path` in supabase/config.toml and restart the stack, or pass { alg: \"HS256\" } to sign with the local JWT secret.");
166
+ const keys = JSON.parse(await readFile(file, "utf8"));
167
+ const first = Array.isArray(keys) ? keys[0] : void 0;
168
+ if (!isSigningKey(first)) throw new TypeError(`${file} has no private ES256 key first.`);
169
+ return first;
170
+ }
171
+ /** Signs a token with the local stack's ES256 key (see `localSigningKey`). Test-only. */
172
+ async function signLocalJwt(claims, path) {
173
+ return signTestJwtWithKey(await localSigningKey(path), claims);
174
+ }
175
+ //#endregion
126
176
  //#region src/testing/as-user.ts
127
177
  /** The local CLI stack's default JWT secret. */
128
178
  const LOCAL_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long";
@@ -133,12 +183,30 @@ function env$1(...names) {
133
183
  if (value) return value;
134
184
  }
135
185
  }
186
+ const LOCAL_HOSTS = /* @__PURE__ */ new Set([
187
+ "localhost",
188
+ "127.0.0.1",
189
+ "[::1]",
190
+ "host.docker.internal"
191
+ ]);
192
+ async function signFor(stack, url, claims) {
193
+ const alg = stack.alg ?? "ES256";
194
+ switch (alg) {
195
+ case "ES256": return signTestJwtWithKey(stack.signingKey ?? await localSigningKey(stack.signingKeysPath), claims);
196
+ case "HS256":
197
+ if (!LOCAL_HOSTS.has(new URL(url).hostname)) throw new TypeError(`asUser signs HS256 tokens only for a local stack, not ${url}. Use the ES256 key from \`better-supabase keys\`.`);
198
+ return signTestJwt(stack.jwtSecret ?? env$1("SUPABASE_JWT_SECRET") ?? "super-secret-jwt-token-with-at-least-32-characters-long", claims);
199
+ default: throw new TypeError(`asUser: unknown alg "${String(alg)}"`);
200
+ }
201
+ }
136
202
  /**
137
203
  * A test user against the local stack: a signed token and repositories that
138
- * run as that user, for live RLS tests.
204
+ * run as that user, for live RLS tests. The token is ES256-signed with the
205
+ * key from `better-supabase keys`; pass `{ alg: 'HS256' }` for a stack that
206
+ * still uses the shared JWT secret.
139
207
  *
140
208
  * ```ts
141
- * const alice = await asUser(sb, { sub: aliceId, org_id: acme });
209
+ * const alice = await asUser(sb, { sub: aliceId, tenant_id: acme });
142
210
  * expect(await alice.db.customers.count().orThrow()).toBe(3);
143
211
  * ```
144
212
  */
@@ -146,7 +214,7 @@ async function asUser(sb, claims, stack = {}) {
146
214
  const url = stack.url ?? env$1(...ENV_VARIABLES.url) ?? "http://127.0.0.1:54321";
147
215
  const publishableKey = stack.publishableKey ?? env$1(...ENV_VARIABLES.publishableKey, "SUPABASE_ANON_KEY");
148
216
  if (!publishableKey) throw new TypeError("asUser needs publishableKey or $SUPABASE_PUBLISHABLE_KEY. Run `better-supabase env` to write it to .env.local.");
149
- const token = await signTestJwt(stack.jwtSecret ?? env$1("SUPABASE_JWT_SECRET") ?? "super-secret-jwt-token-with-at-least-32-characters-long", claims);
217
+ const token = await signFor(stack, url, claims);
150
218
  const full = {
151
219
  role: "authenticated",
152
220
  ...claims
@@ -313,7 +381,7 @@ function testExecutor(executor, options) {
313
381
  const { sb } = options;
314
382
  const table = tableKey(sb, options.table);
315
383
  const context = { errorMappers: [] };
316
- const read = () => captureOp(sb, (db) => db[table].findMany({ limit: 2 }));
384
+ const read = () => captureOp(sb, (db) => db[table]["findMany"]({ limit: 2 }));
317
385
  const create = options.create;
318
386
  return conform(`Executor "${executor.name}"`, [
319
387
  hasName(executor),
@@ -321,7 +389,7 @@ function testExecutor(executor, options) {
321
389
  const op = await read();
322
390
  const result = await executor.execute(op, context);
323
391
  expect(result.ok, `select failed: ${result.ok ? "" : result.error.message}`);
324
- expect(Array.isArray(result.data.rows), "rows must be an array");
392
+ expect(isList(result.data.rows), "rows must be an array");
325
393
  expect(result.data.rows.length <= 2, `limit 2 returned ${result.data.rows.length} rows`);
326
394
  expect(result.data.count === null || typeof result.data.count === "number", "count must be a number or null");
327
395
  if (op.kind !== "select") return;
@@ -332,7 +400,7 @@ function testExecutor(executor, options) {
332
400
  }
333
401
  }],
334
402
  ["counts rows", async () => {
335
- const op = await captureOp(sb, (db) => db[table].count());
403
+ const op = await captureOp(sb, (db) => db[table]["count"]());
336
404
  const result = await executor.execute(op, context);
337
405
  expect(result.ok, `count failed: ${result.ok ? "" : result.error.message}`);
338
406
  expect(typeof result.data.count === "number" && result.data.count >= 0, "count must be a number");
@@ -380,7 +448,7 @@ function testExecutor(executor, options) {
380
448
  }],
381
449
  executor.batch && ["batch returns one result per operation, in order", async () => {
382
450
  const rows = await read();
383
- const count = await captureOp(sb, (db) => db[table].count());
451
+ const count = await captureOp(sb, (db) => db[table]["count"]());
384
452
  const missing = {
385
453
  ...rows,
386
454
  table: {
@@ -401,15 +469,15 @@ function testExecutor(executor, options) {
401
469
  }],
402
470
  create && ["round-trips a write", async () => {
403
471
  const repository = sb.connect(executor)[table];
404
- const created = await repository.create(create);
472
+ const created = await repository["create"](create);
405
473
  expect(created.ok, `create failed: ${JSON.stringify(created.error)}`);
406
474
  const row = created.data;
407
475
  for (const [key, value] of Object.entries(create)) expect(same(row[key], value), `created row has ${key} = ${JSON.stringify(row[key])}`);
408
476
  const [primary] = sb.meta.tables[table].primaryKey;
409
477
  expect(primary, "write checks need a table with a primary key");
410
- const removed = await repository.delete(row[primary]);
478
+ const removed = await repository["delete"](row[primary]);
411
479
  expect(removed.ok, `delete failed: ${JSON.stringify(removed.error)}`);
412
- const again = await repository.findFirst({ where: { [primary]: row[primary] } });
480
+ const again = await repository["findFirst"]({ where: { [primary]: row[primary] } });
413
481
  expect(again.ok && again.data === null, "the deleted row is still readable");
414
482
  }]
415
483
  ]);
@@ -557,7 +625,7 @@ function testGenerator(generator, options) {
557
625
  hasName(generator),
558
626
  ["writes files inside the project", async () => {
559
627
  const files = await generator.generate(input());
560
- expect(Array.isArray(files), "generate() must return an array");
628
+ expect(isList(files), "generate() must return an array");
561
629
  for (const file of files) {
562
630
  expect(typeof file.path === "string" && typeof file.contents === "string", "files need a path and contents");
563
631
  expect(!file.path.startsWith("/") && !/^[A-Za-z]:/.test(file.path), `${file.path} must be relative`);
@@ -598,14 +666,14 @@ function testPlugin(plugin, options) {
598
666
  for (const key of Object.keys(sb.meta.tables)) expect(db[key], `db.${key} is missing`);
599
667
  }],
600
668
  plugin.transformQuery && ["transformQuery is pure and deterministic", async () => {
601
- const op = await captureOp(sb, (db) => db[table].findMany({ limit: 1 }));
669
+ const op = await captureOp(sb, (db) => db[table]["findMany"]({ limit: 1 }));
602
670
  const first = plugin.transformQuery(frozenCopy(op), hook(op));
603
671
  const second = plugin.transformQuery(frozenCopy(op), hook(op));
604
672
  expect(typeof first === "object" && first !== null && first.kind === op.kind, "must return an operation of the same kind");
605
673
  expect(same(first, second), "two calls with the same input differ");
606
674
  }],
607
675
  plugin.beforeMutation && create && ["beforeMutation is pure and deterministic", async () => {
608
- const op = await captureOp(sb, (db) => db[table].create(create));
676
+ const op = await captureOp(sb, (db) => db[table]["create"](create));
609
677
  if (op.kind === "select") throw new Violation("create did not produce a mutation");
610
678
  const first = await plugin.beforeMutation(frozenCopy(op), hook(op));
611
679
  const second = await plugin.beforeMutation(frozenCopy(op), hook(op));
@@ -613,7 +681,7 @@ function testPlugin(plugin, options) {
613
681
  expect(same(first, second), "two calls with the same input differ");
614
682
  }],
615
683
  plugin.wrapExecutor && ["wrapExecutor keeps results intact", async () => {
616
- const op = await captureOp(sb, (db) => db[table].findMany({ limit: 1 }));
684
+ const op = await captureOp(sb, (db) => db[table]["findMany"]({ limit: 1 }));
617
685
  const data = {
618
686
  rows: [{ marker: "conformance" }],
619
687
  count: 1
@@ -664,8 +732,8 @@ function env(names) {
664
732
  * ```ts
665
733
  * await expectTenantIsolation(sb, {
666
734
  * tenants: [
667
- * { id: ACME, claims: { sub: alice, org_id: ACME } },
668
- * { id: GLOBEX, claims: { sub: bob, org_id: GLOBEX } },
735
+ * { id: ACME, claims: { sub: alice, tenant_id: ACME } },
736
+ * { id: GLOBEX, claims: { sub: bob, tenant_id: GLOBEX } },
669
737
  * ],
670
738
  * tables: {
671
739
  * tags: {
@@ -748,4 +816,4 @@ async function expectTenantIsolation(sb, options) {
748
816
  }
749
817
  }
750
818
  //#endregion
751
- export { ConformanceError, LOCAL_JWT_SECRET, asUser, createTestSigner, defineSeed, expectDbBudget, expectTenantIsolation, isSeed, localAuth, signTestJwt, testAuthResolver, testCacheAdapter, testEventSink, testExecutor, testGenerator, testPlugin };
819
+ export { ConformanceError, LOCAL_JWT_SECRET, asUser, createTestSigner, defineSeed, expectDbBudget, expectTenantIsolation, isSeed, localAuth, localSigningKey, signLocalJwt, signTestJwt, signTestJwtWithKey, testAuthResolver, testCacheAdapter, testEventSink, testExecutor, testGenerator, testPlugin };
@@ -99,16 +99,69 @@ interface SchemaMeta {
99
99
  readonly topics?: Readonly<Record<string, string>>;
100
100
  /** Tables that broadcast change signals (`config.realtime.tables`), keyed by app key. */
101
101
  readonly realtime?: Readonly<Record<string, RealtimeTableMeta>>;
102
+ /** Claim names from `config.claims` that differ from the defaults. */
103
+ readonly claims?: Partial<ClaimsMeta>;
104
+ }
105
+ /** The claim names the SQL kit, codegen and runtime defaults agree on. */
106
+ interface ClaimsMeta {
107
+ /** Top-level claim holding the active tenant id. Defaults to `tenant_id`. */
108
+ readonly tenant: string;
109
+ /** `scope` of the tenant entries in the `memberships` claim. Defaults to `tenant`. */
110
+ readonly scope: string;
111
+ /** Claim holding plan features per tenant (`{ [tenantId]: string[] }`). Defaults to `features`. */
112
+ readonly features: string;
102
113
  }
103
114
  interface RealtimeTableMeta {
104
115
  /** App column whose value scopes the broadcast topic. */
105
116
  readonly tenant?: string;
106
117
  }
118
+ /**
119
+ * Storage policies that call PermDock's generated SQL helpers:
120
+ * `<schema>.permitted_<scope>_ids(key)` for a scope keyed by a path segment,
121
+ * `<schema>.permdock_has(key)` for `scope: 'global'`. Only for permissions
122
+ * whose grants have no row conditions beyond the scope.
123
+ */
124
+ interface PermdockBucketPolicy {
125
+ readonly permdock: {
126
+ /** Downloads, signed URLs, renders and metadata reads. */
127
+ readonly read: string;
128
+ /** Listing; without it `read` covers listing too. */
129
+ readonly list?: string;
130
+ /** Uploads, updates and moves; also deletes unless `delete` is set. */
131
+ readonly write: string;
132
+ readonly delete?: string;
133
+ };
134
+ /** A PermDock scope such as `organization`, or `global`. */
135
+ readonly scope: string;
136
+ /** 1-based path segment holding the scope id. Defaults to the `{orgId}` segment. */
137
+ readonly segment?: number;
138
+ /**
139
+ * Schema of the helpers `permdock rls generate` writes: PermDock's
140
+ * `rls.schema`. Defaults to `public`.
141
+ */
142
+ readonly schema?: string;
143
+ }
144
+ /** A PermDock topic policy: `select` (receive) and `insert` (send) on `realtime.messages`. */
145
+ interface PermdockTopicPolicy {
146
+ readonly receive: string;
147
+ /** Lets clients send on the topic with this permission. */
148
+ readonly send?: string;
149
+ /** A PermDock scope such as `organization`, or `global`. */
150
+ readonly scope: string;
151
+ /** 1-based `:`-separated topic segment holding the scope id. Defaults to the `{orgId}` segment. */
152
+ readonly segment?: number;
153
+ readonly schema?: string;
154
+ }
155
+ type BucketPolicyName = "tenant" | "owner" | "public" | "none";
107
156
  interface BucketMeta {
108
157
  readonly id: string;
109
158
  readonly public: boolean;
110
159
  readonly path: string;
111
- readonly policy?: "tenant" | "owner" | "public" | "none";
160
+ readonly policy?: BucketPolicyName | PermdockBucketPolicy;
161
+ /** Claim paths for the tenant policy, when `config.claims.tenant` is not the default. */
162
+ readonly tenant?: {
163
+ readonly claim: readonly string[];
164
+ };
112
165
  readonly fileSizeLimit?: string;
113
166
  readonly allowedMimeTypes?: readonly string[];
114
167
  }
@@ -192,4 +245,4 @@ type IsUnion<T, U = T> = T extends unknown ? [U] extends [T] ? false : true : ne
192
245
  type PrimaryKeyValue<M extends AnyModels, T extends keyof M> = true extends IsUnion<PrimaryKeyColumn<M, T>> ? { readonly [K in PrimaryKeyColumn<M, T>]: Row<M, T>[K]; } : Row<M, T>[PrimaryKeyColumn<M, T>];
193
246
  type Simplify<T> = { [K in keyof T]: T[K]; } & {};
194
247
  //#endregion
195
- export { TableMeta as A, Relations as C, Simplify as D, SchemaMeta as E, UniqueKeyName as M, UniqueWhere as N, TableFlags as O, Update as P, RelationShape as S, Schema as T, ModelsOf as _, CheckConstraint as a, RealtimeTableMeta as b, DatabaseOf as c, ForeignKeyConstraint as d, FunctionMeta as f, ModelShape as g, Insert as h, Casing as i, UniqueConstraint as j, TableKey as k, Flags as l, FunctionsOf as m, AnyModels as n, Codec as o, FunctionShape as p, BucketMeta as r, ColumnMeta as s, AnyFunctions as t, FlagsShape as u, PrimaryKeyColumn as v, Row as w, RelationMeta as x, PrimaryKeyValue as y };
248
+ export { SchemaMeta as A, PrimaryKeyValue as C, Relations as D, RelationShape as E, UniqueConstraint as F, UniqueKeyName as I, UniqueWhere as L, TableFlags as M, TableKey as N, Row as O, TableMeta as P, Update as R, PrimaryKeyColumn as S, RelationMeta as T, Insert as _, Casing as a, PermdockBucketPolicy as b, Codec as c, Flags as d, FlagsShape as f, FunctionsOf as g, FunctionShape as h, BucketPolicyName as i, Simplify as j, Schema as k, ColumnMeta as l, FunctionMeta as m, AnyModels as n, CheckConstraint as o, ForeignKeyConstraint as p, BucketMeta as r, ClaimsMeta as s, AnyFunctions as t, DatabaseOf as u, ModelShape as v, RealtimeTableMeta as w, PermdockTopicPolicy as x, ModelsOf as y };
@@ -1,13 +1,13 @@
1
1
  import { p as DbError } from "./result-CTkCZ6JA.js";
2
2
  import { n as AmrEntry, s as Impersonator, t as Aal } from "./mfa-TVia554c.js";
3
- import { i as InvalidReason, r as AuthState } from "./resolve-DW8h9NPm.js";
3
+ import { i as InvalidReason, r as AuthState } from "./resolve-CjHILk2w.js";
4
4
  import { JWTClaims, UserClaims } from "@supabase/server";
5
5
  //#region src/auth/view.d.ts
6
6
  /**
7
7
  * The verified caller as plain data: no token, no clients. Safe to return
8
8
  * from a `'use cache: private'` function and to pass to Client Components.
9
9
  */
10
- type AuthSession<C = unknown> = {
10
+ type AuthSession<C = unknown, P = unknown> = {
11
11
  readonly kind: "user";
12
12
  readonly user: UserClaims;
13
13
  /**
@@ -17,6 +17,12 @@ type AuthSession<C = unknown> = {
17
17
  readonly claims: JWTClaims & C;
18
18
  /** Seconds since epoch, from the token's `exp`. */
19
19
  readonly expiresAt: number | null;
20
+ /**
21
+ * `user_metadata` parsed by `sb.userMetadata(schema)`, for display.
22
+ * Absent without a schema or when the metadata fails it. Users
23
+ * write this data with `auth.updateUser()`: never base access on it.
24
+ */
25
+ readonly profile?: P;
20
26
  /** `aal2` once the user verified a second factor in this session. */
21
27
  readonly aal: Aal;
22
28
  /** How the user signed in (`password`, `totp`, `sso/saml`, ...). */
@@ -38,6 +44,6 @@ type AnonReason = Extract<AuthState, {
38
44
  kind: "anon";
39
45
  }>["reason"];
40
46
  /** Drops the token from an `AuthState`, leaving only serializable fields. */
41
- declare function toSession<C>(auth: AuthState<C>): AuthSession<C>;
47
+ declare function toSession<C, P>(auth: AuthState<C, P>): AuthSession<C, P>;
42
48
  //#endregion
43
49
  export { toSession as n, AuthSession as t };
@@ -1,4 +1,4 @@
1
- import { g as impersonatorOf, n as amrOf, t as aalOf } from "./mfa-CpogP66z.js";
1
+ import { g as impersonatorOf, n as amrOf, t as aalOf } from "./mfa-CpLfJSde.js";
2
2
  //#region src/auth/view.ts
3
3
  /** Drops the token from an `AuthState`, leaving only serializable fields. */
4
4
  function toSession(auth) {
@@ -10,6 +10,7 @@ function toSession(auth) {
10
10
  user: auth.user,
11
11
  claims: auth.claims,
12
12
  expiresAt: auth.expiresAt,
13
+ ...auth.profile === void 0 ? {} : { profile: auth.profile },
13
14
  aal: aalOf(auth.claims),
14
15
  amr: amrOf(auth.claims),
15
16
  ...impersonator ? { impersonator } : {}
@@ -1,6 +1,6 @@
1
- import { k as TableKey, n as AnyModels, t as AnyFunctions, w as Row } from "../types-C2fslP1z.js";
1
+ import { N as TableKey, O as Row, n as AnyModels, t as AnyFunctions } from "../types-C83v5SQz.js";
2
2
  import { o as Result } from "../result-CTkCZ6JA.js";
3
- import { t as BetterSupabase } from "../define-CTfWw-SS.js";
3
+ import { t as BetterSupabase } from "../define-e3zI4_4g.js";
4
4
  //#region src/webhooks/index.d.ts
5
5
  export interface VerifiedWebhook<T = unknown> {
6
6
  readonly id: string;
@@ -125,7 +125,7 @@ export declare function hookError(status: number, message: string): AuthHookErro
125
125
  *
126
126
  * ```ts
127
127
  * export const POST = authHook('custom_access_token', env.AUTH_HOOK_SECRET, async ({ claims, user_id }) => ({
128
- * claims: { ...claims, org_id: await orgFor(user_id) },
128
+ * claims: { ...claims, tenant_id: await orgFor(user_id) },
129
129
  * }));
130
130
  * ```
131
131
  */
@@ -1,2 +1,2 @@
1
- import { a as signWebhook, c as verifyWebhook, i as isDatabaseWebhook, n as databaseChange, o as timingSafeEqual, r as hookError, s as verifySharedSecret, t as authHook } from "../webhooks-DbMTJJX2.js";
1
+ import { a as signWebhook, c as verifyWebhook, i as isDatabaseWebhook, n as databaseChange, o as timingSafeEqual, r as hookError, s as verifySharedSecret, t as authHook } from "../webhooks-DMipjhSP.js";
2
2
  export { authHook, databaseChange, hookError, isDatabaseWebhook, signWebhook, timingSafeEqual, verifySharedSecret, verifyWebhook };
@@ -98,7 +98,7 @@ function hookError(status, message) {
98
98
  *
99
99
  * ```ts
100
100
  * export const POST = authHook('custom_access_token', env.AUTH_HOOK_SECRET, async ({ claims, user_id }) => ({
101
- * claims: { ...claims, org_id: await orgFor(user_id) },
101
+ * claims: { ...claims, tenant_id: await orgFor(user_id) },
102
102
  * }));
103
103
  * ```
104
104
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-supabase",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Strongly typed Supabase: generated schema, typed repositories, plugins, auth glue and framework integrations, with a CLI from day one.",
5
5
  "keywords": [
6
6
  "hono",
@@ -184,6 +184,7 @@
184
184
  },
185
185
  "devDependencies": {
186
186
  "@arethetypeswrong/cli": "0.18.5",
187
+ "@better-supabase/ox-config": "0.0.0",
187
188
  "@better-supabase/typescript-config": "0.0.0",
188
189
  "@effect/platform-node": "4.0.0-rc.117",
189
190
  "@opentelemetry/api": "1.9.1",
@@ -192,15 +193,17 @@
192
193
  "@supabase/supabase-js": "2.117.2",
193
194
  "@tanstack/query-core": "5.104.0",
194
195
  "@tanstack/react-query": "5.104.0",
195
- "@types/node": "26.6.3",
196
+ "@types/node": "24.19.0",
196
197
  "@types/pg": "8.23.1",
197
198
  "@types/react": "19.3.0",
198
199
  "@types/react-dom": "19.3.0",
200
+ "@valibot/to-json-schema": "1.8.0",
199
201
  "@vitest/coverage-v8": "5.0.2",
200
202
  "effect": "4.0.0-rc.117",
201
203
  "hono": "4.13.10",
202
- "next": "16.3.6",
204
+ "next": "16.3.7",
203
205
  "oxfmt": "0.71.0",
206
+ "oxlint": "1.86.0",
204
207
  "pg": "8.23.0",
205
208
  "publint": "0.3.24",
206
209
  "react": "19.3.0",
@@ -252,9 +255,10 @@
252
255
  "node": ">=24"
253
256
  },
254
257
  "scripts": {
255
- "bench": "node src/next/next.bench.ts",
258
+ "bench": "node tests/next/next.bench.ts",
256
259
  "build": "tsdown",
257
260
  "check:publish": "publint && pnpm pack --out pack.tgz && attw --profile esm-only pack.tgz && rm -f pack.tgz",
261
+ "lint": "oxlint",
258
262
  "test": "vitest run --coverage",
259
263
  "typecheck": "tsc --noEmit --checkers 4"
260
264
  }
@@ -148,8 +148,7 @@
148
148
  "type": "object",
149
149
  "additionalProperties": false,
150
150
  "properties": {
151
- "column": { "type": "string" },
152
- "claim": { "type": "string" }
151
+ "column": { "type": "string" }
153
152
  }
154
153
  }
155
154
  ]
@@ -169,6 +168,25 @@
169
168
  }
170
169
  }
171
170
  },
171
+ "claims": {
172
+ "description": "Claim names shared by the SQL kit, codegen and runtime defaults (PermDock's claim contract).",
173
+ "type": "object",
174
+ "additionalProperties": false,
175
+ "properties": {
176
+ "tenant": {
177
+ "type": "string",
178
+ "description": "Top-level claim holding the active tenant id. Defaults to `tenant_id`."
179
+ },
180
+ "scope": {
181
+ "type": "string",
182
+ "description": "`scope` of the tenant entries in the `memberships` claim. Defaults to `tenant`."
183
+ },
184
+ "features": {
185
+ "type": "string",
186
+ "description": "Claim holding plan features per tenant. Defaults to `features`."
187
+ }
188
+ }
189
+ },
172
190
  "buckets": {
173
191
  "type": "object",
174
192
  "additionalProperties": {
@@ -182,7 +200,33 @@
182
200
  "type": "string",
183
201
  "description": "Path template, e.g. `{orgId}/{customerId}/logo.webp`."
184
202
  },
185
- "policy": { "enum": ["tenant", "owner", "public", "none"] },
203
+ "policy": {
204
+ "oneOf": [
205
+ { "enum": ["tenant", "owner", "public", "none"] },
206
+ {
207
+ "type": "object",
208
+ "description": "Policies that call PermDock's permitted_<scope>_ids or permdock_has helpers.",
209
+ "required": ["permdock", "scope"],
210
+ "additionalProperties": false,
211
+ "properties": {
212
+ "permdock": {
213
+ "type": "object",
214
+ "required": ["read", "write"],
215
+ "additionalProperties": false,
216
+ "properties": {
217
+ "read": { "type": "string" },
218
+ "list": { "type": "string" },
219
+ "write": { "type": "string" },
220
+ "delete": { "type": "string" }
221
+ }
222
+ },
223
+ "scope": { "type": "string" },
224
+ "segment": { "type": "integer", "minimum": 1 },
225
+ "schema": { "type": "string" }
226
+ }
227
+ }
228
+ ]
229
+ },
186
230
  "fileSizeLimit": { "type": "string" },
187
231
  "allowedMimeTypes": { "type": "array", "items": { "type": "string" } }
188
232
  }
@@ -314,7 +358,8 @@
314
358
  },
315
359
  "strict": { "type": "boolean" },
316
360
  "sources": { "type": "array", "items": { "type": "string" } },
317
- "policyHelperLimit": { "type": "integer", "minimum": 1 }
361
+ "policyHelperLimit": { "type": "integer", "minimum": 1 },
362
+ "claimsLimit": { "type": "integer", "minimum": 1 }
318
363
  }
319
364
  }
320
365
  },
@@ -64,6 +64,7 @@
64
64
  "BS404",
65
65
  "BS405",
66
66
  "BS406",
67
+ "BS407",
67
68
  "BS501",
68
69
  "BS502"
69
70
  ]
@@ -766,6 +766,10 @@
766
766
  "items": {
767
767
  "type": "string"
768
768
  }
769
+ },
770
+ "source": {
771
+ "type": "string",
772
+ "description": "The function body (`pg_proc.prosrc`)."
769
773
  }
770
774
  }
771
775
  }
@@ -53,4 +53,13 @@ filled it.
53
53
  RLS still decides what a user can read and write. `tenant()` makes queries
54
54
  explicit and fills the column; it doesn't replace the policy.
55
55
 
56
+ The tenant comes from the verified `tenant_id` claim (renamed with
57
+ `claims.tenant` in `better-supabase.config.ts`), then `app_metadata.tenant_id`.
58
+ Never read it from `user_metadata`, a URL or a request body. When the project
59
+ has a `permdock.config.ts`, PermDock's hook writes that claim and the
60
+ memberships; don't run `sql add tenant` there or call `membership_claims()`
61
+ from a hook. `sql add entitlements` is fine: `features` is not a PermDock
62
+ claim, but PermDock's hook doesn't write it yet, so read entitlements from the
63
+ database on the server instead of `hasEntitlement(session, ...)`.
64
+
56
65
  Docs: https://bettersupabase.com/docs/plugins.md
@@ -60,8 +60,8 @@ and a replayed webhook or POST doesn't run twice.
60
60
 
61
61
  ## Testing
62
62
 
63
- Use `localAuth(secret)` as a resolver and `signTestJwt` / `asUser` from
64
- `better-supabase/testing`. See the `better-supabase-testing` skill.
63
+ Sign tokens with `signLocalJwt` or `asUser` from `better-supabase/testing`;
64
+ the adapter verifies them against the local JWKS. See the `better-supabase-testing` skill.
65
65
 
66
66
  Docs: https://bettersupabase.com/docs/frameworks/hono.md (and `next`,
67
67
  `orpc`, `edge`, `mcp` under `/docs/frameworks/`).
@@ -25,6 +25,7 @@ again once you restore it.
25
25
  ## Setup
26
26
 
27
27
  - `better-supabase env` writes the URL and keys to `.env.local`; load it in the test setup.
28
+ - `better-supabase keys` creates `supabase/signing_keys.json`. Set `signing_keys_path = "./signing_keys.json"` under `[auth]` in `supabase/config.toml`, gitignore the file and restart the stack. `asUser` signs ES256 tokens with it.
28
29
  - Typed fixtures live in `supabase/seed.ts`:
29
30
 
30
31
  ```ts
@@ -43,7 +44,7 @@ export const seed = defineSeed(sb, {
43
44
  ## RLS tests
44
45
 
45
46
  ```ts
46
- const alice = await asUser(sb, { sub: aliceId, org_id: ACME }, { postgres });
47
+ const alice = await asUser(sb, { sub: aliceId, tenant_id: ACME }, { postgres });
47
48
  expect(await alice.db.customers.count().orThrow()).toBe(1);
48
49
  expect(await alice.db.customers.findById(OTHER_ORG_CUSTOMER)).toMatchObject({ ok: false });
49
50
  ```
@@ -54,8 +55,8 @@ expect(await alice.db.customers.findById(OTHER_ORG_CUSTOMER)).toMatchObject({ ok
54
55
 
55
56
  ## API tests
56
57
 
57
- Pass `auth: { resolvers: [localAuth(LOCAL_JWT_SECRET)] }` to the adapter and
58
- send `authorization: Bearer ${alice.token}`.
58
+ Send `authorization: Bearer ${alice.token}` (or a token from `signLocalJwt`).
59
+ The adapter verifies it against the local JWKS; no test-only resolver is needed.
59
60
 
60
61
  ## pgTAP
61
62