better-supabase 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +2 -0
  2. package/dist/channel-FQW-CnGR.d.ts +70 -0
  3. package/dist/cli/bin.js +1 -1
  4. package/dist/cli/codemod-DqV7612m.js +388 -0
  5. package/dist/cli/{config-D9R0o1LH.js → config-CPVpXP5I.js} +78 -8
  6. package/dist/cli/{config-SSKSTUl2.js → config-dtoekM0v.js} +1 -1
  7. package/dist/cli/{read-sets-CMa29t9a.js → diff-SjkO5rNr.js} +1 -220
  8. package/dist/cli/{dist-CFgGknmZ.js → dist-DBPdlPYK.js} +4 -4
  9. package/dist/cli/{dist-B1MvLv-M.js → dist-DreZ24Sz.js} +1 -1
  10. package/dist/cli/{dist-Du7VnEUG.js → dist-xn4wlboc.js} +112 -1
  11. package/dist/cli/{doctor-BOym3mbL.js → doctor-L7Z8mYTx.js} +531 -50
  12. package/dist/cli/{env-BZ0uy8XK.js → env-DmKv_G4A.js} +1 -1
  13. package/dist/cli/gen-BJc9vdyu.js +2 -0
  14. package/dist/cli/{gen-Ccn_p90L.js → gen-C2osRcr5.js} +283 -87
  15. package/dist/cli/index.d.ts +1 -1
  16. package/dist/cli/index.js +4 -4
  17. package/dist/cli/{init-ClnxRYBF.js → init-Saf5-R9T.js} +2 -2
  18. package/dist/cli/{introspect-DxqfkVIv.js → introspect--H7oGmcd.js} +2 -2
  19. package/dist/cli/{openapi-8WHjxiTJ.js → openapi-Cp5Fyta-.js} +1 -1
  20. package/dist/cli/read-sets-BfMQcKy8.js +289 -0
  21. package/dist/cli/{run-CP20LKk_.js → run-BKR2W_79.js} +16 -12
  22. package/dist/cli/{seed-5J6eu_Xg.js → seed-CUlqWh-8.js} +1 -1
  23. package/dist/cli/sql-DzPbfngx.js +348 -0
  24. package/dist/cli/{version-5mW_8sY1.js → version-BDJuO0UR.js} +1 -1
  25. package/dist/client/index.d.ts +1 -1
  26. package/dist/client/index.js +3 -10
  27. package/dist/clone-BfgmLU90.js +21 -0
  28. package/dist/{compiler-DB3aSFOA.d.ts → compiler-BXH_aZ7N.d.ts} +2 -2
  29. package/dist/config/index.d.ts +4 -4
  30. package/dist/config/index.js +4 -4
  31. package/dist/{config-CbvFmGD8.js → config-DreERlKo.js} +1 -1
  32. package/dist/{config-C1CYcm6o.d.ts → config-T_ejwyd3.d.ts} +176 -11
  33. package/dist/{define-CRS2MRV3.d.ts → define-Bp_BZa5h.d.ts} +11 -7
  34. package/dist/{define-Bh0AqTik.js → define-cNKzqwL_.js} +202 -16
  35. package/dist/edge/index.d.ts +16 -5
  36. package/dist/edge/index.js +10 -7
  37. package/dist/{entitlements-Dm-FHpNH.d.ts → entitlements-DUxU4oHe.d.ts} +1 -1
  38. package/dist/env/index.d.ts +2 -2
  39. package/dist/env/index.js +34 -1
  40. package/dist/{errors-L0xK41y_.js → errors-DtwXa6b8.js} +1 -0
  41. package/dist/events/index.d.ts +3 -2
  42. package/dist/events/index.js +65 -12
  43. package/dist/events-aIO7vsIk.d.ts +230 -0
  44. package/dist/{result-CqEsbgYs.d.ts → executor-BunPOO-k.d.ts} +200 -1
  45. package/dist/{executor-CZ7ImRiD.d.ts → executor-Cghdstwu.d.ts} +1 -2
  46. package/dist/{executor-CVt6dD0B.js → executor-XycESQPH.js} +3 -3
  47. package/dist/hono/index.d.ts +17 -6
  48. package/dist/hono/index.js +40 -5
  49. package/dist/impersonation-VfaGc4Q_.js +41 -0
  50. package/dist/{index-BBdvo-5d.d.ts → index-BGdgBPdf.d.ts} +49 -6
  51. package/dist/{index-BhHEC04r.d.ts → index-C7f8cOBM.d.ts} +2 -2
  52. package/dist/{index-Dy6kuF0P.d.ts → index-CLg87OBW.d.ts} +6 -1
  53. package/dist/{index-D5IfUIDh.d.ts → index-DOESKG27.d.ts} +33 -8
  54. package/dist/{index-DFmOpBGC.d.ts → index-DgILJRXj.d.ts} +35 -9
  55. package/dist/{index-RlLXd8dL.d.ts → index-DmXSxbLi.d.ts} +4 -4
  56. package/dist/{index-Cf-D2jk1.d.ts → index-Dxn2a5cw.d.ts} +2 -2
  57. package/dist/{index-528Ugmyp.d.ts → index-SaTsEfGP.d.ts} +76 -8
  58. package/dist/{index-BAy4tsAP.d.ts → index-fag8VJZ2.d.ts} +3 -4
  59. package/dist/index.d.ts +30 -13
  60. package/dist/index.js +10 -9
  61. package/dist/jobs/index.d.ts +147 -101
  62. package/dist/jobs/index.js +744 -237
  63. package/dist/{json-schema-DP6V7cbr.js → json-schema-m1fiwKgN.js} +22 -1
  64. package/dist/kit-events-CTFDfSwb.js +60 -0
  65. package/dist/kit-transport-B82k1djG.d.ts +33 -0
  66. package/dist/kit-transport-DJlHQUBU.js +55 -0
  67. package/dist/{config-BpxDfnbg.js → kits-CGLLrNPd.js} +37 -9
  68. package/dist/list/index.d.ts +1 -1
  69. package/dist/{live-BHakSWWo.d.ts → live-D9W2M5aO.d.ts} +6 -5
  70. package/dist/mcp/index.d.ts +25 -8
  71. package/dist/mcp/index.js +23 -14
  72. package/dist/{mfa-C7MNQ1Ob.d.ts → mfa-CusDgPaD.d.ts} +5 -1
  73. package/dist/next/index.d.ts +31 -10
  74. package/dist/next/index.js +136 -27
  75. package/dist/notifications/index.d.ts +162 -0
  76. package/dist/notifications/index.js +264 -0
  77. package/dist/openapi/index.d.ts +1 -1
  78. package/dist/openapi/index.js +2 -2
  79. package/dist/orgs/index.d.ts +132 -0
  80. package/dist/orgs/index.js +245 -0
  81. package/dist/orpc/index.d.ts +28 -8
  82. package/dist/orpc/index.js +16 -3
  83. package/dist/otel/index.d.ts +21 -2
  84. package/dist/otel/index.js +50 -9
  85. package/dist/permdock-sql-C2Inc6Xc.d.ts +29 -0
  86. package/dist/permdock-sql-DgOfeixi.js +51 -0
  87. package/dist/plugin-DwARMuQp.js +59 -0
  88. package/dist/{plugin-bNhvJwRh.d.ts → plugin-o144f322.d.ts} +17 -5
  89. package/dist/plugins/actor/index.d.ts +5 -2
  90. package/dist/plugins/actor/index.js +22 -8
  91. package/dist/plugins/rules/index.d.ts +2 -2
  92. package/dist/plugins/rules/index.js +15 -10
  93. package/dist/plugins/soft-delete/index.d.ts +3 -3
  94. package/dist/plugins/soft-delete/index.js +14 -5
  95. package/dist/plugins/tenant/index.d.ts +2 -2
  96. package/dist/plugins/tenant/index.js +38 -20
  97. package/dist/plugins/timestamps/index.d.ts +1 -1
  98. package/dist/plugins/timestamps/index.js +4 -3
  99. package/dist/plugins/validation/index.d.ts +2 -2
  100. package/dist/plugins/validation/index.js +26 -6
  101. package/dist/{pool-DFwEQwmN.d.ts → pool-C9voM4Kw.d.ts} +17 -6
  102. package/dist/postgres/index.d.ts +5 -5
  103. package/dist/postgres/index.js +20 -11
  104. package/dist/{postgrest-Bc-0Xw62.d.ts → postgrest-CruYKAvV.d.ts} +1 -1
  105. package/dist/{problem-BihjPR0t.js → problem-BW1KhzSG.js} +2 -1
  106. package/dist/{problem-U8UEKccv.d.ts → problem-Blxe8S31.d.ts} +1 -1
  107. package/dist/query/index.d.ts +2 -2
  108. package/dist/query/index.js +3 -3
  109. package/dist/{query-BMMSWbI7.js → query-CCbo44lG.js} +2 -2
  110. package/dist/queue-BYUCjr59.d.ts +252 -0
  111. package/dist/react/index.d.ts +8 -7
  112. package/dist/react/index.js +4 -3
  113. package/dist/react/server.d.ts +14 -3
  114. package/dist/react/server.js +10 -2
  115. package/dist/react/session.d.ts +7 -1
  116. package/dist/react/session.js +9 -1
  117. package/dist/{react-CQHMaESR.js → react-C_s7kiNz.js} +103 -11
  118. package/dist/{read-set-FWK8qQ58.js → read-set-DQFt1N_Y.js} +22 -1
  119. package/dist/realtime/index.d.ts +3 -3
  120. package/dist/realtime/index.js +5 -4
  121. package/dist/{refresh-BwYkyNOQ.d.ts → refresh-BTrQ_ZTp.d.ts} +1 -1
  122. package/dist/{resolve-BAqQcwMW.d.ts → resolve-C15TJq77.d.ts} +11 -5
  123. package/dist/{resource-My_y-Syh.js → resource-CRxxwkQq.js} +22 -7
  124. package/dist/{resource-BJcaIfxi.d.ts → resource-D8iqWMr-.d.ts} +5 -5
  125. package/dist/respond-D2OQl62-.d.ts +371 -0
  126. package/dist/{result-Bj6yt33D.js → result-BKiliPY7.js} +1 -1
  127. package/dist/server/index.d.ts +14 -12
  128. package/dist/server/index.js +11 -17
  129. package/dist/{server-BA75B0mT.js → server-Csx2sRjg.js} +251 -34
  130. package/dist/{shared-DnoCdMDr.js → shared-D1dRUrAg.js} +16 -2
  131. package/dist/signers-Cc6r98SW.d.ts +162 -0
  132. package/dist/{simplify-Cqvfr9dj.js → simplify-_0oUKqCd.js} +4 -115
  133. package/dist/sql/index.d.ts +247 -10
  134. package/dist/sql/index.js +8590 -779
  135. package/dist/{sql-B34HU2DI.js → sql-Coh6n4cC.js} +9 -2
  136. package/dist/ssr/index.d.ts +4 -4
  137. package/dist/ssr/index.js +1 -2
  138. package/dist/{standard-BRyBGTtp.js → standard-D7r6dMUu.js} +6 -3
  139. package/dist/{standard-BsaCYQJc.d.ts → standard-J4YgMOLm.d.ts} +1 -1
  140. package/dist/{stats-C_YxA2r1.js → stats-Bqob3WqT.js} +4 -4
  141. package/dist/storage/index.d.ts +4 -4
  142. package/dist/storage/index.js +155 -26
  143. package/dist/support-DT-OhSxD.d.ts +88 -0
  144. package/dist/support-Dk6vdmEw.js +291 -0
  145. package/dist/support-view-Da5peKFg.d.ts +17 -0
  146. package/dist/support-view-g4YLBeqX.js +18 -0
  147. package/dist/{permdock-sql-C4KFzLc9.js → template-TxrGI5jR.js} +2 -31
  148. package/dist/{temporal-BwshQk0W.js → temporal-CVlIV2DZ.js} +17 -4
  149. package/dist/{temporal-required-CBNHT6ni.js → temporal-required-CAt4Dc9Z.js} +2 -2
  150. package/dist/testing/index.d.ts +100 -10
  151. package/dist/testing/index.js +336 -8
  152. package/dist/{types-DONv9SHN.d.ts → types-Dnf_wRDz.d.ts} +26 -3
  153. package/dist/user-change-CHFEUro1.js +49 -0
  154. package/dist/{webhooks-Bh_Jf_-l.js → verify-Ce49ETPz.js} +5 -5
  155. package/dist/{version-Bmf7_FzM.js → version-DM_Roffb.js} +1 -1
  156. package/dist/{view-B64o81wp.d.ts → view-BEf0diC5.d.ts} +8 -3
  157. package/dist/{mfa-byfjruR9.js → view-Bq64ki8-.js} +95 -38
  158. package/dist/webhooks/index.d.ts +135 -4
  159. package/dist/webhooks/index.js +588 -2
  160. package/dist/wire-D2dT86vi.js +35 -0
  161. package/package.json +16 -8
  162. package/schemas/config-v1.json +131 -1
  163. package/schemas/doctor-report-v1.json +10 -0
  164. package/skills/better-supabase/SKILL.md +9 -6
  165. package/skills/better-supabase/references/plugins.md +6 -3
  166. package/skills/better-supabase/references/troubleshooting.md +1 -0
  167. package/skills/better-supabase-api/SKILL.md +9 -2
  168. package/skills/better-supabase-api/references/adapters.md +11 -8
  169. package/skills/better-supabase-auth/SKILL.md +3 -0
  170. package/skills/better-supabase-auth/references/permdock.md +56 -11
  171. package/dist/cli/gen-D5cLCULx.js +0 -2
  172. package/dist/cli/sql-D4VbqmXi.js +0 -199
  173. package/dist/events-B21UOhvP.d.ts +0 -84
  174. package/dist/executor-B5IoUxLE.d.ts +0 -195
  175. package/dist/invalidate-CgxmRamI.js +0 -24
  176. package/dist/permdock-sql-DImJULY-.d.ts +0 -17
  177. package/dist/plugin-BqR6wKMB.js +0 -23
  178. package/dist/respond-DgIbdrt8.d.ts +0 -194
  179. package/dist/view-BrJI2LKp.js +0 -45
@@ -2,17 +2,18 @@ import { a as list, r as defineCliCommand } from "./command-CK14fP91.js";
2
2
  import { n as CliError } from "./errors-4v4DTtda.js";
3
3
  import { r as writeIfChanged } from "./io-k41_z-jT.js";
4
4
  import { i as plain, r as painter } from "./style-C0NFdJRL.js";
5
- import { t as VERSION } from "./version-5mW_8sY1.js";
6
- import { c as diffEngine, d as readSupabaseToml, f as schemaPaths, l as migrationCommand, p as tomlGet, s as stdinDatabaseUrl, u as pgFunctionHooks } from "./config-D9R0o1LH.js";
7
- import { a as parseHookMarker, c as readPermdock, i as parseGrantsMarker, s as permdockSource, t as compiledReadSets } from "./read-sets-CMa29t9a.js";
5
+ import { t as VERSION } from "./version-BDJuO0UR.js";
6
+ import { c as diffEngine, d as pgFunctionHooks, f as readSupabaseToml, l as httpHooks, m as tomlGet, p as schemaPaths, s as stdinDatabaseUrl, u as migrationCommand } from "./config-CPVpXP5I.js";
7
+ import { a as parseHookMarker, i as parseGrantsMarker, o as permdockSource, s as readPermdock, t as compiledReadSets } from "./read-sets-BfMQcKy8.js";
8
8
  import { t as byCodePoint } from "./compare-BmIWVEui.js";
9
- import { C as catalogOf, D as qualified, E as policyObject, O as tableObject, S as entitlementsKit, T as lineOf, c as openSource, d as snapshotFile, h as CACHE_DIR, o as loadSnapshot, r as renderFiles, s as managementTarget, w as exposed, x as PERMDOCK_RULES } from "./gen-Ccn_p90L.js";
9
+ import { C as catalogOf, D as policyObject, E as lineOf, O as qualified, S as entitlementsKit, T as exposedSchemas, c as openSource, d as snapshotFile, h as CACHE_DIR, k as tableObject, o as loadSnapshot, r as renderFiles, s as managementTarget, w as exposed, x as PERMDOCK_RULES } from "./gen-C2osRcr5.js";
10
10
  import { n as withSpinner } from "./prompts-BNcNfAve.js";
11
11
  import { t as connect } from "./db-DOBPt660.js";
12
12
  import { dirname, resolve } from "node:path";
13
13
  import { existsSync } from "node:fs";
14
14
  import { glob, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
15
- import { kitLayout, renderKit, sameKitFile } from "better-supabase/sql";
15
+ import { SQL_MODULES, contractSignature, customContracts, kitDeprecations, kitFileVersion, kitLayout, migrationOptionUses, moduleVersion, renderKit, sameKitFile } from "better-supabase/sql";
16
+ import { DEFAULT_ACTIVE_TENANT, tenantClaimPaths } from "better-supabase/config";
16
17
  import { createHash } from "node:crypto";
17
18
  import { defineBucket, parseSize } from "better-supabase/storage";
18
19
  //#region src/cli/doctor/advisors.ts
@@ -93,7 +94,7 @@ const SLOW_CALLS = 1e3;
93
94
  /** Rows `--explain` reads, like a `findMany()` under Supabase's default `max_rows`. */
94
95
  const EXPLAIN_LIMIT = 1e3;
95
96
  const escape$2 = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
- const ident = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
97
+ const ident$1 = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
97
98
  const literal = (text) => `'${text.replaceAll("'", "''")}'`;
98
99
  const errorText = (cause) => cause instanceof Error ? cause.message : String(cause);
99
100
  const oneLine = (query, max = 160) => {
@@ -105,7 +106,7 @@ const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
105
106
  /** The schema `pg_stat_statements` lives in, when the extension is installed. */
106
107
  async function statementsView(db) {
107
108
  const [row] = await db.query(`select n.nspname as schema from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_stat_statements'`);
108
- return row ? `${ident(row.schema)}.pg_stat_statements` : void 0;
109
+ return row ? `${ident$1(row.schema)}.pg_stat_statements` : void 0;
109
110
  }
110
111
  /** The first exposed table a statement names, to locate it in the SQL files. */
111
112
  function tableIn(query, tables) {
@@ -169,8 +170,8 @@ async function explainTable(db, table, claims) {
169
170
  }
170
171
  await db.query(`select set_config('request.jwt.claims', ${literal(JSON.stringify(claims))}, true)`);
171
172
  const before = tracked ? await functionTimes(db) : /* @__PURE__ */ new Map();
172
- await db.query(`set local role ${ident(role)}`);
173
- const [row] = await db.query(`explain (analyze, buffers, format json) select * from ${ident(table.schema)}.${ident(table.name)} limit ${EXPLAIN_LIMIT}`);
173
+ await db.query(`set local role ${ident$1(role)}`);
174
+ const [row] = await db.query(`explain (analyze, buffers, format json) select * from ${ident$1(table.schema)}.${ident$1(table.name)} limit ${EXPLAIN_LIMIT}`);
174
175
  const raw = row?.["QUERY PLAN"];
175
176
  const plan = typeof raw === "string" ? JSON.parse(raw) : raw;
176
177
  if (!plan?.[0]) throw new Error("EXPLAIN returned no plan");
@@ -337,24 +338,29 @@ const KIT_MEMBERSHIPS = /better_supabase\s*\.\s*membership_claims\b/i;
337
338
  const PERMDOCK_CALL = /\bpermdock\w*\s*\(|permdock\s*\./i;
338
339
  /** Only PermDock's generated hook sets this claim, so it marks that hook's body. */
339
340
  const PERMDOCK_HOOK = /'\{\s*(?:claims\s*,\s*)?memberships_truncated\s*\}'/i;
340
- const escapeRegExp = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
341
- /** The claims PermDock's hook writes that a second writer would contradict. */
342
- function ownedClaims(tenant) {
343
- return [
341
+ const escapeRegExp$1 = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
342
+ /**
343
+ * The claims PermDock's hook writes that a second writer would contradict.
344
+ * The tenant claim is the manifest's when there is one, since that is the
345
+ * claim PermDock's hook really writes.
346
+ */
347
+ function ownedClaims(context) {
348
+ const manifest = context.permdock?.manifest;
349
+ const tenant = manifest?.rls?.tenantClaim ?? manifest?.tenantClaim ?? context.config.claims.tenant;
350
+ return [.../* @__PURE__ */ new Set([
344
351
  "roles",
345
352
  "user_role",
346
353
  "memberships",
347
- tenant
348
- ];
354
+ tenant,
355
+ context.config.claims.tenant
356
+ ])];
349
357
  }
350
- const quotedName = (schema, name) => `(?:"?${escapeRegExp(schema)}"?\\s*\\.\\s*)?"?${escapeRegExp(name)}"?`;
351
- /** A SQL file that starts with PermDock's hook marker and creates `fn`. */
358
+ const quotedName = (schema, name) => `(?:"?${escapeRegExp$1(schema)}"?\\s*\\.\\s*)?"?${escapeRegExp$1(name)}"?`;
359
+ const createsFunction = (fn) => new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${quotedName(fn.schema, fn.name)}\\s*\\(`, "i");
360
+ /** The SQL file that starts with PermDock's hook marker and creates `fn`. */
352
361
  function markerFileFor(context, fn) {
353
- const creates = new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${quotedName(fn.schema, fn.name)}\\s*\\(`, "i");
354
- for (const file of context.sqlFiles ?? []) {
355
- const marker = parseHookMarker(file.text);
356
- if (marker && creates.test(file.text)) return marker;
357
- }
362
+ const creates = createsFunction(fn);
363
+ return (context.sqlFiles ?? []).find((file) => parseHookMarker(file.text) && creates.test(file.text));
358
364
  }
359
365
  /** Whether `fn` is the hook PermDock generated: by its manifest, its marker file or its body. */
360
366
  function isPermdockHook(context, fn) {
@@ -373,7 +379,7 @@ function registeredClaims(context) {
373
379
  for (const claim of context.permdock?.manifest?.claims ?? []) if (claim.source !== "permdock") claims.set(claim.name, claim.source);
374
380
  if (claims.size > 0) return claims;
375
381
  const owned = /* @__PURE__ */ new Set([
376
- ...ownedClaims(context.config.claims.tenant),
382
+ ...ownedClaims(context),
377
383
  "memberships_truncated",
378
384
  "attrs",
379
385
  "authz_ver"
@@ -389,7 +395,7 @@ function wrapsPermdockHook(context, source) {
389
395
  /** Which of `claims` the hook body writes through `jsonb_set` or `jsonb_build_object`. */
390
396
  function writtenClaims(source, claims) {
391
397
  return claims.filter((claim) => {
392
- const name = escapeRegExp(claim);
398
+ const name = escapeRegExp$1(claim);
393
399
  return new RegExp(`jsonb_set\\s*\\([^;]*?'\\{\\s*(?:claims\\s*,\\s*)?${name}\\s*\\}'`, "i").test(source) || new RegExp(`jsonb_build_object\\s*\\([^;]*?'${name}'\\s*,`, "i").test(source);
394
400
  });
395
401
  }
@@ -435,18 +441,28 @@ function grantProblems(fn) {
435
441
  fix
436
442
  };
437
443
  }
438
- /** The `-- permdock:grants v1` migration that grants `fn`, if any. */
439
- function grantsMigrationFor(context, fn) {
440
- const grants = new RegExp(`grant\\s+execute\\s+on\\s+function\\s+${quotedName(fn.schema, fn.name)}\\s*\\(`, "i");
441
- return (context.sqlFiles ?? []).find((file) => parseGrantsMarker(file.text) && grants.test(file.text))?.path;
444
+ const MIGRATIONS = /(?:^|\/)supabase\/migrations\//;
445
+ /**
446
+ * The file that grants `fn` to `supabase_auth_admin`: a `-- permdock:grants
447
+ * v1` migration, or under pg-delta, which carries grants, the declarative
448
+ * schema file that defines `fn`. `supabase db diff` drops grants, so the
449
+ * legacy engine only counts the migration.
450
+ */
451
+ function grantsSourceFor(context, fn, engine) {
452
+ const grants = new RegExp(`grant\\s+execute\\s+on\\s+function\\s+${quotedName(fn.schema, fn.name)}\\s*\\([^)]*\\)\\s*to\\s+[^;]*\\bsupabase_auth_admin\\b`, "i");
453
+ const creates = createsFunction(fn);
454
+ return (context.sqlFiles ?? []).find((file) => grants.test(file.text) && (parseGrantsMarker(file.text) !== void 0 || engine === "pg-delta" && !MIGRATIONS.test(file.path) && creates.test(file.text)))?.path;
442
455
  }
443
456
  const PERMDOCK_GRANTS_COMMAND = "permdock supabase hook generate --grants-out supabase/migrations/<timestamp>_permdock_hook_grants.sql";
457
+ const permdockSchemaCommand = (file) => `permdock supabase hook generate --out ${file ?? "<the schema file that defines it>"}`;
444
458
  /** Configured hook functions whose grants are wrong (BS404, `doctor --fix-grants`). */
445
459
  function hookGrantProblems(context) {
460
+ const engine = diffEngine(context.configToml);
446
461
  return configuredHooks(context).flatMap(({ config, extras }) => (extras?.functions ?? []).flatMap((fn) => {
447
462
  const { problems, fix } = grantProblems(fn);
448
463
  if (problems.length === 0) return [];
449
- const file = grantsMigrationFor(context, fn);
464
+ const file = grantsSourceFor(context, fn, engine);
465
+ const hookFile = markerFileFor(context, fn)?.path;
450
466
  return [{
451
467
  hook: config.hook,
452
468
  fn,
@@ -454,7 +470,10 @@ function hookGrantProblems(context) {
454
470
  fix: file ? {
455
471
  kind: "migration",
456
472
  file
457
- } : isPermdockHook(context, fn) ? { kind: "permdock" } : {
473
+ } : isPermdockHook(context, fn) ? {
474
+ kind: "permdock",
475
+ ...hookFile ? { file: hookFile } : {}
476
+ } : {
458
477
  kind: "sql",
459
478
  sql: fix
460
479
  }
@@ -465,8 +484,8 @@ function hookGrantProblems(context) {
465
484
  function grantFixText(fix, engine) {
466
485
  switch (fix.kind) {
467
486
  case "sql": return engine === "pg-delta" ? `Add to the schema file that defines the function, then run \`supabase db schema declarative sync\` (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}` : `Append to the migration \`supabase db diff\` wrote (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}`;
468
- case "permdock": return `It is PermDock's hook, so let PermDock write the grants: \`${PERMDOCK_GRANTS_COMMAND}\`.`;
469
- case "migration": return `${fix.file} grants it, so the database is behind the migrations: apply them (\`supabase migration up\`).`;
487
+ case "permdock": return engine === "pg-delta" ? `It is PermDock's hook, so let PermDock write the grants into the schema file that defines it: \`${permdockSchemaCommand(fix.file)}\`, without \`--grants-out\` so pg-delta carries them, then run \`supabase db schema declarative sync\`.` : `It is PermDock's hook, so let PermDock write the grants: \`${PERMDOCK_GRANTS_COMMAND}\`.`;
488
+ case "migration": return engine === "pg-delta" ? `${fix.file} grants it, so the database is behind: run \`supabase db schema declarative sync\`, then apply the migrations (\`supabase migration up\`).` : `${fix.file} grants it, so the database is behind the migrations: apply them (\`supabase migration up\`).`;
470
489
  default: return fix;
471
490
  }
472
491
  }
@@ -481,10 +500,10 @@ function hookGrantBlock(problems, engine = "migra") {
481
500
  lines.push("", `-- ${name}`, ...fix.sql);
482
501
  break;
483
502
  case "permdock":
484
- lines.push("", `-- ${name} is PermDock's hook: ${PERMDOCK_GRANTS_COMMAND}`);
503
+ lines.push("", engine === "pg-delta" ? `-- ${name} is PermDock's hook: ${permdockSchemaCommand(fix.file)} (without --grants-out), then supabase db schema declarative sync` : `-- ${name} is PermDock's hook: ${PERMDOCK_GRANTS_COMMAND}`);
485
504
  break;
486
505
  case "migration":
487
- lines.push("", `-- ${name}: ${fix.file} grants it; run \`supabase migration up\`.`);
506
+ lines.push("", engine === "pg-delta" ? `-- ${name}: ${fix.file} grants it; run \`supabase db schema declarative sync\`, then \`supabase migration up\`.` : `-- ${name}: ${fix.file} grants it; run \`supabase migration up\`.`);
488
507
  break;
489
508
  default: return fix;
490
509
  }
@@ -495,8 +514,14 @@ const emptySearchPath = (fn) => {
495
514
  const value = fn.settings["search_path"];
496
515
  return value === "" || value === "\"\"" || value === "''";
497
516
  };
498
- async function hookClaimsSize(db, fn, userId) {
499
- const call = `${ident(fn.schema)}.${ident(fn.name)}`;
517
+ /** Auth's issuer shape (`{url}/auth/v1`); doctor has no project URL to put in it. */
518
+ const DOCTOR_ISSUER = "https://doctor.invalid/auth/v1";
519
+ /**
520
+ * Runs the hook as Auth does for `userId` (as `supabase_auth_admin` when the
521
+ * connecting role may switch to it), in a transaction that is rolled back.
522
+ * Undefined when `auth.users` has no such user.
523
+ */
524
+ async function withHookEvent(db, userId, run) {
500
525
  await db.query("begin");
501
526
  try {
502
527
  await db.query(`set local statement_timeout = '10s'`);
@@ -510,6 +535,10 @@ async function hookClaimsSize(db, fn, userId) {
510
535
  'user_metadata', coalesce(u.raw_user_meta_data, '{}'::jsonb),
511
536
  'aal', 'aal1', 'session_id', gen_random_uuid(),
512
537
  'is_anonymous', coalesce(u.is_anonymous, false),
538
+ 'amr', jsonb_build_array(jsonb_build_object(
539
+ 'method', 'password', 'timestamp', extract(epoch from now())::int8
540
+ )),
541
+ 'iss', ${literal(DOCTOR_ISSUER)},
513
542
  'iat', extract(epoch from now())::int8,
514
543
  'exp', extract(epoch from now())::int8 + 3600
515
544
  )
@@ -519,11 +548,28 @@ async function hookClaimsSize(db, fn, userId) {
519
548
  const [role] = await db.query(`select case when exists(select 1 from pg_roles where rolname = 'supabase_auth_admin')
520
549
  then pg_has_role('supabase_auth_admin', 'member') else false end as member`);
521
550
  if (role?.member) await db.query("set local role supabase_auth_admin");
551
+ return await run();
552
+ } finally {
553
+ await db.query("rollback");
554
+ }
555
+ }
556
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
557
+ const hookCall = (fn) => `${ident$1(fn.schema)}.${ident$1(fn.name)}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims'`;
558
+ /** The claims the hook returns for `userId`, or undefined without that user. */
559
+ async function hookClaims(db, fn, userId) {
560
+ return withHookEvent(db, userId, async () => {
561
+ const [row] = await db.query(`select ${hookCall(fn)} as claims`);
562
+ const claims = typeof row?.claims === "string" ? JSON.parse(row.claims) : row?.claims;
563
+ return isRecord(claims) ? claims : {};
564
+ });
565
+ }
566
+ async function hookClaimsSize(db, fn, userId) {
567
+ return withHookEvent(db, userId, async () => {
522
568
  const [row] = await db.query(`select octet_length(c::text) as bytes,
523
569
  octet_length((c -> 'memberships')::text) as memberships,
524
570
  octet_length((c -> 'attrs')::text) as attrs,
525
571
  c ->> 'memberships_truncated' = 'true' as truncated
526
- from (select ${call}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims' as c) h`);
572
+ from (select ${hookCall(fn)} as c) h`);
527
573
  const memberships = Number(row?.memberships ?? 0);
528
574
  return {
529
575
  bytes: Number(row?.bytes ?? 0),
@@ -531,9 +577,32 @@ async function hookClaimsSize(db, fn, userId) {
531
577
  budget: memberships + Number(row?.attrs ?? 0),
532
578
  truncated: row?.truncated === true
533
579
  };
534
- } finally {
535
- await db.query("rollback");
536
- }
580
+ });
581
+ }
582
+ const LOCAL_HOSTS = /* @__PURE__ */ new Set([
583
+ "localhost",
584
+ "127.0.0.1",
585
+ "[::1]",
586
+ "host.docker.internal"
587
+ ]);
588
+ /** The `secrets = ...` line of `[auth.hook.<hook>]` reads an `env()` value. */
589
+ function secretsFromEnv(text, hook) {
590
+ const start = text.search(new RegExp(`^\\s*\\[auth\\.hook\\.${hook}\\]`, "m"));
591
+ if (start < 0) return false;
592
+ const rest = text.slice(start).split("\n").slice(1);
593
+ const end = rest.findIndex((line) => /^\s*\[/.test(line));
594
+ const section = (end < 0 ? rest : rest.slice(0, end)).join("\n");
595
+ return /^\s*secrets\s*=\s*["']env\(/m.test(section);
596
+ }
597
+ /**
598
+ * What is wrong with one Standard Webhooks secret (`v1,whsec_<base64>`), the
599
+ * format Auth signs HTTP hook requests with. Never quotes the secret.
600
+ */
601
+ function secretProblem(secret) {
602
+ const match = /^v1,whsec_([A-Za-z0-9+/]+={0,2})$/.exec(secret.trim());
603
+ if (!match) return "is not in the `v1,whsec_<base64>` format";
604
+ const bytes = atob(match[1]).length;
605
+ return bytes < 24 || bytes > 64 ? `decodes to ${bytes} bytes; Standard Webhooks secrets are 24 to 64` : void 0;
537
606
  }
538
607
  const HOOK_RULES = [
539
608
  {
@@ -658,7 +727,7 @@ const HOOK_RULES = [
658
727
  if (fn.source === void 0 || isPermdockHook(context, fn)) continue;
659
728
  const kit = KIT_MEMBERSHIPS.test(fn.source);
660
729
  const wrapper = wrapsPermdockHook(context, fn.source);
661
- const written = writtenClaims(fn.source, [...ownedClaims(context.config.claims.tenant), ...wrapper ? registered.keys() : []]);
730
+ const written = writtenClaims(fn.source, [...ownedClaims(context), ...wrapper ? registered.keys() : []]);
662
731
  if (!kit && written.length === 0) continue;
663
732
  const permdock = project ? permdockSource(project) : PERMDOCK_CALL.test(fn.source) ? "the hook body" : void 0;
664
733
  if (!permdock) continue;
@@ -683,6 +752,363 @@ const HOOK_RULES = [
683
752
  }
684
753
  return findings;
685
754
  }
755
+ },
756
+ {
757
+ code: "BS410",
758
+ severity: "warning",
759
+ title: "HTTP auth hooks",
760
+ description: "Auth calls an `http://` or `https://` hook with a request signed by the Standard Webhooks secret in `secrets` (`v1,whsec_<base64>`, several joined with `|`). Doctor can't read the endpoint's code, so it reports each HTTP hook and checks what it can: the secret's format, that it comes from `env()` rather than the committed file, and that a non-local endpoint uses https. Verify the request in the endpoint with `authHook` from `better-supabase/webhooks`.",
761
+ check: (context) => {
762
+ const toml = context.configToml;
763
+ if (!toml) return [];
764
+ return httpHooks(toml.document).flatMap(({ hook, uri, secrets }) => {
765
+ const location = hookLocation(context, hook);
766
+ const at = location ? { location } : {};
767
+ const target = `[auth.hook.${hook}]`;
768
+ const findings = [{
769
+ severity: "info",
770
+ message: `${target} calls ${uri.origin}${uri.pathname} over HTTP. Doctor can't check its code; verify the signature there with authHook from better-supabase/webhooks.`,
771
+ target,
772
+ ...at
773
+ }];
774
+ if (uri.protocol === "http:" && !LOCAL_HOSTS.has(uri.hostname)) findings.push({
775
+ message: `${target} sends claims and its signature to ${uri.host} over plain http. Use https.`,
776
+ target,
777
+ ...at
778
+ });
779
+ const fromEnv = secretsFromEnv(toml.text, hook);
780
+ if (secrets === void 0 || secrets.trim() === "") {
781
+ findings.push({
782
+ severity: "error",
783
+ message: `${target} has no \`secrets\`, so Auth can't sign its requests. Set \`secrets = "env(AUTH_HOOK_SECRET)"\` with a \`v1,whsec_<base64>\` value.`,
784
+ target,
785
+ ...at
786
+ });
787
+ return findings;
788
+ }
789
+ if (!fromEnv) findings.push({
790
+ message: `${target} has its secret written in ${toml.path}, which is committed. Move it to an env file and read it with \`secrets = "env(AUTH_HOOK_SECRET)"\`.`,
791
+ target,
792
+ ...at
793
+ });
794
+ if (fromEnv && secrets.trim().startsWith("env(")) return findings;
795
+ const problems = secrets.split("|").flatMap((secret, index) => {
796
+ const problem = secretProblem(secret);
797
+ return problem ? [`secret ${index + 1} ${problem}`] : [];
798
+ });
799
+ if (problems.length > 0) findings.push({
800
+ severity: "error",
801
+ message: `${target}: ${problems.join("; ")}. Auth rejects the config; generate one with \`openssl rand -base64 32\` and prefix it with \`v1,whsec_\`.`,
802
+ target,
803
+ ...at
804
+ });
805
+ return findings;
806
+ });
807
+ }
808
+ }
809
+ ];
810
+ //#endregion
811
+ //#region src/cli/doctor/kits.ts
812
+ /** The non-empty string at a dotted path, as the tenant() plugin reads it. */
813
+ function claimAt(claims, path) {
814
+ let value = claims;
815
+ for (const segment of path.split(".")) value = isRecord(value) ? value[segment] : void 0;
816
+ return typeof value === "string" && value.length > 0 ? value : void 0;
817
+ }
818
+ function contractChecks(context) {
819
+ return customContracts(context.config.sql.kit, kitLayout(context.config)).flatMap((contract) => contract.functions.map((fn) => ({
820
+ module: contract.module,
821
+ schema: contract.schema,
822
+ name: fn.name,
823
+ args: contractSignature(fn, contract.idType),
824
+ returns: fn.returns.replaceAll("{id}", contract.idType)
825
+ })));
826
+ }
827
+ const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
828
+ const ident = (name) => `"?${escapeRegExp(name)}"?`;
829
+ /** Whether a `create function schema.name(` appears in the SQL files. */
830
+ function declared(context, check) {
831
+ const pattern = new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${ident(check.schema)}\\.${ident(check.name)}\\s*\\(`, "i");
832
+ return (context.sqlFiles ?? []).some((file) => pattern.test(file.text));
833
+ }
834
+ const normalize$1 = (types) => types.replaceAll(/\s+/g, " ").replaceAll(/\s*,\s*/g, ", ").trim();
835
+ const PRE_REQUEST_HOOK = `select h.hook,
836
+ coalesce(pg_catalog.strpos(pg_catalog.pg_get_functiondef(pg_catalog.to_regproc(h.hook)), 'better_supabase.check_request') > 0, false) as calls
837
+ from (
838
+ select (
839
+ select pg_catalog.split_part(setting, '=', 2)
840
+ from pg_catalog.pg_db_role_setting s
841
+ join pg_catalog.pg_roles r on r.oid = s.setrole
842
+ cross join lateral pg_catalog.unnest(s.setconfig) setting
843
+ where r.rolname = 'authenticator' and s.setdatabase = 0
844
+ and setting like 'pgrst.db_pre_request=%'
845
+ limit 1
846
+ ) as hook
847
+ ) h`;
848
+ /** `rate-limit` in `sql.kit` without PostgREST's pre-request hook reaching `check_request()`. */
849
+ async function rateLimitHook(context) {
850
+ const db = context.database;
851
+ if (!context.config.sql.kit.includes("rate-limit") || !db || "skipped" in db) return [];
852
+ let rows;
853
+ try {
854
+ rows = await db.query(PRE_REQUEST_HOOK);
855
+ } catch {
856
+ return [];
857
+ }
858
+ const [row] = rows;
859
+ if (!row || row.hook === "better_supabase.check_request" || row.calls) return [];
860
+ return [{
861
+ message: row.hook === null ? "pgrst.db_pre_request isn't set for authenticator, so better_supabase.check_request() never runs. Apply the migration `better-supabase sql data` writes, which sets it." : `pgrst.db_pre_request is ${row.hook}, which doesn't call better_supabase.check_request(), so rate limits never apply. Call it from ${row.hook}.`,
862
+ target: "authenticator pgrst.db_pre_request"
863
+ }];
864
+ }
865
+ /** Custom-mode modules whose contract functions the database or SQL files don't have. */
866
+ async function missingContracts(context) {
867
+ const checks = contractChecks(context);
868
+ if (checks.length === 0) return [];
869
+ const db = context.database;
870
+ const live = db && !("skipped" in db) ? db : void 0;
871
+ if (!live) return checks.filter((check) => !declared(context, check)).map((check) => ({
872
+ message: `kits.${check.module} is in custom mode, but no SQL file declares ${check.schema}.${check.name}(${check.args}). Write it, or switch the module to adopt or managed.`,
873
+ target: `${check.schema}.${check.name}`,
874
+ object: {
875
+ kind: "function",
876
+ schema: check.schema,
877
+ name: check.name
878
+ }
879
+ }));
880
+ const names = checks.map((check) => `(${literal(check.schema)}, ${literal(check.name)})`).join(", ");
881
+ let rows;
882
+ try {
883
+ rows = await live.query(`select n.nspname as schema, p.proname as name,
884
+ oidvectortypes(p.proargtypes) as args,
885
+ format_type(p.prorettype, null) as returns
886
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
887
+ where (n.nspname, p.proname) in (${names})`);
888
+ } catch (cause) {
889
+ return [{
890
+ severity: "info",
891
+ message: `Reading the custom kit contract functions failed: ${errorText(cause)}`,
892
+ target: "kits"
893
+ }];
894
+ }
895
+ return checks.flatMap((check) => {
896
+ const found = rows.filter((row) => row.schema === check.schema && row.name === check.name);
897
+ const target = `${check.schema}.${check.name}`;
898
+ const object = {
899
+ kind: "function",
900
+ schema: check.schema,
901
+ name: check.name
902
+ };
903
+ if (found.length === 0) return [{
904
+ message: `kits.${check.module} is in custom mode, but ${target}(${check.args}) doesn't exist. Write it, or switch the module to adopt or managed.`,
905
+ target,
906
+ object
907
+ }];
908
+ const match = found.find((row) => normalize$1(row.args) === normalize$1(check.args));
909
+ if (!match) return [{
910
+ message: `${target} takes (${found.map((row) => row.args).join(" | ")}); the ${check.module} contract calls it with (${check.args}).`,
911
+ target,
912
+ object
913
+ }];
914
+ if (normalize$1(match.returns) !== normalize$1(check.returns)) return [{
915
+ message: `${target}(${check.args}) returns ${match.returns}; the ${check.module} contract expects ${check.returns}.`,
916
+ target,
917
+ object
918
+ }];
919
+ return [];
920
+ });
921
+ }
922
+ /** With the tenant claim as the active tenant, the hook must put it in the token. */
923
+ async function missingTenantClaim(context) {
924
+ if (!context.config.sql.kit.includes("tenant")) return [];
925
+ if ((context.config.kits.access?.activeTenant ?? DEFAULT_ACTIVE_TENANT) !== "claim") return [];
926
+ const userId = context.hookUser;
927
+ const db = context.database;
928
+ if (!userId || !db || "skipped" in db || !db.session) return [];
929
+ const claim = context.config.claims.tenant;
930
+ const findings = [];
931
+ for (const { config, extras } of configuredHooks(context)) {
932
+ if (config.hook !== "custom_access_token" || !extras) continue;
933
+ for (const fn of extras.functions) {
934
+ const target = `${signatureOf(fn)}:${claim}`;
935
+ let claims;
936
+ try {
937
+ claims = await hookClaims(db, fn, userId);
938
+ } catch {
939
+ continue;
940
+ }
941
+ if (claims === void 0) continue;
942
+ if (tenantClaimPaths(claim).some((path) => claimAt(claims, path) !== void 0)) continue;
943
+ findings.push({
944
+ message: `${signatureOf(fn)} returns no \`${claim}\` claim for ${userId} (top level or app_metadata), and kits.access.activeTenant is 'claim', so current_tenant_id() is null and the tenant() plugin rejects their requests. Write the claim in the hook, or set kits.access.activeTenant to 'resolver' (with ServerOptions.tenant) or a profile column.`,
945
+ target,
946
+ object: {
947
+ kind: "function",
948
+ schema: fn.schema,
949
+ name: fn.name
950
+ }
951
+ });
952
+ }
953
+ }
954
+ return findings;
955
+ }
956
+ /** Where a deprecated symbol is used: a call, a qualified name or a claim lookup. */
957
+ function deprecationPattern(entry) {
958
+ const [first = "", second] = entry.symbol.split(".");
959
+ const qualified = second === void 0 ? ident(first) : `${ident(first)}\\.${ident(second)}`;
960
+ switch (entry.kind) {
961
+ case "function": return new RegExp(`${qualified}\\s*\\(`, "i");
962
+ case "table":
963
+ case "column": return new RegExp(`(?<![\\w."])${qualified}(?![\\w"])`, "i");
964
+ case "claim": {
965
+ const name = escapeRegExp(entry.symbol);
966
+ return new RegExp(`->>?\\s*'${name}'|#>>?\\s*'\\{[^}]*\\b${name}\\b[^}]*\\}'`, "i");
967
+ }
968
+ default: {
969
+ const never = entry.kind;
970
+ throw new TypeError(`Unknown deprecation kind ${String(never)}`);
971
+ }
972
+ }
973
+ }
974
+ const bareWord = (name) => new RegExp(`(?<![\\w"])${ident(name)}(?![\\w"])`, "i");
975
+ /**
976
+ * The line where `text` uses a deprecated symbol. A column also counts
977
+ * unqualified (`m.org_id`) in a statement that names its table, or in a
978
+ * policy on that table.
979
+ */
980
+ function lineOfUse(entry, text, policyTable) {
981
+ const direct = lineOf(text, deprecationPattern(entry));
982
+ if (direct !== void 0 || entry.kind !== "column") return direct;
983
+ const [table = "", column = ""] = entry.symbol.split(".");
984
+ let offset = 0;
985
+ for (const statement of text.split(";")) {
986
+ const named = policyTable === table || bareWord(table).test(statement);
987
+ const at = statement.search(bareWord(column));
988
+ if (named && at !== -1) return text.slice(0, offset + at).split("\n").length;
989
+ offset += statement.length + 1;
990
+ }
991
+ }
992
+ /** SQL files and policies that still use a symbol a kit module deprecated or removed. */
993
+ function deprecatedSymbols(context) {
994
+ const configured = new Set(Object.values(context.config.claims));
995
+ const entries = kitDeprecations().filter((entry) => context.config.sql.kit.includes(entry.module) && !(entry.kind === "claim" && configured.has(entry.symbol)) && !((entry.kind === "table" || entry.kind === "column") && (context.config.kits[entry.module]?.mode ?? "managed") !== "managed"));
996
+ if (entries.length === 0) return [];
997
+ const files = (context.sqlFiles ?? []).filter((file) => !file.path.startsWith("supabase/migrations/") && kitFileVersion(file.text) === void 0);
998
+ const findings = [];
999
+ for (const entry of entries) {
1000
+ const status = entry.removed === void 0 ? `deprecated since ${entry.since}` : `removed in ${entry.removed}`;
1001
+ const advice = `the ${entry.module} module's ${entry.kind} ${entry.symbol}, ${status}. Use ${entry.use}.`;
1002
+ for (const file of files) {
1003
+ const line = lineOfUse(entry, file.text);
1004
+ if (line === void 0) continue;
1005
+ findings.push({
1006
+ message: `${file.path} uses ${advice}`,
1007
+ target: `${file.path}:${entry.symbol}`,
1008
+ location: {
1009
+ file: file.path,
1010
+ line
1011
+ }
1012
+ });
1013
+ }
1014
+ for (const table of catalogOf(context).tables) for (const policy of table.policies) {
1015
+ if (lineOfUse(entry, `${policy.using ?? ""}\n${policy.check ?? ""}`, table.name) === void 0) continue;
1016
+ findings.push({
1017
+ message: `Policy "${policy.name}" on ${qualified(table)} uses ${advice}`,
1018
+ target: `${qualified(table)}.${policy.name}:${entry.symbol}`,
1019
+ object: policyObject(table, policy)
1020
+ });
1021
+ }
1022
+ }
1023
+ return findings;
1024
+ }
1025
+ /** Kit triggers and the equivalent triggers they replace. */
1026
+ const EQUIVALENT_TRIGGERS = [{
1027
+ trigger: "bs_updated_at",
1028
+ call: "track_updated_at",
1029
+ pattern: /updated_at|moddatetime|touch/i
1030
+ }, {
1031
+ trigger: "bs_audit",
1032
+ call: "audit",
1033
+ pattern: /audit/i
1034
+ }];
1035
+ /** Tables where a kit trigger and an older trigger do the same work. */
1036
+ function duplicateTriggers(context) {
1037
+ return catalogOf(context).tables.flatMap((table) => EQUIVALENT_TRIGGERS.flatMap(({ trigger, call, pattern }) => {
1038
+ if (!table.triggers.some((entry) => entry.name === trigger)) return [];
1039
+ return table.triggers.filter((entry) => entry.name !== trigger && pattern.test(entry.function.split(".").at(-1) ?? "")).map((entry) => ({
1040
+ message: `${qualified(table)} has ${trigger} and ${entry.name} (${entry.function}), so both run on every write. Drop ${entry.name}, or run \`select better_supabase.${call}('${qualified(table)}', replace_trigger => true)\` in a migration.`,
1041
+ target: `${qualified(table)}.${entry.name}`,
1042
+ object: tableObject(table)
1043
+ }));
1044
+ }));
1045
+ }
1046
+ /** `better_supabase` and every `kits.*.schema` that `[api] schemas` serves through the Data API. */
1047
+ function exposedKitSchemas(context) {
1048
+ if (context.config.sql.kit.length === 0) return [];
1049
+ const kitSchemas = /* @__PURE__ */ new Set(["better_supabase"]);
1050
+ for (const module of Object.values(context.config.kits)) if (module?.schema !== void 0) kitSchemas.add(module.schema);
1051
+ return exposedSchemas(context).filter((schema) => kitSchemas.has(schema)).map((schema) => ({
1052
+ message: `The Data API serves the kit schema ${schema}, so its tables and internal helpers are reachable over REST and RPC. Remove it from [api] schemas in supabase/config.toml (and the dashboard's exposed schemas), and call the kit functions through a wrapper in an exposed schema.`,
1053
+ target: schema
1054
+ }));
1055
+ }
1056
+ function migrationOptions(context) {
1057
+ const kit = new Set(context.config.sql.kit);
1058
+ return migrationOptionUses(context.config.kits).filter((use) => kit.has(use.module)).map((use) => ({
1059
+ message: `${use.message} It exists to adopt an existing schema; remove it once your data matches the managed default.`,
1060
+ target: `kits.${use.module}.options.${use.option}`
1061
+ }));
1062
+ }
1063
+ const KIT_RULES = [
1064
+ {
1065
+ code: "BS307",
1066
+ severity: "error",
1067
+ title: "Custom kit module without its contract",
1068
+ description: "A module in `kits` uses `mode: 'custom'`, so the app writes its contract functions. One is missing or has another signature, and the modules and TypeScript APIs that call it fail at run time.",
1069
+ check: missingContracts
1070
+ },
1071
+ {
1072
+ code: "BS308",
1073
+ severity: "warning",
1074
+ title: "Tenant claim the hook does not write",
1075
+ description: "With the `tenant` module and `kits.access.activeTenant: 'claim'`, `current_tenant_id()` and the `tenant()` plugin read the tenant from the `claims.tenant` claim, at the top level or in `app_metadata`. With `--as <user id>` doctor calls the custom access token hook for that user and warns when the claims it returns have neither. The default source, `'resolver'`, takes the tenant from the request instead.",
1076
+ check: missingTenantClaim
1077
+ },
1078
+ {
1079
+ code: "BS309",
1080
+ severity: "warning",
1081
+ title: "Deprecated SQL kit symbol",
1082
+ description: "A schema file or policy uses a function, table, column or claim that a kit module deprecated or removed. Deprecated symbols keep a wrapper for at least one minor release; removed ones fail at run time.",
1083
+ check: deprecatedSymbols
1084
+ },
1085
+ {
1086
+ code: "BS310",
1087
+ severity: "warning",
1088
+ title: "Duplicate kit trigger",
1089
+ description: "A table has a kit trigger (`bs_updated_at`, `bs_audit`) and another trigger that does the same work, so both run on every write.",
1090
+ check: duplicateTriggers
1091
+ },
1092
+ {
1093
+ code: "BS313",
1094
+ severity: "warning",
1095
+ title: "Rate limits not wired to PostgREST",
1096
+ description: "The `rate-limit` module is in `sql.kit`, but on the live database `pgrst.db_pre_request` for the `authenticator` role is unset or names a function that doesn't call `better_supabase.check_request()`, so Data API requests are never counted.",
1097
+ check: rateLimitHook
1098
+ },
1099
+ {
1100
+ code: "BS312",
1101
+ severity: "error",
1102
+ title: "Kit schema exposed through the Data API",
1103
+ description: "`[api] schemas` in `config.toml` lists `better_supabase` or a `kits.*.schema`. The kit schemas hold internal tables and helpers that are granted to `authenticated` for policies, so exposing them makes those callable over REST and RPC.",
1104
+ check: exposedKitSchemas
1105
+ },
1106
+ {
1107
+ code: "BS314",
1108
+ severity: "warning",
1109
+ title: "Migration-only kit option",
1110
+ description: "A module in adopt mode sets an option that only exists to match an existing schema: plain invitation tokens, webhook secrets in a column, non-text webhook ids, or a custom outbox source. Remove it once the data matches the managed default.",
1111
+ check: migrationOptions
686
1112
  }
687
1113
  ];
688
1114
  //#endregion
@@ -1401,6 +1827,56 @@ const DOCS_URL = "https://bettersupabase.com/docs/cli/doctor";
1401
1827
  const writes = (policy) => policy.command === "all" || policy.command === "insert" || policy.command === "update" || policy.command === "delete";
1402
1828
  const publicRoles = (policy) => policy.roles.length === 0 || policy.roles.some((role) => role === "public" || role === "anon");
1403
1829
  const isTrue = (expression) => expression !== null && /^\(*\s*true\s*\)*$/i.test(expression.trim());
1830
+ /** The `sql.kit` files as this release renders them, next to what is on disk. */
1831
+ async function kitFiles(context) {
1832
+ if (context.config.sql.kit.length === 0) return [];
1833
+ const readSets = context.readSets;
1834
+ const skipped = readSets !== void 0 && "skipped" in readSets;
1835
+ const files = renderKit(context.config.sql.kit, kitLayout(context.config, context.config.sql.testsDir, skipped ? [] : readSets, entitlementsKit(context))).filter((file) => !(skipped && file.module === "read-sets"));
1836
+ return Promise.all(files.map(async (file) => {
1837
+ const current = await readFile(resolve(context.config.root, file.path), "utf8").catch(() => void 0);
1838
+ const version = kitFileVersion(file.contents)?.version ?? 1;
1839
+ return {
1840
+ file,
1841
+ current,
1842
+ behind: current !== void 0 && (kitFileVersion(current)?.version ?? 1) < version
1843
+ };
1844
+ }));
1845
+ }
1846
+ async function kitVersionsBehind(context) {
1847
+ const findings = [];
1848
+ const upgrade = "Run `better-supabase sql upgrade`";
1849
+ for (const { file, current, behind } of await kitFiles(context)) {
1850
+ if (!behind || current === void 0) continue;
1851
+ findings.push({
1852
+ message: `${file.path} has ${file.module} version ${String(kitFileVersion(current)?.version ?? 1)}; this release ships version ${String(kitFileVersion(file.contents)?.version ?? 1)}. ${upgrade}, then \`${migrationCommand(context.configToml)}\`.`,
1853
+ target: file.path,
1854
+ location: {
1855
+ file: file.path,
1856
+ line: 1
1857
+ }
1858
+ });
1859
+ }
1860
+ const db = context.database;
1861
+ if (!db || "skipped" in db || context.config.sql.kit.length === 0) return findings;
1862
+ let rows;
1863
+ try {
1864
+ rows = await db.query("select name, version from better_supabase.kit_modules");
1865
+ } catch {
1866
+ return findings;
1867
+ }
1868
+ for (const row of rows) {
1869
+ const module = SQL_MODULES[row.name];
1870
+ if (!module || !context.config.sql.kit.includes(row.name)) continue;
1871
+ const version = moduleVersion(module);
1872
+ if (row.version >= version) continue;
1873
+ findings.push({
1874
+ message: `The database has ${row.name} version ${String(row.version)}; this release ships version ${String(version)}. ${upgrade} and apply the migrations it prints.`,
1875
+ target: `better_supabase.kit_modules.${row.name}`
1876
+ });
1877
+ }
1878
+ return findings;
1879
+ }
1404
1880
  function authSetting(context, key) {
1405
1881
  return context.configToml ? tomlGet(context.configToml.document, ["auth", key]) : void 0;
1406
1882
  }
@@ -1729,16 +2205,13 @@ const RULES = [
1729
2205
  title: "SQL kit files are out of date",
1730
2206
  description: "A module listed in `sql.kit` differs from the version in this release. Same check as `sql sync --check`.",
1731
2207
  check: async (context) => {
1732
- if (context.config.sql.kit.length === 0) return [];
1733
2208
  const stale = [];
1734
- const readSets = context.readSets;
1735
- const skipped = readSets !== void 0 && "skipped" in readSets;
1736
- for (const file of renderKit(context.config.sql.kit, kitLayout(context.config, context.config.sql.testsDir, skipped ? [] : readSets, entitlementsKit(context)))) {
1737
- if (skipped && file.module === "read-sets") continue;
1738
- const current = await readFile(resolve(context.config.root, file.path), "utf8").catch(() => void 0);
1739
- if (sameKitFile(current, file.contents)) continue;
2209
+ for (const { file, current, behind } of await kitFiles(context)) {
2210
+ if (behind || sameKitFile(current, file.contents)) continue;
2211
+ const edited = current === void 0 ? "" : ` If you edited it, move the change to \`kits.${file.module}\` in better-supabase.config.ts or to the module's SQL hooks: sync overwrites the file.`;
2212
+ const next = file.kind === "data" ? "Run `better-supabase sql sync`, which also writes the rows into a migration, since a schema diff skips them." : `Run \`better-supabase sql sync\`, then \`${migrationCommand(context.configToml)}\`.`;
1740
2213
  stale.push({
1741
- message: `${file.path} (${file.module}) is ${current === void 0 ? "missing" : "out of date"}. Run \`better-supabase sql sync\`, then \`${migrationCommand(context.configToml)}\`.`,
2214
+ message: `${file.path} (${file.module}) is ${current === void 0 ? "missing" : "out of date"}. ${next}${edited}`,
1742
2215
  target: file.path,
1743
2216
  ...current === void 0 ? {} : { location: {
1744
2217
  file: file.path,
@@ -1785,6 +2258,13 @@ const RULES = [
1785
2258
  }));
1786
2259
  }
1787
2260
  },
2261
+ {
2262
+ code: "BS311",
2263
+ severity: "warning",
2264
+ title: "SQL kit module behind its current version",
2265
+ description: "A module file or `better_supabase.kit_modules` records an older module version than this release ships. Same check as `sql upgrade --check`.",
2266
+ check: kitVersionsBehind
2267
+ },
1788
2268
  {
1789
2269
  code: "BS401",
1790
2270
  severity: "warning",
@@ -1891,7 +2371,8 @@ const RULES = [
1891
2371
  ...SCHEMA_DESIGN_RULES,
1892
2372
  ...HOOK_RULES,
1893
2373
  ...PERMDOCK_RULES,
1894
- ...LIVE_RULES
2374
+ ...LIVE_RULES,
2375
+ ...KIT_RULES
1895
2376
  ].sort((a, b) => byCodePoint(a.code, b.code));
1896
2377
  const RULE_CODES = RULES.map((rule) => rule.code);
1897
2378
  async function runRules(context, rules = RULES) {