better-supabase 0.2.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 (190) hide show
  1. package/README.md +37 -33
  2. package/dist/base64-D2gsioCY.js +42 -0
  3. package/dist/claims-BAf6c7is.js +48 -0
  4. package/dist/cli/_format-Cax5EsM9.js +101 -0
  5. package/dist/cli/bin.js +39 -6
  6. package/dist/cli/command-CK14fP91.js +502 -0
  7. package/dist/cli/compare-BmIWVEui.js +9 -0
  8. package/dist/cli/config-D9R0o1LH.js +1895 -0
  9. package/dist/cli/config-SSKSTUl2.js +2 -0
  10. package/dist/cli/db-DOBPt660.js +52 -0
  11. package/dist/cli/dist-B1MvLv-M.js +129 -0
  12. package/dist/cli/dist-CFgGknmZ.js +1112 -0
  13. package/dist/cli/dist-Du7VnEUG.js +621 -0
  14. package/dist/cli/doctor-BOym3mbL.js +2382 -0
  15. package/dist/cli/env-BZ0uy8XK.js +108 -0
  16. package/dist/cli/env-jIFUADLY.js +511 -0
  17. package/dist/cli/errors-4v4DTtda.js +56 -0
  18. package/dist/cli/fastest-levenshtein-CFN-lNDv.js +120 -0
  19. package/dist/cli/gen-Ccn_p90L.js +2008 -0
  20. package/dist/cli/gen-D5cLCULx.js +2 -0
  21. package/dist/cli/index.d.ts +295 -28
  22. package/dist/cli/index.js +184 -3
  23. package/dist/cli/init-ClnxRYBF.js +550 -0
  24. package/dist/cli/introspect-DxqfkVIv.js +81 -0
  25. package/dist/cli/io-k41_z-jT.js +24 -0
  26. package/dist/cli/json5-ChRycquj.js +787 -0
  27. package/dist/cli/jsonc-BUniUpOk.js +2 -0
  28. package/dist/cli/jsonc-D5Fu67jw.js +45 -0
  29. package/dist/cli/keys-ByKHnvP9.js +83 -0
  30. package/dist/cli/ohash-D7bG_EOb.js +277 -0
  31. package/dist/cli/openapi-8WHjxiTJ.js +66 -0
  32. package/dist/cli/project-B_GeXyHh.js +70 -0
  33. package/dist/cli/prompts-BNcNfAve.js +1142 -0
  34. package/dist/cli/prompts-BTYQGunc.js +2 -0
  35. package/dist/cli/read-sets-CMa29t9a.js +971 -0
  36. package/dist/cli/rolldown-runtime-BPOCksWG.js +24 -0
  37. package/dist/cli/run-CP20LKk_.js +347 -0
  38. package/dist/cli/seed-5J6eu_Xg.js +98 -0
  39. package/dist/cli/skills-LJJIhLFR.js +137 -0
  40. package/dist/cli/sql-D4VbqmXi.js +199 -0
  41. package/dist/cli/style-C0NFdJRL.js +29 -0
  42. package/dist/cli/toml-DB-MNnFs.js +424 -0
  43. package/dist/{version-Cqq1m3UY.js → cli/version-5mW_8sY1.js} +1 -1
  44. package/dist/cli/yaml-CYCbJ6et.js +2252 -0
  45. package/dist/client/index.d.ts +2 -2
  46. package/dist/client/index.js +15 -25
  47. package/dist/{compiler-CZ9nIuqy.d.ts → compiler-DB3aSFOA.d.ts} +2 -2
  48. package/dist/config/index.d.ts +4 -3
  49. package/dist/config/index.js +5 -3
  50. package/dist/config-BpxDfnbg.js +129 -0
  51. package/dist/{index-DOsjFvMf.d.ts → config-C1CYcm6o.d.ts} +49 -49
  52. package/dist/{config-ClDXmVtY.js → config-CbvFmGD8.js} +14 -137
  53. package/dist/{define-BXMYD9AY.js → define-Bh0AqTik.js} +221 -120
  54. package/dist/{define-e3zI4_4g.d.ts → define-CRS2MRV3.d.ts} +36 -31
  55. package/dist/edge/index.d.ts +9 -13
  56. package/dist/edge/index.js +51 -25
  57. package/dist/{entitlements-Dng-ctuT.d.ts → entitlements-Dm-FHpNH.d.ts} +2 -2
  58. package/dist/env/index.js +203 -1
  59. package/dist/{errors-Bl3rkOzo.js → errors-L0xK41y_.js} +1 -1
  60. package/dist/events/index.d.ts +1 -1
  61. package/dist/events/index.js +139 -1
  62. package/dist/{events-DOt3nSO-.d.ts → events-B21UOhvP.d.ts} +4 -4
  63. package/dist/{executor-CGdNQ27g.d.ts → executor-B5IoUxLE.d.ts} +2 -2
  64. package/dist/{executor-DeppvirK.js → executor-CVt6dD0B.js} +9 -4
  65. package/dist/{executor-BYXQ9AGn.d.ts → executor-CZ7ImRiD.d.ts} +2 -2
  66. package/dist/hono/index.d.ts +17 -24
  67. package/dist/hono/index.js +11 -11
  68. package/dist/{index-DuP14VHo.d.ts → index-528Ugmyp.d.ts} +30 -10
  69. package/dist/{index-Bw1bwtC0.d.ts → index-BAy4tsAP.d.ts} +28 -15
  70. package/dist/{index-CBDufH1M.d.ts → index-BBdvo-5d.d.ts} +24 -26
  71. package/dist/{index-DbxFr7XG.d.ts → index-BhHEC04r.d.ts} +9 -4
  72. package/dist/index-Cf-D2jk1.d.ts +50 -0
  73. package/dist/{index-Ciu5Xlml.d.ts → index-D5IfUIDh.d.ts} +6 -5
  74. package/dist/{index-K-3WM2Hp.d.ts → index-DFmOpBGC.d.ts} +13 -13
  75. package/dist/{index-D4v7sncu.d.ts → index-RlLXd8dL.d.ts} +13 -13
  76. package/dist/index.d.ts +17 -17
  77. package/dist/index.js +23 -14
  78. package/dist/{invalidate-DbxuL2jL.js → invalidate-CgxmRamI.js} +7 -1
  79. package/dist/jobs/index.d.ts +33 -26
  80. package/dist/jobs/index.js +44 -22
  81. package/dist/{json-schema-BpYqkCKf.js → json-schema-DP6V7cbr.js} +8 -3
  82. package/dist/lint/index.js +1 -1
  83. package/dist/list/index.d.ts +2 -2
  84. package/dist/list/index.js +131 -96
  85. package/dist/{live-DJyZMD6w.d.ts → live-BHakSWWo.d.ts} +26 -16
  86. package/dist/{live-D8rV1Ldw.js → live-CdJBOc4f.js} +13 -9
  87. package/dist/lookup-BaP5bI27.js +18 -0
  88. package/dist/mcp/index.d.ts +34 -27
  89. package/dist/mcp/index.js +59 -25
  90. package/dist/{mfa-TVia554c.d.ts → mfa-C7MNQ1Ob.d.ts} +1 -1
  91. package/dist/{mfa-CpLfJSde.js → mfa-byfjruR9.js} +190 -46
  92. package/dist/next/image/index.js +6 -3
  93. package/dist/next/index.d.ts +64 -48
  94. package/dist/next/index.js +63 -50
  95. package/dist/openapi/index.d.ts +1 -1
  96. package/dist/openapi/index.js +61 -38
  97. package/dist/orpc/index.d.ts +18 -21
  98. package/dist/orpc/index.js +12 -12
  99. package/dist/otel/index.d.ts +5 -5
  100. package/dist/otel/index.js +84 -63
  101. package/dist/{permdock-sql-CghlHPuH.js → permdock-sql-C4KFzLc9.js} +12 -7
  102. package/dist/permdock-sql-DImJULY-.d.ts +17 -0
  103. package/dist/{plugin-C4TR9J0Z.d.ts → plugin-bNhvJwRh.d.ts} +5 -4
  104. package/dist/plugins/actor/index.d.ts +1 -1
  105. package/dist/plugins/actor/index.js +1 -1
  106. package/dist/plugins/rules/index.d.ts +2 -2
  107. package/dist/plugins/rules/index.js +46 -16
  108. package/dist/plugins/soft-delete/index.d.ts +3 -3
  109. package/dist/plugins/soft-delete/index.js +4 -4
  110. package/dist/plugins/tenant/index.d.ts +3 -3
  111. package/dist/plugins/tenant/index.js +10 -4
  112. package/dist/plugins/timestamps/index.d.ts +1 -1
  113. package/dist/plugins/timestamps/index.js +2 -2
  114. package/dist/plugins/validation/index.d.ts +2 -2
  115. package/dist/plugins/validation/index.js +3 -3
  116. package/dist/pool-DFwEQwmN.d.ts +78 -0
  117. package/dist/postgres/index.d.ts +10 -5
  118. package/dist/postgres/index.js +37 -16
  119. package/dist/{postgrest-cAEQ_IQN.d.ts → postgrest-Bc-0Xw62.d.ts} +1 -1
  120. package/dist/{problem-BTkTtRV9.js → problem-BihjPR0t.js} +11 -5
  121. package/dist/{problem-DlgB0lYl.d.ts → problem-U8UEKccv.d.ts} +6 -4
  122. package/dist/query/index.d.ts +2 -2
  123. package/dist/query/index.js +2 -2
  124. package/dist/{query-ChvDbsDE.js → query-BMMSWbI7.js} +19 -19
  125. package/dist/react/index.d.ts +7 -7
  126. package/dist/react/index.js +2 -2
  127. package/dist/react/server.d.ts +6 -6
  128. package/dist/react/server.js +3 -3
  129. package/dist/react/session.d.ts +21 -2
  130. package/dist/react/session.js +19 -1
  131. package/dist/{react-C83BdBGt.js → react-CQHMaESR.js} +59 -55
  132. package/dist/{read-set-4lI3W_gZ.js → read-set-FWK8qQ58.js} +51 -16
  133. package/dist/realtime/index.d.ts +4 -3
  134. package/dist/realtime/index.js +19 -17
  135. package/dist/{index-CG69s15v.d.ts → refresh-BwYkyNOQ.d.ts} +7 -3
  136. package/dist/{resolve-CjHILk2w.d.ts → resolve-BAqQcwMW.d.ts} +17 -11
  137. package/dist/{resource-CgZa3YG_.d.ts → resource-BJcaIfxi.d.ts} +17 -8
  138. package/dist/{resource-BHEWmQJK.js → resource-My_y-Syh.js} +43 -21
  139. package/dist/{respond-Dne1ARSa.d.ts → respond-DgIbdrt8.d.ts} +39 -14
  140. package/dist/{result-DWXatkd6.js → result-Bj6yt33D.js} +2 -2
  141. package/dist/{result-CTkCZ6JA.d.ts → result-CqEsbgYs.d.ts} +16 -11
  142. package/dist/server/index.d.ts +38 -13
  143. package/dist/server/index.js +43 -14
  144. package/dist/{respond-DMcbQwfN.js → server-BA75B0mT.js} +151 -96
  145. package/dist/{shared-_6-uPL7E.js → shared-DnoCdMDr.js} +4 -3
  146. package/dist/{simplify-bwJpLnaS.js → simplify-Cqvfr9dj.js} +76 -39
  147. package/dist/sql/index.d.ts +139 -0
  148. package/dist/sql/index.js +1983 -0
  149. package/dist/{sql-DTjA1LPj.js → sql-B34HU2DI.js} +74 -17
  150. package/dist/ssr/index.d.ts +4 -4
  151. package/dist/ssr/index.js +2 -2
  152. package/dist/{standard-FUi3Z3-7.js → standard-BRyBGTtp.js} +2 -2
  153. package/dist/{standard-CyhRzctb.d.ts → standard-BsaCYQJc.d.ts} +1 -1
  154. package/dist/{stats-C5W-rBfR.js → stats-C_YxA2r1.js} +40 -14
  155. package/dist/storage/index.d.ts +4 -3
  156. package/dist/storage/index.js +548 -1
  157. package/dist/{tables-rZxRxNPW.js → tables-Cb-3_VWc.js} +19 -5
  158. package/dist/temporal-BwshQk0W.js +27 -0
  159. package/dist/temporal-required-CBNHT6ni.js +19 -0
  160. package/dist/testing/index.d.ts +25 -24
  161. package/dist/testing/index.js +165 -66
  162. package/dist/{types-C83v5SQz.d.ts → types-DONv9SHN.d.ts} +14 -11
  163. package/dist/version-Bmf7_FzM.js +4 -0
  164. package/dist/{view-D1s0wa0T.d.ts → view-B64o81wp.d.ts} +36 -6
  165. package/dist/{view-aOYizTeE.js → view-BrJI2LKp.js} +11 -2
  166. package/dist/webhooks/index.d.ts +19 -17
  167. package/dist/webhooks/index.js +1 -1
  168. package/dist/{webhooks-DMipjhSP.js → webhooks-Bh_Jf_-l.js} +45 -24
  169. package/package.json +50 -22
  170. package/schemas/config-v1.json +31 -1
  171. package/schemas/doctor-report-v1.json +17 -1
  172. package/schemas/snapshot-v2.json +35 -0
  173. package/skills/better-supabase/SKILL.md +77 -18
  174. package/skills/better-supabase/references/plugins.md +21 -22
  175. package/skills/better-supabase/references/troubleshooting.md +38 -18
  176. package/skills/better-supabase-api/SKILL.md +66 -18
  177. package/skills/better-supabase-api/references/adapters.md +64 -41
  178. package/skills/better-supabase-auth/SKILL.md +146 -0
  179. package/skills/better-supabase-auth/references/permdock.md +55 -0
  180. package/skills/better-supabase-testing/SKILL.md +68 -11
  181. package/dist/casing-da0uRTqt.js +0 -14
  182. package/dist/claims-CoTobNC-.js +0 -25
  183. package/dist/cli-DrSgDOQC.js +0 -6646
  184. package/dist/env-BEToab0I.js +0 -204
  185. package/dist/events-BuzTgn2D.js +0 -135
  186. package/dist/pool-R2m_DkXi.d.ts +0 -37
  187. package/dist/seed-CzecODcJ.js +0 -101
  188. package/dist/session-C3LxnQ0e.d.ts +0 -22
  189. package/dist/session-CqEwzVup.js +0 -20
  190. package/dist/storage-DuGn38fr.js +0 -527
@@ -0,0 +1,2382 @@
1
+ import { a as list, r as defineCliCommand } from "./command-CK14fP91.js";
2
+ import { n as CliError } from "./errors-4v4DTtda.js";
3
+ import { r as writeIfChanged } from "./io-k41_z-jT.js";
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";
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";
10
+ import { n as withSpinner } from "./prompts-BNcNfAve.js";
11
+ import { t as connect } from "./db-DOBPt660.js";
12
+ import { dirname, resolve } from "node:path";
13
+ import { existsSync } from "node:fs";
14
+ import { glob, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
15
+ import { kitLayout, renderKit, sameKitFile } from "better-supabase/sql";
16
+ import { createHash } from "node:crypto";
17
+ import { defineBucket, parseSize } from "better-supabase/storage";
18
+ //#region src/cli/doctor/advisors.ts
19
+ /**
20
+ * splinter (https://github.com/supabase/splinter), the linter behind the
21
+ * dashboard's Security and Performance Advisors. It has no license, so it is
22
+ * downloaded at this commit and checked against the hash instead of bundled.
23
+ */
24
+ const SPLINTER_COMMIT = "e74a9e36cb12258cb67d1464bc1cb196e9cd8446";
25
+ const SPLINTER_SHA256 = "d8d558baad3e03832e521c527907fa50a9a172fabd899dd0f5c2504a5a0e9349";
26
+ const SPLINTER_URL = `https://raw.githubusercontent.com/supabase/splinter/${SPLINTER_COMMIT}/splinter.sql`;
27
+ function isLint(value) {
28
+ const lint = value;
29
+ return typeof lint === "object" && lint !== null && typeof lint.name === "string" && typeof lint.level === "string" && Array.isArray(lint.categories);
30
+ }
31
+ /** The hosted project's advisors (`GET /v1/projects/{ref}/advisors/{category}`). */
32
+ function managementAdvisors(options) {
33
+ const base = (options.apiUrl ?? "https://api.supabase.com").replace(/\/$/, "");
34
+ const doFetch = options.fetch ?? fetch;
35
+ return {
36
+ describe: `project ${options.projectRef} advisors (Management API)`,
37
+ async lints(category) {
38
+ const url = `${base}/v1/projects/${encodeURIComponent(options.projectRef)}/advisors/${category}`;
39
+ const response = await doFetch(url, { headers: { authorization: `Bearer ${options.accessToken}` } });
40
+ const text = await response.text();
41
+ if (!response.ok) throw new Error(`Management API ${category} advisors failed (${response.status}): ${text.slice(0, 300)}`);
42
+ const body = JSON.parse(text);
43
+ if (!Array.isArray(body.lints)) throw new TypeError(`Management API ${category} advisors returned no "lints".`);
44
+ return body.lints.filter(isLint);
45
+ }
46
+ };
47
+ }
48
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
49
+ /** Downloads (once) and verifies the pinned `splinter.sql`. */
50
+ async function splinterSql(options) {
51
+ const file = resolve(options.cacheDir, `splinter-${SPLINTER_COMMIT}.sql`);
52
+ if (existsSync(file)) {
53
+ const cached = await readFile(file, "utf8");
54
+ if (sha256(cached) === SPLINTER_SHA256) return cached;
55
+ }
56
+ const response = await (options.fetch ?? fetch)(SPLINTER_URL);
57
+ if (!response.ok) throw new Error(`Downloading splinter.sql failed (${response.status}) from ${SPLINTER_URL}`);
58
+ const text = await response.text();
59
+ if (sha256(text) !== SPLINTER_SHA256) throw new Error(`splinter.sql from ${SPLINTER_URL} does not match the pinned hash; refusing to run it.`);
60
+ await mkdir(dirname(file), { recursive: true });
61
+ await writeFile(file, text);
62
+ return text;
63
+ }
64
+ /** `pg` returns one result per statement for a multi-statement query. */
65
+ function lintRows(result) {
66
+ const results = Array.isArray(result) ? result : [result];
67
+ return results.find((entry) => entry.fields?.some((field) => field.name === "cache_key"))?.rows ?? results.at(-1)?.rows ?? [];
68
+ }
69
+ /**
70
+ * Runs splinter against a database (local stack, `$DATABASE_URL` or `--db-url-stdin`) in a
71
+ * read-only transaction that is rolled back.
72
+ */
73
+ function splinterAdvisors(queryable, describe, options) {
74
+ let all;
75
+ const run = async () => {
76
+ const sql = await splinterSql(options);
77
+ return lintRows(await queryable.query(`begin read only;\n${sql}\n;\nrollback;`)).filter(isLint);
78
+ };
79
+ return {
80
+ describe: `${describe} (splinter ${SPLINTER_COMMIT.slice(0, 7)})`,
81
+ async lints(category) {
82
+ all ??= run();
83
+ const wanted = category.toUpperCase();
84
+ return (await all).filter((lint) => lint.categories.includes(wanted));
85
+ }
86
+ };
87
+ }
88
+ //#endregion
89
+ //#region src/cli/doctor/live.ts
90
+ /** Statements slower than this (ms, mean) with more calls than `SLOW_CALLS` are BS209. */
91
+ const SLOW_MEAN_MS = 50;
92
+ const SLOW_CALLS = 1e3;
93
+ /** Rows `--explain` reads, like a `findMany()` under Supabase's default `max_rows`. */
94
+ const EXPLAIN_LIMIT = 1e3;
95
+ const escape$2 = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
+ const ident = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
97
+ const literal = (text) => `'${text.replaceAll("'", "''")}'`;
98
+ const errorText = (cause) => cause instanceof Error ? cause.message : String(cause);
99
+ const oneLine = (query, max = 160) => {
100
+ const flat = query.replaceAll(/\s+/g, " ").trim();
101
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
102
+ };
103
+ const plural$1 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
104
+ const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
105
+ /** The schema `pg_stat_statements` lives in, when the extension is installed. */
106
+ async function statementsView(db) {
107
+ 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
+ }
110
+ /** The first exposed table a statement names, to locate it in the SQL files. */
111
+ function tableIn(query, tables) {
112
+ return tables.find((table) => {
113
+ const schema = escape$2(table.schema);
114
+ const name = escape$2(table.name);
115
+ return new RegExp(`(?:"${schema}"|\\b${schema})\\.(?:"${name}"|${name}\\b)`).test(query);
116
+ });
117
+ }
118
+ const live = (context) => context.database;
119
+ /**
120
+ * Function calls and self time so far in this transaction. The view can
121
+ * still hold unflushed counts from earlier transactions, so callers diff two
122
+ * readings.
123
+ */
124
+ async function functionTimes(db) {
125
+ const rows = await db.query(`select schemaname || '.' || funcname as name, calls, self_time from pg_stat_xact_user_functions`);
126
+ return new Map(rows.map((row) => [row.name, {
127
+ calls: Number(row.calls),
128
+ selfTime: Number(row.self_time)
129
+ }]));
130
+ }
131
+ /** Node types, timings and loops of an `EXPLAIN (ANALYZE, FORMAT JSON)` plan; never rows. */
132
+ function summarizePlan(output) {
133
+ const nodes = [];
134
+ const perRowSubPlans = [];
135
+ let initPlans = 0;
136
+ const walk = (node) => {
137
+ const loops = node["Actual Loops"] ?? 1;
138
+ const ms = (node["Actual Total Time"] ?? 0).toFixed(2);
139
+ const on = node["Relation Name"] ? ` on ${node["Relation Name"]}` : "";
140
+ const sub = node["Subplan Name"] ? `${node["Subplan Name"]}: ` : "";
141
+ nodes.push(`${sub}${node["Node Type"]}${on} ${ms} ms ×${loops}`);
142
+ if (node["Parent Relationship"] === "InitPlan") initPlans += 1;
143
+ if (node["Parent Relationship"] === "SubPlan" && loops > 1) perRowSubPlans.push(`${sub}${node["Node Type"]}${on} ×${loops}`);
144
+ for (const child of node.Plans ?? []) walk(child);
145
+ };
146
+ walk(output.Plan);
147
+ return {
148
+ nodes,
149
+ initPlans,
150
+ perRowSubPlans,
151
+ executionMs: output["Execution Time"] ?? 0
152
+ };
153
+ }
154
+ const ROLE = /^[a-z_][a-z0-9_]*$/;
155
+ async function explainTable(db, table, claims) {
156
+ const role = typeof claims["role"] === "string" ? claims["role"] : "anon";
157
+ if (!ROLE.test(role)) throw new Error(`Invalid role "${role}" in the claims`);
158
+ await db.query("begin");
159
+ try {
160
+ await db.query(`set local statement_timeout = '30s'`);
161
+ let tracked = true;
162
+ await db.query("savepoint bs_track");
163
+ try {
164
+ await db.query(`set local track_functions = 'all'`);
165
+ await db.query("release savepoint bs_track");
166
+ } catch {
167
+ tracked = false;
168
+ await db.query("rollback to savepoint bs_track");
169
+ }
170
+ await db.query(`select set_config('request.jwt.claims', ${literal(JSON.stringify(claims))}, true)`);
171
+ 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}`);
174
+ const raw = row?.["QUERY PLAN"];
175
+ const plan = typeof raw === "string" ? JSON.parse(raw) : raw;
176
+ if (!plan?.[0]) throw new Error("EXPLAIN returned no plan");
177
+ const functions = tracked ? [...await functionTimes(db)].map(([name, after]) => {
178
+ const start = before.get(name);
179
+ return {
180
+ name,
181
+ calls: after.calls - (start?.calls ?? 0),
182
+ selfTime: after.selfTime - (start?.selfTime ?? 0)
183
+ };
184
+ }).filter((fn) => fn.calls > 0).sort((a, b) => b.selfTime - a.selfTime) : void 0;
185
+ return {
186
+ summary: summarizePlan(plan[0]),
187
+ functions
188
+ };
189
+ } finally {
190
+ await db.query("rollback");
191
+ }
192
+ }
193
+ function resolveTable(context, name) {
194
+ return name.includes(".") ? catalogOf(context).tables.find((table) => qualified(table) === name) : exposed(context).find((table) => table.name === name);
195
+ }
196
+ const LIVE_RULES = [
197
+ {
198
+ code: "BS208",
199
+ severity: "warning",
200
+ title: "Queries spill to temporary files",
201
+ description: "Sorts and hashes that exceed `work_mem` write temporary files, which is slow. Reads `pg_stat_database` and, when installed, the top statements from `pg_stat_statements`.",
202
+ async check(context) {
203
+ const db = live(context);
204
+ if (!db || "skipped" in db) return [];
205
+ try {
206
+ const [stats] = await db.query(`select temp_files, temp_bytes, current_setting('work_mem') as work_mem from pg_stat_database where datname = current_database()`);
207
+ const files = Number(stats?.temp_files ?? 0);
208
+ if (files === 0) return [];
209
+ let top = "";
210
+ const view = await statementsView(db);
211
+ if (view) {
212
+ const statements = await db.query(`select query, temp_blks_written from ${view} where temp_blks_written > 0 order by temp_blks_written desc limit 5`).catch(() => []);
213
+ if (statements.length > 0) top = ` Top statements by temporary blocks written: ${statements.map((statement) => `${oneLine(statement.query, 100)} (${statement.temp_blks_written} blocks)`).join("; ")}.`;
214
+ }
215
+ return [{
216
+ message: `${files} temporary files (${megabytes(Number(stats?.temp_bytes ?? 0))}) since the statistics were reset; work_mem is ${stats?.work_mem}. Add indexes so large sorts go away, or raise work_mem for the role that runs them.${top}`,
217
+ target: "pg_stat_database:temp_files"
218
+ }];
219
+ } catch (cause) {
220
+ return [{
221
+ severity: "info",
222
+ message: `Could not read pg_stat_database (${db.describe}): ${errorText(cause)}`
223
+ }];
224
+ }
225
+ }
226
+ },
227
+ {
228
+ code: "BS209",
229
+ severity: "warning",
230
+ title: "Slow frequent statements",
231
+ description: `Statements from \`pg_stat_statements\` with a mean time above ${SLOW_MEAN_MS} ms and more than ${SLOW_CALLS} calls. Runs with \`--stats\`.`,
232
+ async check(context) {
233
+ if (!context.stats) return [];
234
+ const db = live(context);
235
+ if (!db) return [];
236
+ if ("skipped" in db) return [{
237
+ severity: "info",
238
+ message: `Skipped --stats: ${db.skipped}`
239
+ }];
240
+ try {
241
+ const view = await statementsView(db);
242
+ if (!view) return [{
243
+ severity: "info",
244
+ message: "pg_stat_statements is not installed, so --stats has nothing to read. Run `create extension pg_stat_statements with schema extensions;`."
245
+ }];
246
+ const statements = await db.query(`select queryid::text as queryid, query, calls, mean_exec_time from ${view} where calls > ${SLOW_CALLS} and mean_exec_time > ${SLOW_MEAN_MS} order by total_exec_time desc limit 20`);
247
+ const tables = exposed(context);
248
+ return statements.map((statement) => {
249
+ const table = tableIn(statement.query, tables);
250
+ return {
251
+ message: `${Number(statement.mean_exec_time).toFixed(1)} ms mean over ${statement.calls} calls: ${oneLine(statement.query)}`,
252
+ target: `pg_stat_statements:${statement.queryid}`,
253
+ ...table ? { object: tableObject(table) } : {}
254
+ };
255
+ });
256
+ } catch (cause) {
257
+ return [{
258
+ severity: "info",
259
+ message: `Could not read pg_stat_statements (${db.describe}): ${errorText(cause)}`
260
+ }];
261
+ }
262
+ }
263
+ },
264
+ {
265
+ code: "BS212",
266
+ severity: "info",
267
+ title: "RLS plan for a table",
268
+ description: `\`--explain <tables>\` runs \`EXPLAIN (ANALYZE, BUFFERS)\` on \`select * from <table> limit ${EXPLAIN_LIMIT}\` as the given claims, in a transaction that is rolled back. It reports node types, timings and loops, never rows, and warns when a policy SubPlan runs once per row.`,
269
+ async check(context) {
270
+ const request = context.explain;
271
+ if (!request) return [];
272
+ const db = live(context);
273
+ if (!db || "skipped" in db || !db.session) return [{
274
+ severity: "warning",
275
+ message: `--explain needs a direct database connection (local stack, $DATABASE_URL or --db-url-stdin)${db && "skipped" in db ? `: ${db.skipped}` : "."}`
276
+ }];
277
+ const who = typeof request.claims["sub"] === "string" ? `${String(request.claims["role"] ?? "anon")} ${request.claims["sub"]}` : String(request.claims["role"] ?? "anon");
278
+ const findings = [];
279
+ for (const name of request.tables) {
280
+ const table = resolveTable(context, name);
281
+ if (!table) {
282
+ findings.push({
283
+ severity: "warning",
284
+ message: `--explain: no table "${name}" in the exposed schemas; qualify tables in other schemas, like better_supabase.memberships.`
285
+ });
286
+ continue;
287
+ }
288
+ try {
289
+ const { summary, functions } = await explainTable(db, table, request.claims);
290
+ const helpers = functions ? functions.length > 0 ? ` Function time: ${functions.map((fn) => {
291
+ const self = fn.selfTime;
292
+ const share = summary.executionMs > 0 ? Math.round(self / summary.executionMs * 100) : 0;
293
+ return `${fn.name} ${self.toFixed(2)} ms over ${plural$1(fn.calls, "call")} (${share}%)`;
294
+ }).join(", ")}.` : " No tracked function ran; inlined SQL functions are part of the plan." : " Function times need `track_functions`, which this role may not set; showing the plan only.";
295
+ const perRow = summary.perRowSubPlans.length > 0 ? ` Per-row SubPlans: ${summary.perRowSubPlans.join(", ")}; wrap the policy's function calls in \`(select ...)\` so they run once as an InitPlan.` : "";
296
+ findings.push({
297
+ ...summary.perRowSubPlans.length > 0 ? { severity: "warning" } : {},
298
+ message: `${qualified(table)} as ${who}: ${summary.executionMs.toFixed(2)} ms, ${plural$1(summary.initPlans, "InitPlan")}. Plan: ${summary.nodes.join(" > ")}.${perRow}${helpers}`,
299
+ target: `${qualified(table)}:explain`,
300
+ object: tableObject(table)
301
+ });
302
+ } catch (cause) {
303
+ findings.push({
304
+ severity: "warning",
305
+ message: `--explain ${qualified(table)} failed: ${errorText(cause)}`,
306
+ target: `${qualified(table)}:explain`
307
+ });
308
+ }
309
+ }
310
+ return findings;
311
+ }
312
+ }
313
+ ];
314
+ //#endregion
315
+ //#region src/cli/doctor/hooks.ts
316
+ /** Claims past this size make every request carry a large cookie and header. */
317
+ const HOOK_CLAIMS_LIMIT = 2048;
318
+ /** PermDock's default `supabase.hook.budget`, over `memberships` plus `attrs`. */
319
+ const PERMDOCK_CLAIMS_LIMIT = 1024;
320
+ /**
321
+ * The BS405 limits. With PermDock, `doctor.claimsLimit` (else the manifest's
322
+ * budget) is its budget for `memberships` plus `attrs` and the whole token
323
+ * keeps 2048; without it, `doctor.claimsLimit` limits the whole token.
324
+ */
325
+ function claimsLimits(context) {
326
+ const configured = context.config.doctor.claimsLimit;
327
+ return context.permdock ? {
328
+ token: HOOK_CLAIMS_LIMIT,
329
+ budget: configured ?? context.permdock.manifest?.budget ?? PERMDOCK_CLAIMS_LIMIT
330
+ } : {
331
+ token: configured ?? HOOK_CLAIMS_LIMIT,
332
+ budget: void 0
333
+ };
334
+ }
335
+ /** The kit function that fills PermDock's `memberships` claim. */
336
+ const KIT_MEMBERSHIPS = /better_supabase\s*\.\s*membership_claims\b/i;
337
+ const PERMDOCK_CALL = /\bpermdock\w*\s*\(|permdock\s*\./i;
338
+ /** Only PermDock's generated hook sets this claim, so it marks that hook's body. */
339
+ 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 [
344
+ "roles",
345
+ "user_role",
346
+ "memberships",
347
+ tenant
348
+ ];
349
+ }
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`. */
352
+ 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
+ }
358
+ }
359
+ /** Whether `fn` is the hook PermDock generated: by its manifest, its marker file or its body. */
360
+ function isPermdockHook(context, fn) {
361
+ const hook = context.permdock?.manifest?.hook;
362
+ if (hook && hook.schema === fn.schema && hook.function === fn.name) return true;
363
+ if (markerFileFor(context, fn)) return true;
364
+ return fn.source !== void 0 && PERMDOCK_HOOK.test(fn.source);
365
+ }
366
+ /**
367
+ * Extra claims PermDock's hook writes from `supabase.hook.claims`, by claim
368
+ * name, with the function that fills each: from the manifest, else from the
369
+ * hook marker's `claims=` list.
370
+ */
371
+ function registeredClaims(context) {
372
+ const claims = /* @__PURE__ */ new Map();
373
+ for (const claim of context.permdock?.manifest?.claims ?? []) if (claim.source !== "permdock") claims.set(claim.name, claim.source);
374
+ if (claims.size > 0) return claims;
375
+ const owned = /* @__PURE__ */ new Set([
376
+ ...ownedClaims(context.config.claims.tenant),
377
+ "memberships_truncated",
378
+ "attrs",
379
+ "authz_ver"
380
+ ]);
381
+ for (const file of context.sqlFiles ?? []) for (const name of parseHookMarker(file.text)?.claims ?? []) if (!owned.has(name)) claims.set(name, void 0);
382
+ return claims;
383
+ }
384
+ /** Whether the hook body calls PermDock's generated hook (a wrapper around it). */
385
+ function wrapsPermdockHook(context, source) {
386
+ const hook = context.permdock?.manifest?.hook;
387
+ return hook !== void 0 && new RegExp(`${quotedName(hook.schema, hook.function)}\\s*\\(`, "i").test(source);
388
+ }
389
+ /** Which of `claims` the hook body writes through `jsonb_set` or `jsonb_build_object`. */
390
+ function writtenClaims(source, claims) {
391
+ return claims.filter((claim) => {
392
+ const name = escapeRegExp(claim);
393
+ 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
+ });
395
+ }
396
+ const API_ROLES$1 = ["authenticated", "anon"];
397
+ /** Hooks enabled in `config.toml`, matched with their introspected functions. */
398
+ function configuredHooks(context) {
399
+ if (!context.configToml) return [];
400
+ const introspected = context.snapshot.extras.hooks ?? [];
401
+ return pgFunctionHooks(context.configToml.document).map((config) => ({
402
+ config,
403
+ extras: introspected.find((hook) => hook.hook === config.hook && hook.schema === config.schema && hook.name === config.name)
404
+ }));
405
+ }
406
+ function hookLocation(context, hook) {
407
+ if (!context.configToml) return void 0;
408
+ const line = lineOf(context.configToml.text, new RegExp(`^\\s*\\[auth\\.hook\\.${hook}\\]`));
409
+ return line ? {
410
+ file: context.configToml.path,
411
+ line
412
+ } : void 0;
413
+ }
414
+ const signatureOf = (fn) => `${fn.schema}.${fn.name}(${fn.signature})`;
415
+ /** What is wrong with who may call a hook function, and the SQL that fixes it. */
416
+ function grantProblems(fn) {
417
+ const problems = [];
418
+ const fix = [];
419
+ if (!fn.schemaUsage.includes("supabase_auth_admin")) {
420
+ problems.push(`supabase_auth_admin has no usage on schema ${fn.schema}`);
421
+ fix.push(`grant usage on schema ${fn.schema} to supabase_auth_admin;`);
422
+ }
423
+ if (!fn.execute.includes("supabase_auth_admin")) {
424
+ problems.push("supabase_auth_admin may not execute it");
425
+ fix.push(`grant execute on function ${signatureOf(fn)} to supabase_auth_admin;`);
426
+ }
427
+ const exposedTo = API_ROLES$1.filter((role) => fn.execute.includes(role));
428
+ if (exposedTo.length > 0 || fn.publicExecute) {
429
+ const roles = [...exposedTo, ...fn.publicExecute ? ["public"] : []];
430
+ problems.push(`${roles.join(", ")} may execute it, so any client can call it through the API`);
431
+ fix.push(`revoke execute on function ${signatureOf(fn)} from authenticated, anon, public;`);
432
+ }
433
+ return {
434
+ problems,
435
+ fix
436
+ };
437
+ }
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;
442
+ }
443
+ const PERMDOCK_GRANTS_COMMAND = "permdock supabase hook generate --grants-out supabase/migrations/<timestamp>_permdock_hook_grants.sql";
444
+ /** Configured hook functions whose grants are wrong (BS404, `doctor --fix-grants`). */
445
+ function hookGrantProblems(context) {
446
+ return configuredHooks(context).flatMap(({ config, extras }) => (extras?.functions ?? []).flatMap((fn) => {
447
+ const { problems, fix } = grantProblems(fn);
448
+ if (problems.length === 0) return [];
449
+ const file = grantsMigrationFor(context, fn);
450
+ return [{
451
+ hook: config.hook,
452
+ fn,
453
+ problems,
454
+ fix: file ? {
455
+ kind: "migration",
456
+ file
457
+ } : isPermdockHook(context, fn) ? { kind: "permdock" } : {
458
+ kind: "sql",
459
+ sql: fix
460
+ }
461
+ }];
462
+ }));
463
+ }
464
+ /** The sentence that tells the reader how to apply `fix`. */
465
+ function grantFixText(fix, engine) {
466
+ switch (fix.kind) {
467
+ 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\`).`;
470
+ default: return fix;
471
+ }
472
+ }
473
+ /** `doctor --fix-grants`: the SQL block for every BS404 problem, as one migration snippet. */
474
+ function hookGrantBlock(problems, engine = "migra") {
475
+ if (problems.length === 0) return "-- Every configured Auth hook function has its grants.";
476
+ const lines = ["-- Auth hook grants (better-supabase doctor --fix-grants).", engine === "pg-delta" ? "-- Add this to the schema file that defines the function, then run `supabase db schema declarative sync`." : "-- `supabase db diff` does not carry function grants; append this to its migration."];
477
+ for (const { fn, hook, fix } of problems) {
478
+ const name = `${signatureOf(fn)} ([auth.hook.${hook}])`;
479
+ switch (fix.kind) {
480
+ case "sql":
481
+ lines.push("", `-- ${name}`, ...fix.sql);
482
+ break;
483
+ case "permdock":
484
+ lines.push("", `-- ${name} is PermDock's hook: ${PERMDOCK_GRANTS_COMMAND}`);
485
+ break;
486
+ case "migration":
487
+ lines.push("", `-- ${name}: ${fix.file} grants it; run \`supabase migration up\`.`);
488
+ break;
489
+ default: return fix;
490
+ }
491
+ }
492
+ return lines.join("\n");
493
+ }
494
+ const emptySearchPath = (fn) => {
495
+ const value = fn.settings["search_path"];
496
+ return value === "" || value === "\"\"" || value === "''";
497
+ };
498
+ async function hookClaimsSize(db, fn, userId) {
499
+ const call = `${ident(fn.schema)}.${ident(fn.name)}`;
500
+ await db.query("begin");
501
+ try {
502
+ await db.query(`set local statement_timeout = '10s'`);
503
+ const [user] = await db.query(`select set_config('better_supabase.hook_event', jsonb_build_object(
504
+ 'user_id', u.id,
505
+ 'authentication_method', 'password',
506
+ 'claims', jsonb_build_object(
507
+ 'aud', 'authenticated', 'role', 'authenticated', 'sub', u.id,
508
+ 'email', coalesce(u.email, ''), 'phone', coalesce(u.phone, ''),
509
+ 'app_metadata', coalesce(u.raw_app_meta_data, '{}'::jsonb),
510
+ 'user_metadata', coalesce(u.raw_user_meta_data, '{}'::jsonb),
511
+ 'aal', 'aal1', 'session_id', gen_random_uuid(),
512
+ 'is_anonymous', coalesce(u.is_anonymous, false),
513
+ 'iat', extract(epoch from now())::int8,
514
+ 'exp', extract(epoch from now())::int8 + 3600
515
+ )
516
+ )::text, true) as event
517
+ from auth.users u where u.id = ${literal(userId)}::uuid`);
518
+ if (!user?.event) return void 0;
519
+ const [role] = await db.query(`select case when exists(select 1 from pg_roles where rolname = 'supabase_auth_admin')
520
+ then pg_has_role('supabase_auth_admin', 'member') else false end as member`);
521
+ if (role?.member) await db.query("set local role supabase_auth_admin");
522
+ const [row] = await db.query(`select octet_length(c::text) as bytes,
523
+ octet_length((c -> 'memberships')::text) as memberships,
524
+ octet_length((c -> 'attrs')::text) as attrs,
525
+ c ->> 'memberships_truncated' = 'true' as truncated
526
+ from (select ${call}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims' as c) h`);
527
+ const memberships = Number(row?.memberships ?? 0);
528
+ return {
529
+ bytes: Number(row?.bytes ?? 0),
530
+ memberships,
531
+ budget: memberships + Number(row?.attrs ?? 0),
532
+ truncated: row?.truncated === true
533
+ };
534
+ } finally {
535
+ await db.query("rollback");
536
+ }
537
+ }
538
+ const HOOK_RULES = [
539
+ {
540
+ code: "BS404",
541
+ severity: "error",
542
+ title: "Auth hook function grants",
543
+ description: "Auth calls Postgres hook functions as `supabase_auth_admin`, which needs usage on the schema and execute on the function. Nobody else should be able to call them: a client calling the custom access token hook through the API sees what it adds for any user id.",
544
+ check: (context) => [...configuredHooks(context).flatMap(({ config, extras }) => {
545
+ if (!extras || extras.functions.length > 0) return [];
546
+ const location = hookLocation(context, config.hook);
547
+ return [{
548
+ message: `[auth.hook.${config.hook}] calls ${config.schema}.${config.name}, which does not exist. Sign-ins fail until it does.`,
549
+ target: `[auth.hook.${config.hook}]`,
550
+ ...location ? { location } : {}
551
+ }];
552
+ }), ...hookGrantProblems(context).map(({ hook, fn, problems, fix }) => ({
553
+ message: `${signatureOf(fn)} ([auth.hook.${hook}]): ${problems.join("; ")}. ${grantFixText(fix, diffEngine(context.configToml))}`,
554
+ target: signatureOf(fn),
555
+ object: {
556
+ kind: "function",
557
+ schema: fn.schema,
558
+ name: fn.name
559
+ }
560
+ }))]
561
+ },
562
+ {
563
+ code: "BS405",
564
+ severity: "warning",
565
+ title: "Custom access token hook shape",
566
+ description: `Auth runs the custom access token hook on every sign-in and refresh. It should be \`stable\` with \`set search_path = ''\`, and the claims it returns end up in every request's cookie and Authorization header. With \`--as <user id>\` doctor calls it for that user in a transaction that is rolled back. It warns when the whole token's claims pass ${HOOK_CLAIMS_LIMIT} bytes (\`doctor.claimsLimit\` without PermDock). With a \`permdock.config.ts\` it also warns when \`memberships\` plus \`attrs\` pass PermDock's budget, ${PERMDOCK_CLAIMS_LIMIT} bytes or \`doctor.claimsLimit\`, measured with \`octet_length\` as PermDock's hook measures it.`,
567
+ async check(context) {
568
+ const findings = [];
569
+ const limits = claimsLimits(context);
570
+ for (const { config, extras } of configuredHooks(context)) {
571
+ if (config.hook !== "custom_access_token" || !extras) continue;
572
+ for (const fn of extras.functions) {
573
+ const object = {
574
+ kind: "function",
575
+ schema: fn.schema,
576
+ name: fn.name
577
+ };
578
+ const problems = [];
579
+ if (fn.volatility !== "stable") problems.push(`it is ${fn.volatility}; declare it \`stable\``);
580
+ if (!emptySearchPath(fn)) problems.push("it has no `set search_path = ''`; add it and qualify every table");
581
+ if (problems.length > 0) findings.push({
582
+ message: `${signatureOf(fn)}: ${problems.join("; ")}.`,
583
+ target: signatureOf(fn),
584
+ object
585
+ });
586
+ if (!context.hookUser) continue;
587
+ const db = context.database;
588
+ if (!db || "skipped" in db || !db.session) {
589
+ findings.push({
590
+ severity: "info",
591
+ message: `Measuring the hook's claims needs a direct database connection (local stack, $DATABASE_URL or --db-url-stdin)${db && "skipped" in db ? `: ${db.skipped}` : "."}`,
592
+ target: `${signatureOf(fn)}:claims`
593
+ });
594
+ continue;
595
+ }
596
+ try {
597
+ const size = await hookClaimsSize(db, fn, context.hookUser);
598
+ if (size === void 0) {
599
+ findings.push({
600
+ severity: "info",
601
+ message: `--as ${context.hookUser}: no such user in auth.users, so the hook was not called.`,
602
+ target: `${signatureOf(fn)}:claims`
603
+ });
604
+ continue;
605
+ }
606
+ if (limits.budget !== void 0 && size.budget > limits.budget) findings.push({
607
+ message: `${signatureOf(fn)} returns ${size.budget} bytes of memberships and attrs for ${context.hookUser}, over PermDock's budget of ${limits.budget} (memberships ${size.memberships}). Raise \`supabase.hook.budget\` and \`doctor.claimsLimit\` together, or move attrs out of the token.`,
608
+ target: `${signatureOf(fn)}:budget`,
609
+ object
610
+ });
611
+ if (size.bytes > limits.token) findings.push({
612
+ message: `${signatureOf(fn)} returns ${size.bytes} bytes of claims for ${context.hookUser} (limit ${limits.token}, memberships ${size.memberships}). Every request carries them twice (cookie and header); keep ids and roles in the token and look up the rest.`,
613
+ target: `${signatureOf(fn)}:claims`,
614
+ object
615
+ });
616
+ if (size.truncated) findings.push({
617
+ severity: "info",
618
+ message: `${signatureOf(fn)} sets memberships_truncated for ${context.hookUser}: the token lists only some memberships (${size.memberships} bytes). Server checks for this user need a database lookup (PermDock's claimsFirst falls back to one).`,
619
+ target: `${signatureOf(fn)}:truncated`,
620
+ object
621
+ });
622
+ } catch (cause) {
623
+ findings.push({
624
+ message: `Calling ${signatureOf(fn)} for ${context.hookUser} failed: ${errorText(cause)}`,
625
+ target: `${signatureOf(fn)}:claims`,
626
+ object
627
+ });
628
+ }
629
+ }
630
+ }
631
+ return findings;
632
+ }
633
+ },
634
+ {
635
+ code: "BS407",
636
+ severity: "error",
637
+ title: "Two authorization hooks",
638
+ description: "A `permdock.config.ts` or `permdock.manifest.json` (or a hook that calls PermDock's functions) means PermDock writes `user_role`, `roles`, `memberships` and the tenant claim (`claims.tenant`) into the token. A custom access token hook that also calls `better_supabase.membership_claims`, or writes one of those claims itself, gives them a second source that drifts from PermDock's. Generate the hook with `permdock supabase hook generate` and drop the extra writes. Functions registered in PermDock's `supabase.hook.claims` (such as `better_supabase.feature_claims` for `features`) are PermDock's hook's own sources and are not reported; a hook that wraps PermDock's and writes one of those claims again is.",
639
+ check: (context) => {
640
+ const project = context.permdock;
641
+ const findings = [];
642
+ if (project) {
643
+ findings.push(...project.problems.filter((problem) => problem.startsWith(project.manifestPath)).map((problem) => ({
644
+ severity: "info",
645
+ message: `Could not read PermDock's manifest: ${problem}`,
646
+ target: project.manifestPath
647
+ })));
648
+ if (project.config && !project.manifest && !project.problems.some((problem) => problem.startsWith(project.manifestPath))) findings.push({
649
+ severity: "info",
650
+ message: `${project.config} is present but ${project.manifestPath} is not, so doctor and the SQL kit can't see PermDock's hook, helpers and membership sources. Run \`permdock supabase inspect --out\`, and \`permdock supabase inspect --check\` in CI.`,
651
+ target: project.manifestPath
652
+ });
653
+ }
654
+ const registered = registeredClaims(context);
655
+ for (const { config, extras } of configuredHooks(context)) {
656
+ if (config.hook !== "custom_access_token" || !extras) continue;
657
+ for (const fn of extras.functions) {
658
+ if (fn.source === void 0 || isPermdockHook(context, fn)) continue;
659
+ const kit = KIT_MEMBERSHIPS.test(fn.source);
660
+ const wrapper = wrapsPermdockHook(context, fn.source);
661
+ const written = writtenClaims(fn.source, [...ownedClaims(context.config.claims.tenant), ...wrapper ? registered.keys() : []]);
662
+ if (!kit && written.length === 0) continue;
663
+ const permdock = project ? permdockSource(project) : PERMDOCK_CALL.test(fn.source) ? "the hook body" : void 0;
664
+ if (!permdock) continue;
665
+ const location = hookLocation(context, config.hook);
666
+ const what = [...kit ? ["calls better_supabase.membership_claims"] : [], ...written.length > 0 ? [`writes ${written.join(", ")}`] : []].join(" and ");
667
+ const extra = written.filter((claim) => registered.has(claim));
668
+ const sources = extra.map((claim) => {
669
+ const source = registered.get(claim);
670
+ return source ? `${claim} from ${source}` : claim;
671
+ }).join(", ");
672
+ findings.push({
673
+ message: `${signatureOf(fn)} ${what}, and ${permdock} says PermDock owns those claims${extra.length > 0 ? ` (its hook already writes ${sources} through supabase.hook.claims)` : ""}. Keep one source: run \`permdock supabase hook generate\` and remove these writes from your hook.`,
674
+ target: signatureOf(fn),
675
+ object: {
676
+ kind: "function",
677
+ schema: fn.schema,
678
+ name: fn.name
679
+ },
680
+ ...location ? { location } : {}
681
+ });
682
+ }
683
+ }
684
+ return findings;
685
+ }
686
+ }
687
+ ];
688
+ //#endregion
689
+ //#region src/cli/doctor/rls.ts
690
+ const escape$1 = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
691
+ /** `'it''s'` and other string literals, blanked so their text can't match. */
692
+ const withoutStrings = (text) => text.replaceAll(/'(?:[^']|'')*'/g, "''");
693
+ const functionName = (fn) => `${fn.schema}.${fn.name}`;
694
+ const functionObject = (fn) => ({
695
+ kind: "function",
696
+ schema: fn.schema,
697
+ name: fn.name
698
+ });
699
+ /** The functions policies call, by `schema.name`; overloads share a key. */
700
+ function functionsByName(context) {
701
+ const map = /* @__PURE__ */ new Map();
702
+ for (const fn of context.snapshot.extras.functions ?? []) {
703
+ const key = functionName(fn);
704
+ const list = map.get(key);
705
+ if (list) list.push(fn);
706
+ else map.set(key, [fn]);
707
+ }
708
+ return map;
709
+ }
710
+ /** Argument lists of every call to `schema.name(...)` in an expression. */
711
+ function callArguments(expression, fn) {
712
+ if (!expression) return [];
713
+ const text = withoutStrings(expression);
714
+ const pattern = new RegExp(`(?<![\\w."])(?:"?${escape$1(fn.schema)}"?\\.)?"?${escape$1(fn.name)}"?\\s*\\(`, "g");
715
+ const calls = [];
716
+ for (const match of text.matchAll(pattern)) {
717
+ const start = match.index + match[0].length;
718
+ let depth = 1;
719
+ let end = start;
720
+ while (end < text.length && depth > 0) {
721
+ if (text[end] === "(") depth += 1;
722
+ else if (text[end] === ")") depth -= 1;
723
+ end += 1;
724
+ }
725
+ calls.push(text.slice(start, end - 1));
726
+ }
727
+ return calls;
728
+ }
729
+ /** The first column of `table` an argument list refers to, bare or as `table.column`. */
730
+ function columnArgument(args, table) {
731
+ return table.columns.find((column) => new RegExp(`(?<![\\w."$:])(?:"?${escape$1(table.name)}"?\\.)?"?${escape$1(column.name)}"?(?![\\w"(.$])`).test(args))?.name;
732
+ }
733
+ const expressions = (policy) => [policy.using, policy.check];
734
+ /**
735
+ * Why Postgres can't inline `fn` into the query that calls it, or
736
+ * `undefined` when it can: only `language sql` functions that are not
737
+ * volatile, not security definer and have no `set` options are inlined.
738
+ */
739
+ function notInlinable(fn) {
740
+ if (fn.language !== "sql") return `a ${fn.language} function`;
741
+ if (fn.volatility === "volatile") return "a volatile function";
742
+ if (fn.securityDefiner) return "a security definer function";
743
+ const settings = Object.keys(fn.settings);
744
+ if (settings.length > 0) return `a function with \`set ${settings.join(", ")}\``;
745
+ }
746
+ const rlsTables = (context) => exposed(context).filter((table) => table.rls);
747
+ const COMMANDS$1 = [
748
+ "select",
749
+ "insert",
750
+ "update",
751
+ "delete"
752
+ ];
753
+ /** Policies apply to every role when they list `public` or none. */
754
+ const appliesTo = (policy, role) => policy.roles.length === 0 || policy.roles.includes("public") || policy.roles.includes(role);
755
+ /** Commands and roles that more than one permissive policy of the table covers. */
756
+ function permissiveOverlaps(table) {
757
+ const permissive = table.policies.filter((policy) => policy.permissive);
758
+ const roles = /* @__PURE__ */ new Set();
759
+ for (const policy of permissive) {
760
+ const named = policy.roles.filter((role) => role !== "public");
761
+ if (named.length < policy.roles.length || policy.roles.length === 0) {
762
+ roles.add("anon");
763
+ roles.add("authenticated");
764
+ }
765
+ for (const role of named) roles.add(role);
766
+ }
767
+ const overlaps = [];
768
+ for (const command of COMMANDS$1) for (const role of [...roles].sort()) {
769
+ const policies = permissive.filter((policy) => (policy.command === "all" || policy.command === command) && appliesTo(policy, role)).map((policy) => policy.name);
770
+ if (policies.length > 1) overlaps.push({
771
+ command,
772
+ role,
773
+ policies
774
+ });
775
+ }
776
+ return overlaps;
777
+ }
778
+ const WRITES = ["insert", "update"];
779
+ const API_ROLES = ["anon", "authenticated"];
780
+ const NOT_ALIAS = /* @__PURE__ */ new Set([
781
+ "where",
782
+ "join",
783
+ "inner",
784
+ "left",
785
+ "right",
786
+ "full",
787
+ "cross",
788
+ "on",
789
+ "using",
790
+ "group",
791
+ "order",
792
+ "limit",
793
+ "union",
794
+ "natural",
795
+ "lateral",
796
+ "as"
797
+ ]);
798
+ /** The body of `schema.name` from the snapshot, else from the first SQL file that creates it. */
799
+ function functionBody(context, schema, name) {
800
+ const introspected = context.snapshot.generator.functions.find((fn) => fn.schema === schema && fn.name === name && fn.definition)?.definition;
801
+ if (introspected) return introspected;
802
+ const creates = new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+(?:"?${escape$1(schema)}"?\\.)?"?${escape$1(name)}"?\\s*\\([\\s\\S]*?\\bas\\s+(\\$\\w*\\$)([\\s\\S]*?)\\1`, "i");
803
+ for (const file of context.sqlFiles ?? []) {
804
+ const match = creates.exec(file.text);
805
+ if (match) return match[2];
806
+ }
807
+ }
808
+ const helpersByContext = /* @__PURE__ */ new WeakMap();
809
+ /** The functions policies call, and the functions those call, with their bodies. */
810
+ function policyHelpers(context) {
811
+ let helpers = helpersByContext.get(context);
812
+ if (!helpers) {
813
+ helpers = findPolicyHelpers(context);
814
+ helpersByContext.set(context, helpers);
815
+ }
816
+ return helpers;
817
+ }
818
+ function findPolicyHelpers(context) {
819
+ const known = context.snapshot.generator.functions;
820
+ const queue = catalogOf(context).tables.flatMap((table) => table.policies.flatMap((policy) => policy.functions ?? []));
821
+ const bodies = /* @__PURE__ */ new Map();
822
+ while (queue.length > 0) {
823
+ const key = queue.pop();
824
+ if (bodies.has(key)) continue;
825
+ const dot = key.indexOf(".");
826
+ const body = functionBody(context, key.slice(0, dot), key.slice(dot + 1));
827
+ if (!body) continue;
828
+ const text = withoutStrings(body);
829
+ bodies.set(key, text);
830
+ for (const fn of known) {
831
+ const called = `${fn.schema}.${fn.name}`;
832
+ if (!bodies.has(called) && callArguments(text, fn).length > 0) queue.push(called);
833
+ }
834
+ }
835
+ return bodies;
836
+ }
837
+ /** Tables a function body reads (`from` and `join`), with the names it refers to them by. */
838
+ function readTables(body, tables) {
839
+ const found = /* @__PURE__ */ new Map();
840
+ for (const match of body.matchAll(/\b(?:from|join)\s+(?:"?(\w+)"?\s*\.\s*)?"?(\w+)"?(?:\s+(?:as\s+)?"?(\w+)"?)?/gi)) {
841
+ const [, schema, name, alias] = match;
842
+ const table = tables.find((t) => t.name === name && t.schema === schema) ?? (schema ? void 0 : tables.find((t) => t.name === name && t.schema === "public") ?? tables.find((t) => t.name === name));
843
+ if (!table) continue;
844
+ const names = found.get(table) ?? /* @__PURE__ */ new Set([table.name]);
845
+ if (alias && !NOT_ALIAS.has(alias.toLowerCase())) names.add(alias);
846
+ found.set(table, names);
847
+ }
848
+ return [...found].map(([table, names]) => ({
849
+ table,
850
+ names: [...names]
851
+ }));
852
+ }
853
+ /**
854
+ * Columns that decide who may read other rows, by `schema.table`: the
855
+ * columns of the tables policy helpers read that their bodies mention
856
+ * (qualified, or bare when the body reads one table), and the manifest's
857
+ * `decidingColumns`.
858
+ */
859
+ function grantColumns(context) {
860
+ const tables = catalogOf(context).tables;
861
+ const columns = /* @__PURE__ */ new Map();
862
+ const entry = (table, column) => {
863
+ const byColumn = columns.get(table) ?? /* @__PURE__ */ new Map();
864
+ columns.set(table, byColumn);
865
+ const existing = byColumn.get(column);
866
+ if (existing) return existing;
867
+ const created = {
868
+ helpers: /* @__PURE__ */ new Set(),
869
+ permdock: false
870
+ };
871
+ byColumn.set(column, created);
872
+ return created;
873
+ };
874
+ for (const [helper, body] of policyHelpers(context)) {
875
+ const read = readTables(body, tables);
876
+ for (const { table, names } of read) for (const column of table.columns) {
877
+ const col = `"?${escape$1(column.name)}"?(?![\\w"(])`;
878
+ const qualifiedColumn = new RegExp(`(?<![\\w"])(?:${names.map((name) => `"?${escape$1(name)}"?`).join("|")})\\s*\\.\\s*${col}`, "i");
879
+ const bare = new RegExp(`(?<![\\w."])${col}`, "i");
880
+ if (qualifiedColumn.test(body) || read.length === 1 && bare.test(body)) entry(qualified(table), column.name).helpers.add(helper);
881
+ }
882
+ }
883
+ for (const deciding of context.permdock?.manifest?.decidingColumns ?? []) {
884
+ const dot = deciding.lastIndexOf(".");
885
+ if (dot > 0) entry(deciding.slice(0, dot), deciding.slice(dot + 1)).permdock = true;
886
+ }
887
+ return columns;
888
+ }
889
+ /** Whether `role` may run `command` on rows of `table` at all: no RLS, or a permissive policy for it. */
890
+ const policyAllows = (table, command, role) => !table.rls || table.policies.some((policy) => policy.permissive && (policy.command === "all" || policy.command === command) && appliesTo(policy, role));
891
+ /** The grant columns `role` may write with `command`, through a table-level or a column-level grant. */
892
+ function writableColumns(table, columns, command, role) {
893
+ if (!policyAllows(table, command, role)) return [];
894
+ const privilege = command.toUpperCase();
895
+ if (table.grants.some((grant) => grant.role === role && grant.privileges.includes(privilege))) return [...columns];
896
+ return columns.filter((column) => (table.columnGrants ?? []).some((grant) => grant.role === role && grant.column === column && grant.privileges.includes(privilege)));
897
+ }
898
+ /** PostgREST's default for `db-hoisted-tx-settings`. */
899
+ const DEFAULT_HOISTED = [
900
+ "statement_timeout",
901
+ "plan_filter.statement_cost_limit",
902
+ "default_transaction_isolation"
903
+ ];
904
+ const TIMEOUT_ROLES = [
905
+ "anon",
906
+ "authenticated",
907
+ "authenticator"
908
+ ];
909
+ const RLS_RULES = [
910
+ {
911
+ code: "BS205",
912
+ severity: "warning",
913
+ title: "Policy calls a slow function once per row",
914
+ description: "A policy passes a column of the row to a function Postgres cannot inline: plpgsql, volatile, security definer, or with a `set` option such as `search_path`. It runs once for every row the query reads.",
915
+ check: (context) => {
916
+ const functions = functionsByName(context);
917
+ return rlsTables(context).flatMap((table) => table.policies.flatMap((policy) => {
918
+ for (const name of policy.functions ?? []) {
919
+ const slow = functions.get(name)?.find((fn) => notInlinable(fn) !== void 0);
920
+ if (!slow) continue;
921
+ const column = expressions(policy).flatMap((expression) => callArguments(expression, slow)).map((args) => columnArgument(args, table)).find((found) => found !== void 0);
922
+ if (!column) continue;
923
+ const fix = slow.securityDefiner || Object.keys(slow.settings).length > 0 ? "" : `, or make it \`language sql stable\` so Postgres inlines it`;
924
+ return [{
925
+ message: `Policy "${policy.name}" on ${qualified(table)} calls ${name}(${column}), ${notInlinable(slow)}, with a column of the row, so it runs once per row. Have a helper return the allowed values once and compare: \`${column} in (select <helper>())\`${fix}.`,
926
+ target: `${qualified(table)}.${policy.name}`,
927
+ object: policyObject(table, policy)
928
+ }];
929
+ }
930
+ return [];
931
+ }));
932
+ }
933
+ },
934
+ {
935
+ code: "BS206",
936
+ severity: "warning",
937
+ title: "Security definer policy helper that cannot be inlined",
938
+ description: "A security definer function is used in many policies. Postgres never inlines a security definer function, so a call that takes a column of the row runs once per row. Wrapped in `(select ...)`, or returning a set the policy compares with `in (select ...)`, it runs once per statement.",
939
+ check: (context) => {
940
+ const limit = context.config.doctor.policyHelperLimit;
941
+ const functions = functionsByName(context);
942
+ const uses = /* @__PURE__ */ new Map();
943
+ for (const table of rlsTables(context)) for (const policy of table.policies) for (const name of policy.functions ?? []) {
944
+ const list = uses.get(name) ?? [];
945
+ list.push(`${qualified(table)}.${policy.name}`);
946
+ uses.set(name, list);
947
+ }
948
+ return [...uses].flatMap(([name, policies]) => {
949
+ if (policies.length <= limit) return [];
950
+ const fn = functions.get(name)?.find((candidate) => candidate.securityDefiner);
951
+ if (!fn) return [];
952
+ const shown = policies.slice(0, 5).join(", ");
953
+ const more = policies.length > 5 ? ` and ${policies.length - 5} more` : "";
954
+ return [{
955
+ message: `${name} is a security definer ${fn.language} function used in ${policies.length} policies (${shown}${more}; the limit is ${limit}). Postgres can't inline it, so call it as \`(select ${name}(...))\` when its arguments don't come from the row, or return the allowed ids as a set and compare with \`column in (select ${name}())\`, so it runs once per statement.`,
956
+ target: name,
957
+ object: functionObject(fn)
958
+ }];
959
+ });
960
+ }
961
+ },
962
+ {
963
+ code: "BS207",
964
+ severity: "warning",
965
+ title: "Several permissive policies for one command and role",
966
+ description: "Postgres evaluates every permissive policy that applies and ORs them, so each extra policy adds its cost to every row. Merge them into one policy per command and role. This replaces splinter's `multiple_permissive_policies` lint for the same table.",
967
+ check: (context) => rlsTables(context).flatMap((table) => {
968
+ const overlaps = permissiveOverlaps(table);
969
+ if (overlaps.length === 0) return [];
970
+ const first = table.policies.find((policy) => policy.name === overlaps[0].policies[0]);
971
+ const list = overlaps.map((overlap) => `${overlap.command} for ${overlap.role}: ${overlap.policies.join(", ")}`).join("; ");
972
+ return [{
973
+ message: `${qualified(table)} has several permissive policies per command and role (${list}). Postgres runs all of them for every row; merge each group into one policy.`,
974
+ target: qualified(table),
975
+ object: policyObject(table, first)
976
+ }];
977
+ })
978
+ },
979
+ {
980
+ code: "BS213",
981
+ severity: "warning",
982
+ title: "API roles can write the columns that grant access",
983
+ description: "RLS helpers decide access from columns such as `memberships.user_id`, `memberships.role` or `contacts.customer_id`, and PermDock's manifest lists them as `decidingColumns`. When `anon` or `authenticated` may insert or update one of them, and a policy lets them write the row, a user can grant themselves access. A table-level grant counts even after a column-level revoke.",
984
+ check: (context) => {
985
+ const tables = new Map(catalogOf(context).tables.map((table) => [qualified(table), table]));
986
+ return [...grantColumns(context)].flatMap(([name, byColumn]) => {
987
+ const table = tables.get(name);
988
+ if (!table) return [];
989
+ const columns = table.columns.map((column) => column.name).filter((column) => byColumn.has(column)).sort();
990
+ const writes = [];
991
+ const fix = [];
992
+ for (const role of API_ROLES) {
993
+ const tableLevel = [];
994
+ for (const command of WRITES) {
995
+ const writable = writableColumns(table, columns, command, role);
996
+ if (writable.length === 0) continue;
997
+ writes.push(`${role} may ${command} ${writable.join(", ")}`);
998
+ if (table.grants.some((grant) => grant.role === role && grant.privileges.includes(command.toUpperCase()))) tableLevel.push(command);
999
+ else fix.push(`revoke ${command} (${writable.join(", ")}) on ${name} from ${role};`);
1000
+ }
1001
+ if (tableLevel.length === 0) continue;
1002
+ fix.push(`revoke ${tableLevel.join(", ")} on ${name} from ${role};`);
1003
+ const rest = table.columns.map((column) => column.name).filter((column) => !byColumn.has(column));
1004
+ if (rest.length > 0) fix.push(`grant ${tableLevel.map((command) => `${command} (${rest.join(", ")})`).join(", ")} on ${name} to ${role};`);
1005
+ }
1006
+ if (writes.length === 0) return [];
1007
+ const helpers = [...new Set(columns.flatMap((column) => [...byColumn.get(column).helpers]))].sort();
1008
+ const permdock = columns.filter((column) => byColumn.get(column).permdock);
1009
+ const readers = [...helpers.length > 0 ? [`${helpers.join(", ")} read${helpers.length === 1 ? "s" : ""} them to decide access`] : [], ...permdock.length > 0 ? [`PermDock's manifest lists ${permdock.join(", ")} as deciding columns, which PD028 protects`] : []].join(", and ");
1010
+ return [{
1011
+ message: `${name}: ${writes.join("; ")}. ${readers}, so a user who writes them can grant themselves access. Revoke the columns and grant the rest:\n${fix.join("\n")}`,
1012
+ target: name,
1013
+ object: tableObject(table)
1014
+ }];
1015
+ });
1016
+ }
1017
+ },
1018
+ {
1019
+ code: "BS211",
1020
+ severity: "info",
1021
+ title: "Statement timeouts for the Data API roles",
1022
+ description: "PostgREST switches to `anon` or `authenticated` for each request, so their `statement_timeout` limits Data API queries. A function's own `set statement_timeout` only applies when PostgREST hoists it (`pgrst.db_hoisted_tx_settings`). `idle_in_transaction_session_timeout` ends sessions that leave a transaction open; it applies to the role a connection logs in as.",
1023
+ check: (context) => {
1024
+ const settings = context.snapshot.extras.roleSettings;
1025
+ if (!settings) return [];
1026
+ const values = (setting) => TIMEOUT_ROLES.map((role) => `${role} ${settings[role]?.[setting] ?? "not set"}`).join(", ");
1027
+ const findings = [{
1028
+ message: `statement_timeout: ${values("statement_timeout")}. idle_in_transaction_session_timeout: ${values("idle_in_transaction_session_timeout")}. Unset roles use the database default.`,
1029
+ target: "roles:statement_timeout"
1030
+ }];
1031
+ const configured = settings["authenticator"]?.["pgrst.db_hoisted_tx_settings"];
1032
+ if ((configured ? configured.split(",").map((setting) => setting.trim()) : DEFAULT_HOISTED).includes("statement_timeout")) return findings;
1033
+ for (const fn of context.snapshot.extras.functions ?? []) {
1034
+ const timeout = fn.settings["statement_timeout"];
1035
+ if (!timeout || !context.config.schemas.includes(fn.schema)) continue;
1036
+ findings.push({
1037
+ severity: "warning",
1038
+ message: `${functionName(fn)}(${fn.signature}) sets statement_timeout = ${timeout}, but pgrst.db_hoisted_tx_settings (${configured}) leaves it out, so the setting doesn't limit the RPC. Add statement_timeout to it, or set the timeout on the role.`,
1039
+ target: `${functionName(fn)}:statement_timeout`,
1040
+ object: functionObject(fn)
1041
+ });
1042
+ }
1043
+ return findings;
1044
+ }
1045
+ }
1046
+ ];
1047
+ //#endregion
1048
+ //#region src/cli/doctor/policies.ts
1049
+ const AUTH_ROLE = /\bauth"?\s*\.\s*"?role"?\s*\(\s*\)/i;
1050
+ /** A check of who is calling: the JWT, the user id, or the session role. */
1051
+ const CALLER_CHECK = /\bauth"?\s*\.\s*"?(?:uid|jwt)"?\s*\(|request\.jwt|\b(?:current_user|session_user|current_role)\b|has_org_role|member_org_ids/i;
1052
+ const USER_METADATA = /\buser_metadata\b/i;
1053
+ /** Privileges an API role never needs on a table. */
1054
+ const NEEDLESS = [
1055
+ "TRUNCATE",
1056
+ "REFERENCES",
1057
+ "TRIGGER"
1058
+ ];
1059
+ const policyText = (policy) => withoutStrings(`${policy.using ?? ""} ${policy.check ?? ""}`);
1060
+ /** A permissive policy lets `role` run `command` on the table. */
1061
+ const allows = (table, command, role) => table.policies.some((policy) => policy.permissive && (policy.command === "all" || policy.command === command) && appliesTo(policy, role));
1062
+ /** Policy commands on `storage.objects` the SQL files create, by policy name. */
1063
+ function storagePolicies(context) {
1064
+ const commands = /* @__PURE__ */ new Map();
1065
+ const pattern = /create\s+policy\s+("(?:[^"]|"")+"|\S+)\s+on\s+"?storage"?\s*\.\s*"?objects"?\b([^;]*)/gi;
1066
+ for (const file of context.sqlFiles ?? []) for (const match of withoutStrings(file.text).matchAll(pattern)) {
1067
+ const name = match[1];
1068
+ if (commands.has(name)) continue;
1069
+ const command = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(match[2])?.[1];
1070
+ commands.set(name, command?.toLowerCase() ?? "all");
1071
+ }
1072
+ return commands;
1073
+ }
1074
+ const POLICY_RULES = [
1075
+ {
1076
+ code: "BS109",
1077
+ severity: "warning",
1078
+ title: "auth.role() in a policy or function",
1079
+ description: "Supabase deprecated `auth.role()`. Scope the policy with `to authenticated`, or read the claim with `(select auth.jwt() ->> 'role')`.",
1080
+ check: (context) => {
1081
+ const findings = [];
1082
+ for (const table of catalogOf(context).tables) for (const policy of table.policies) {
1083
+ if (!AUTH_ROLE.test(policyText(policy))) continue;
1084
+ findings.push({
1085
+ message: `Policy "${policy.name}" on ${qualified(table)} calls auth.role(), which Supabase deprecated. Use \`to authenticated\` on the policy, or \`(select auth.jwt() ->> 'role')\`.`,
1086
+ target: `${qualified(table)}.${policy.name}`,
1087
+ object: policyObject(table, policy)
1088
+ });
1089
+ }
1090
+ const bodies = new Map(policyHelpers(context));
1091
+ for (const fn of context.snapshot.generator.functions) {
1092
+ if (!context.config.schemas.includes(fn.schema) || !fn.definition) continue;
1093
+ bodies.set(`${fn.schema}.${fn.name}`, withoutStrings(fn.definition));
1094
+ }
1095
+ for (const [name, body] of bodies) {
1096
+ if (!AUTH_ROLE.test(body)) continue;
1097
+ const dot = name.indexOf(".");
1098
+ findings.push({
1099
+ message: `${name} calls auth.role(), which Supabase deprecated. Read \`auth.jwt() ->> 'role'\` instead.`,
1100
+ target: name,
1101
+ object: {
1102
+ kind: "function",
1103
+ schema: name.slice(0, dot),
1104
+ name: name.slice(dot + 1)
1105
+ }
1106
+ });
1107
+ }
1108
+ return findings;
1109
+ }
1110
+ },
1111
+ {
1112
+ code: "BS110",
1113
+ severity: "warning",
1114
+ title: "Update policy without a select policy or with check",
1115
+ description: "An update has to find the rows first, so without a select policy for the same role it matches no rows and reports success. Without `with check`, Postgres checks the new row against `using`, which hides whether moving a row to another owner or tenant is allowed.",
1116
+ check: (context) => exposed(context).flatMap((table) => {
1117
+ if (!table.rls) return [];
1118
+ return table.policies.flatMap((policy) => {
1119
+ if (!policy.permissive || policy.command !== "update") return [];
1120
+ const blind = (policy.roles.length === 0 || policy.roles.includes("public") ? ["anon", "authenticated"] : policy.roles).filter((role) => !allows(table, "select", role));
1121
+ const target = `${qualified(table)}.${policy.name}`;
1122
+ if (blind.length > 0) return [{
1123
+ message: `Policy "${policy.name}" on ${qualified(table)} allows update for ${blind.join(", ")}, but no select policy does, so updates match no rows without an error. Add a select policy for ${blind.join(", ")}.`,
1124
+ target,
1125
+ object: policyObject(table, policy)
1126
+ }];
1127
+ if (policy.check !== null) return [];
1128
+ return [{
1129
+ severity: "info",
1130
+ message: `Policy "${policy.name}" on ${qualified(table)} has no \`with check\`, so Postgres checks updated rows against \`using\`. State the rule for the new row with \`with check (...)\`.`,
1131
+ target,
1132
+ object: policyObject(table, policy)
1133
+ }];
1134
+ });
1135
+ })
1136
+ },
1137
+ {
1138
+ code: "BS111",
1139
+ severity: "warning",
1140
+ title: "API roles hold privileges they don't use",
1141
+ description: "`anon` and `authenticated` never need `truncate`, `references` or `trigger`, and `truncate` skips RLS. A grant to `anon` on a table no policy opens to it does nothing except list the table in the Data API schema.",
1142
+ check: (context) => exposed(context).flatMap((table) => {
1143
+ if (table.kind !== "table") return [];
1144
+ const findings = [];
1145
+ for (const role of ["anon", "authenticated"]) {
1146
+ const privileges = new Set(table.grants.filter((grant) => grant.role === role).flatMap((grant) => grant.privileges));
1147
+ const needless = NEEDLESS.filter((privilege) => privileges.has(privilege));
1148
+ if (needless.length > 0) findings.push({
1149
+ message: `${role} has ${needless.join(", ").toLowerCase()} on ${qualified(table)}. \`truncate\` bypasses RLS. Run \`revoke ${needless.join(", ").toLowerCase()} on table ${qualified(table)} from ${role};\`.`,
1150
+ target: `${qualified(table)}:${role}`,
1151
+ object: tableObject(table)
1152
+ });
1153
+ if (role !== "anon" || !table.rls) continue;
1154
+ const unused = COMMANDS$1.filter((command) => privileges.has(command.toUpperCase()) && !allows(table, command, role));
1155
+ if (unused.length > 0) findings.push({
1156
+ severity: "info",
1157
+ message: `anon has ${unused.join(", ")} on ${qualified(table)}, but no policy allows it for anon, so the grant only lists the table in the Data API schema. Run \`revoke ${unused.join(", ")} on table ${qualified(table)} from anon;\`.`,
1158
+ target: `${qualified(table)}:anon:unused`,
1159
+ object: tableObject(table)
1160
+ });
1161
+ }
1162
+ return findings;
1163
+ })
1164
+ },
1165
+ {
1166
+ code: "BS112",
1167
+ severity: "warning",
1168
+ title: "Exposed security definer function that doesn't check the caller",
1169
+ description: "A `security definer` function in an exposed schema runs as its owner, past RLS, and the Data API serves it at `/rpc`. When anon or authenticated may execute it and its body never reads `auth.uid()` or `auth.jwt()`, any caller gets the owner's access. Check the caller, revoke `execute`, or move it to a schema the API doesn't expose.",
1170
+ check: (context) => (context.snapshot.extras.functions ?? []).flatMap((fn) => {
1171
+ if (!fn.securityDefiner || !context.config.schemas.includes(fn.schema)) return [];
1172
+ const callers = fn.execute ?? [];
1173
+ if (callers.length === 0) return [];
1174
+ const body = functionBody(context, fn.schema, fn.name);
1175
+ if (body === void 0 || CALLER_CHECK.test(body)) return [];
1176
+ return [{
1177
+ message: `${fn.schema}.${fn.name}(${fn.signature}) is security definer, ${callers.join(" and ")} may execute it through /rpc, and it never checks auth.uid() or auth.jwt(). Check the caller, run \`revoke execute on function ${fn.schema}.${fn.name}(${fn.signature}) from ${callers.join(", ")};\`, or move it to a private schema.`,
1178
+ target: `${fn.schema}.${fn.name}`,
1179
+ object: functionObject(fn)
1180
+ }];
1181
+ })
1182
+ },
1183
+ {
1184
+ code: "BS113",
1185
+ severity: "info",
1186
+ title: "Storage upload policy without the upsert policies",
1187
+ description: "An upload with `upsert: true` (`x-upsert`) needs `select` and `update` policies on `storage.objects` besides `insert`; without them, replacing an existing file fails with a 403. Doctor reads the policies in `supabase/schemas` and the migrations.",
1188
+ check: (context) => {
1189
+ const commands = new Set(storagePolicies(context).values());
1190
+ if (!commands.has("insert") || commands.has("all")) return [];
1191
+ const missing = ["select", "update"].filter((command) => !commands.has(command));
1192
+ if (missing.length === 0) return [];
1193
+ return [{
1194
+ message: `storage.objects has an insert policy but no ${missing.join(" or ")} policy, so uploads with \`upsert: true\` fail with 403 when the file exists. Add ${missing.join(" and ")} policies with the same bucket and path check.`,
1195
+ target: "storage.objects"
1196
+ }];
1197
+ }
1198
+ },
1199
+ {
1200
+ code: "BS114",
1201
+ severity: "error",
1202
+ title: "Policy helper reads user_metadata",
1203
+ description: "Users can change their own `raw_user_meta_data` with `auth.updateUser()`, so a helper that reads `user_metadata` to decide access lets them grant themselves rights. Read `app_metadata`, which only the service role can write. Splinter's `rls_references_user_metadata` covers the policy text; this covers the functions policies call.",
1204
+ check: (context) => [...policyHelpers(context).keys()].flatMap((name) => {
1205
+ const dot = name.indexOf(".");
1206
+ const body = functionBody(context, name.slice(0, dot), name.slice(dot + 1));
1207
+ if (!body || !USER_METADATA.test(body)) return [];
1208
+ return [{
1209
+ message: `${name} reads user_metadata, which users can write themselves, and policies call it. Read \`auth.jwt() -> 'app_metadata'\` instead.`,
1210
+ target: name,
1211
+ object: {
1212
+ kind: "function",
1213
+ schema: name.slice(0, dot),
1214
+ name: name.slice(dot + 1)
1215
+ }
1216
+ }];
1217
+ })
1218
+ },
1219
+ {
1220
+ code: "BS215",
1221
+ severity: "warning",
1222
+ title: "Policy calls a helper per row that could run once",
1223
+ description: "A call such as `current_tenant_id()` takes no column of the row, so its result is the same for every row. Wrapped as `(select current_tenant_id())`, Postgres runs it once per statement as an InitPlan. Splinter's `auth_rls_initplan` covers `auth.*` calls; this covers your own helpers.",
1224
+ check: (context) => {
1225
+ const volatility = new Map((context.snapshot.extras.functions ?? []).map((fn) => [`${fn.schema}.${fn.name}`, fn.volatility]));
1226
+ return exposed(context).flatMap((table) => {
1227
+ if (!table.rls) return [];
1228
+ return table.policies.flatMap((policy) => {
1229
+ const text = policyText(policy);
1230
+ const bare = (policy.functions ?? []).filter((name) => {
1231
+ const dot = name.indexOf(".");
1232
+ const schema = name.slice(0, dot);
1233
+ if (schema === "auth" || volatility.get(name) === "immutable") return false;
1234
+ return new RegExp(`(?<!\\bselect\\s+)(?<![\\w."])(?:"?${escape$1(schema)}"?\\.)?"?${escape$1(name.slice(dot + 1))}"?\\s*\\(\\s*\\)`, "i").test(text);
1235
+ });
1236
+ if (bare.length === 0) return [];
1237
+ return [{
1238
+ message: `Policy "${policy.name}" on ${qualified(table)} calls ${bare.map((name) => `${name}()`).join(", ")} without \`(select ...)\`, so it runs once per row. Write \`(select ${bare[0]}())\`.`,
1239
+ target: `${qualified(table)}.${policy.name}`,
1240
+ object: policyObject(table, policy)
1241
+ }];
1242
+ });
1243
+ });
1244
+ }
1245
+ }
1246
+ ];
1247
+ //#endregion
1248
+ //#region src/cli/doctor/schema-design.ts
1249
+ /** Whether the first `columns.length` columns of a non-partial index are exactly `columns`, in any order. */
1250
+ const covers = (index, columns) => !index.partial && index.columns.length >= columns.length && index.columns.slice(0, columns.length).every((column) => columns.includes(column));
1251
+ /** Foreign keys of `table` that no index starts with. */
1252
+ const unindexedForeignKeys = (table) => table.foreignKeys.filter((key) => !table.indexes.some((index) => covers(index, key.columns)));
1253
+ const SOFT_DELETE = /^(?:deleted|archived)_at$/;
1254
+ /** Operators that need a GIN index on jsonb or array columns. */
1255
+ const CONTAINS = String.raw`(?:@>|<@|\?\||\?&|\?|&&)`;
1256
+ /** `.contains("tags"`, `.containedBy('meta'`, `.overlaps("labels"`, supabase-js filters. */
1257
+ const CLIENT_CONTAINS = /\.(?:contains|containedBy|overlaps)\(\s*["'`]([A-Za-z_]\w*)["'`]/g;
1258
+ /** Column types Postgres' wiki and the Supabase skills advise against. */
1259
+ const AVOID = {
1260
+ timestamp: "timestamptz",
1261
+ varchar: "text with a check constraint",
1262
+ bpchar: "text",
1263
+ money: "numeric",
1264
+ json: "jsonb"
1265
+ };
1266
+ /** A Supabase direct (`db.<ref>.supabase.co`) or session-pooler (`:5432`) connection string. */
1267
+ const DIRECT_URL = /postgres(?:ql)?:\/\/[^\s"'`]*@(?:db\.[a-z0-9]+\.supabase\.co(?::5432)?|[\w.-]+\.pooler\.supabase\.com:5432)\b/i;
1268
+ /** Files that show the app runs on serverless or edge functions. */
1269
+ const SERVERLESS = /\bexport\s+(?:const\s+runtime|async\s+function\s+(?:GET|POST|PUT|PATCH|DELETE)|default\s+\{\s*(?:async\s+)?fetch)\b|\bDeno\.serve\(/;
1270
+ function lines(file) {
1271
+ return file.text.split("\n").map((text, index) => ({
1272
+ line: index + 1,
1273
+ text
1274
+ }));
1275
+ }
1276
+ const SCHEMA_DESIGN_RULES = [
1277
+ {
1278
+ code: "BS216",
1279
+ severity: "info",
1280
+ title: "Foreign key without an index",
1281
+ description: "Deletes and updates on the referenced table scan the referencing table for each row, and joins on the key can't use an index. Add an index that starts with the key's columns. This replaces splinter's `unindexed_foreign_keys` for the same table and also runs on a saved snapshot.",
1282
+ check: (context) => exposed(context).flatMap((table) => {
1283
+ if (table.kind !== "table") return [];
1284
+ return unindexedForeignKeys(table).map((key) => ({
1285
+ message: `${qualified(table)}.${key.name} (${key.columns.join(", ")}) has no index that starts with its columns, so deletes on ${key.refSchema}.${key.refTable} scan ${qualified(table)}. Run \`create index on ${qualified(table)} (${key.columns.join(", ")});\`.`,
1286
+ target: `${qualified(table)}.${key.name}`,
1287
+ object: tableObject(table)
1288
+ }));
1289
+ })
1290
+ },
1291
+ {
1292
+ code: "BS217",
1293
+ severity: "warning",
1294
+ title: "Tenant foreign key that can cross tenants",
1295
+ description: "When a tenant table references another tenant table by `id` alone, a row can point at a parent in another organization; RLS checks each row, not the pair. Reference `(id, organization_id)` with a composite foreign key, backed by `unique (id, organization_id)` on the parent.",
1296
+ check: (context) => {
1297
+ const column = context.config.plugins.tenant?.column;
1298
+ if (!column) return [];
1299
+ const tables = new Map(catalogOf(context).tables.map((table) => [qualified(table), table]));
1300
+ const hasTenant = (table) => table?.columns.some((entry) => entry.name === column) ?? false;
1301
+ return exposed(context).flatMap((table) => {
1302
+ if (table.kind !== "table" || !hasTenant(table)) return [];
1303
+ return table.foreignKeys.flatMap((key) => {
1304
+ if (key.columns.includes(column)) return [];
1305
+ const parent = tables.get(`${key.refSchema}.${key.refTable}`);
1306
+ if (!hasTenant(parent) || parent === table) return [];
1307
+ return [{
1308
+ message: `${qualified(table)}.${key.name} references ${key.refSchema}.${key.refTable} (${key.refColumns.join(", ")}) without ${column}, so a row can point at another tenant's ${key.refTable}. Add \`unique (${[...key.refColumns, column].join(", ")})\` to ${key.refTable} and reference \`(${[...key.columns, column].join(", ")})\`.`,
1309
+ target: `${qualified(table)}.${key.name}`,
1310
+ object: tableObject(table)
1311
+ }];
1312
+ });
1313
+ });
1314
+ }
1315
+ },
1316
+ {
1317
+ code: "BS218",
1318
+ severity: "info",
1319
+ title: "Soft-delete table without a partial index",
1320
+ description: "Queries on a table with `deleted_at` or `archived_at` read the live rows (`where deleted_at is null`). A partial index with that predicate stays small and matches those queries.",
1321
+ check: (context) => exposed(context).flatMap((table) => {
1322
+ if (table.kind !== "table") return [];
1323
+ const column = table.columns.find((entry) => SOFT_DELETE.test(entry.name) && entry.nullable);
1324
+ if (!column) return [];
1325
+ const isNull = new RegExp(`"?${escape$1(column.name)}"?\\s+IS\\s+NULL`, "i");
1326
+ if (table.indexes.some((index) => index.partial && (index.predicate === void 0 || index.predicate !== null && isNull.test(index.predicate)))) return [];
1327
+ return [{
1328
+ message: `${qualified(table)} soft-deletes with ${column.name}, but no index has \`where ${column.name} is null\`. Index the live rows you list, for example \`create index on ${qualified(table)} (created_at desc) where ${column.name} is null;\`.`,
1329
+ target: `${qualified(table)}.${column.name}`,
1330
+ object: tableObject(table)
1331
+ }];
1332
+ })
1333
+ },
1334
+ {
1335
+ code: "BS219",
1336
+ severity: "warning",
1337
+ title: "Containment filter on a column without a GIN index",
1338
+ description: "`@>`, `<@`, `?` and `&&` on jsonb and array columns, and the `.contains()`, `.containedBy()` and `.overlaps()` filters, can only use a GIN index. Without one, every query reads the whole table. Doctor looks in policies, exposed functions and `doctor.sources`.",
1339
+ check: (context) => {
1340
+ const tables = exposed(context).filter((table) => table.kind === "table");
1341
+ const policyText = (table) => withoutStrings(table.policies.map((policy) => `${policy.using ?? ""} ${policy.check ?? ""}`).join(" "));
1342
+ const functions = context.snapshot.generator.functions.filter((fn) => context.config.schemas.includes(fn.schema) && fn.definition).map((fn) => withoutStrings(fn.definition)).join("\n");
1343
+ const filtered = /* @__PURE__ */ new Set();
1344
+ for (const file of context.sources) for (const match of file.text.matchAll(CLIENT_CONTAINS)) filtered.add(match[1]);
1345
+ return tables.flatMap((table) => {
1346
+ const own = policyText(table);
1347
+ return table.columns.filter((column) => {
1348
+ if (!column.isArray && column.udt !== "jsonb") return false;
1349
+ if (table.indexes.some((index) => index.columns[0] === column.name && (index.method === void 0 || index.method === "gin"))) return false;
1350
+ const used = new RegExp(`"?${escape$1(column.name)}"?\\s*${CONTAINS}|${CONTAINS}\\s*[\\w."]*\\b${escape$1(column.name)}\\b`);
1351
+ return filtered.has(column.name) || used.test(own) || functions.includes(table.name) && used.test(functions);
1352
+ }).map((column) => ({
1353
+ message: `${qualified(table)}.${column.name} is filtered by containment but has no GIN index, so each query scans the table. Run \`create index on ${qualified(table)} using gin (${column.name}${column.udt === "jsonb" ? " jsonb_path_ops" : ""});\`${column.udt === "jsonb" ? " (`jsonb_path_ops` supports `@>` only; drop it for `?`)" : ""}.`,
1354
+ target: `${qualified(table)}.${column.name}`,
1355
+ object: tableObject(table)
1356
+ }));
1357
+ });
1358
+ }
1359
+ },
1360
+ {
1361
+ code: "BS220",
1362
+ severity: "info",
1363
+ title: "Column type to avoid",
1364
+ description: "`timestamp` drops the time zone, `varchar(n)` and `char(n)` only add a length check `text` does with a constraint, `money` rounds by locale and `json` reparses on every read. Use `timestamptz`, `text`, `numeric` and `jsonb`.",
1365
+ check: (context) => exposed(context).flatMap((table) => {
1366
+ if (table.kind !== "table") return [];
1367
+ const columns = table.columns.filter((column) => column.typeSchema === "pg_catalog" && column.udt in AVOID);
1368
+ if (columns.length === 0) return [];
1369
+ return [{
1370
+ message: `${qualified(table)}: ${columns.map((column) => `${column.name} is ${column.udt}, use ${AVOID[column.udt]}`).join("; ")}.`,
1371
+ target: qualified(table),
1372
+ object: tableObject(table)
1373
+ }];
1374
+ })
1375
+ },
1376
+ {
1377
+ code: "BS221",
1378
+ severity: "warning",
1379
+ title: "Direct database connection in a serverless app",
1380
+ description: "Each serverless or edge invocation opens its own connection. Through the direct host or the session pooler on port 5432 they exhaust `max_connections`; the transaction pooler on port 6543 shares them. The direct host is also IPv6 only.",
1381
+ check: (context) => {
1382
+ if (!(context.sources.some((file) => SERVERLESS.test(file.text)) || context.envFiles.some((file) => /^\s*VERCEL\w*=/m.test(file.text)))) return [];
1383
+ return context.envFiles.flatMap((file) => lines(file).flatMap(({ line, text }) => {
1384
+ if (/^\s*#/.test(text) || !DIRECT_URL.test(text)) return [];
1385
+ const key = /^\s*(?:export\s+)?(\w+)\s*=/.exec(text)?.[1];
1386
+ return [{
1387
+ message: `${file.path}${key ? `:${key}` : ""} connects through port 5432 or the direct host, but the app runs on serverless functions. Use the transaction pooler: \`postgres://postgres.<ref>:<password>@<region>.pooler.supabase.com:6543/postgres\`.`,
1388
+ target: `${file.path}${key ? `:${key}` : ""}`,
1389
+ location: {
1390
+ file: file.path,
1391
+ line
1392
+ }
1393
+ }];
1394
+ }));
1395
+ }
1396
+ }
1397
+ ];
1398
+ //#endregion
1399
+ //#region src/cli/doctor/rules.ts
1400
+ const DOCS_URL = "https://bettersupabase.com/docs/cli/doctor";
1401
+ const writes = (policy) => policy.command === "all" || policy.command === "insert" || policy.command === "update" || policy.command === "delete";
1402
+ const publicRoles = (policy) => policy.roles.length === 0 || policy.roles.some((role) => role === "public" || role === "anon");
1403
+ const isTrue = (expression) => expression !== null && /^\(*\s*true\s*\)*$/i.test(expression.trim());
1404
+ function authSetting(context, key) {
1405
+ return context.configToml ? tomlGet(context.configToml.document, ["auth", key]) : void 0;
1406
+ }
1407
+ function tomlLocation(context, key) {
1408
+ if (!context.configToml) return void 0;
1409
+ const line = lineOf(context.configToml.text, new RegExp(`^\\s*${key}\\s*=`)) ?? lineOf(context.configToml.text, /^\[auth\]/);
1410
+ return line ? {
1411
+ file: context.configToml.path,
1412
+ line
1413
+ } : void 0;
1414
+ }
1415
+ const PUBLIC_PREFIXES = [
1416
+ "NEXT_PUBLIC_",
1417
+ "VITE_",
1418
+ "PUBLIC_",
1419
+ "EXPO_PUBLIC_",
1420
+ "NUXT_PUBLIC_"
1421
+ ];
1422
+ function envEntries(file) {
1423
+ return file.text.split("\n").flatMap((raw, index) => {
1424
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
1425
+ if (!match) return [];
1426
+ const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
1427
+ return [{
1428
+ key: match[1],
1429
+ value,
1430
+ line: index + 1
1431
+ }];
1432
+ });
1433
+ }
1434
+ const ADVISOR_SEVERITY = {
1435
+ ERROR: "error",
1436
+ WARN: "warning",
1437
+ INFO: "info"
1438
+ };
1439
+ function lintObject(lint) {
1440
+ const { schema, name, type } = lint.metadata ?? {};
1441
+ if (typeof schema !== "string" || typeof name !== "string") return void 0;
1442
+ if (type === "table" || type === "view") return {
1443
+ kind: "table",
1444
+ schema,
1445
+ name
1446
+ };
1447
+ if (type === "function") return {
1448
+ kind: "function",
1449
+ schema,
1450
+ name
1451
+ };
1452
+ }
1453
+ function lintFinding(lint) {
1454
+ const object = lintObject(lint);
1455
+ return {
1456
+ severity: ADVISOR_SEVERITY[lint.level] ?? "warning",
1457
+ title: lint.title,
1458
+ message: `${lint.detail.replaceAll("\\`", "`")} [${lint.name}]`,
1459
+ target: object ? `${object.schema}.${object.name}` : lint.cache_key,
1460
+ help: lint.remediation,
1461
+ ...object ? { object } : {}
1462
+ };
1463
+ }
1464
+ function advisorRule(code, category, title, description, covered = () => false) {
1465
+ return {
1466
+ code,
1467
+ severity: "warning",
1468
+ title,
1469
+ description,
1470
+ async check(context) {
1471
+ const { advisors } = context;
1472
+ if (!advisors) return [];
1473
+ if ("skipped" in advisors) return [{
1474
+ severity: "info",
1475
+ message: `Skipped the ${category} advisor: ${advisors.skipped}`
1476
+ }];
1477
+ try {
1478
+ return (await advisors.lints(category)).filter((lint) => !covered(lint, context)).map(lintFinding);
1479
+ } catch (cause) {
1480
+ return [{ message: `The ${category} advisor could not run (${advisors.describe}): ${cause instanceof Error ? cause.message : String(cause)}` }];
1481
+ }
1482
+ }
1483
+ };
1484
+ }
1485
+ /**
1486
+ * splinter lints doctor's own rules report for the same table:
1487
+ * `multiple_permissive_policies` (BS207) and `unindexed_foreign_keys` (BS216).
1488
+ */
1489
+ function coveredByOwnRules(lint, context) {
1490
+ const code = lint.name === "multiple_permissive_policies" ? "BS207" : lint.name === "unindexed_foreign_keys" ? "BS216" : void 0;
1491
+ if (!code || !context.codes?.includes(code)) return false;
1492
+ const object = lintObject(lint);
1493
+ if (!object) return false;
1494
+ const table = exposed(context).find((candidate) => candidate.schema === object.schema && candidate.name === object.name);
1495
+ if (!table) return false;
1496
+ return code === "BS207" ? permissiveOverlaps(table).length > 0 : unindexedForeignKeys(table).length > 0;
1497
+ }
1498
+ /**
1499
+ * `db.x.aggregate(`, an `_sum:`/`_avg:`/`_min:`/`_max:` include or a list
1500
+ * with `facetCounts: true`.
1501
+ */
1502
+ const AGGREGATE_USE = /\.aggregate\(|\b_(?:sum|avg|min|max)\s*:\s*\{|\bfacetCounts\s*:\s*true\b/;
1503
+ /**
1504
+ * A policy that reads memberships or the tenant claim: `is_member(...)`,
1505
+ * `current_tenant_id()`, `memberships`, PermDock's `permitted_*_ids()`.
1506
+ */
1507
+ const TENANT_HELPER = /member|tenant|current_org|org_id|permitted_\w+_ids/i;
1508
+ /** A foreign key to the tenant table itself, whose own policies are usually read-only. */
1509
+ const TENANT_COLUMN = /^(?:org|organization|tenant|team|workspace|account)_id$/;
1510
+ const COMMANDS = [
1511
+ "select",
1512
+ "insert",
1513
+ "update",
1514
+ "delete"
1515
+ ];
1516
+ const MFA_FACTORS = /\bauth\.\s*"?mfa_factors\b/i;
1517
+ const RULES = [
1518
+ ...[
1519
+ advisorRule("BS100", "security", "Supabase security advisor", "Findings from the Security Advisor (splinter): RLS disabled in exposed schemas, RLS without policies, mutable search_path, security definer functions callable by anon, exposed auth.users and more. Hosted projects use the Management API; local stacks and direct connections run the pinned splinter.sql."),
1520
+ {
1521
+ code: "BS103",
1522
+ severity: "error",
1523
+ title: "Policy allows anonymous writes",
1524
+ description: "A permissive policy with `true` for anon or public lets anyone insert, update or delete rows.",
1525
+ check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => policy.permissive && writes(policy) && publicRoles(policy) && (isTrue(policy.using) || isTrue(policy.check))).map((policy) => ({
1526
+ message: `Policy "${policy.name}" on ${qualified(table)} allows ${policy.command} for ${policy.roles.join(", ") || "public"} with \`true\`.`,
1527
+ target: `${qualified(table)}.${policy.name}`,
1528
+ object: policyObject(table, policy)
1529
+ })))
1530
+ },
1531
+ {
1532
+ code: "BS106",
1533
+ severity: "error",
1534
+ title: "Table not granted to the Data API",
1535
+ description: "Supabase no longer grants new tables to anon and authenticated. Without a grant, every Data API request fails with 42501 before RLS runs. Tables in `expose` need the privileges listed there; other tables need `select` for authenticated.",
1536
+ check: (context) => {
1537
+ const auto = context.configToml ? tomlGet(context.configToml.document, ["api", "auto_expose_new_tables"]) : void 0;
1538
+ const note = auto === false || auto === "false" ? " config.toml sets [api] auto_expose_new_tables = false, so new tables start without grants." : "";
1539
+ return exposed(context).flatMap((table) => {
1540
+ if (context.config.tables[table.name]?.exclude) return [];
1541
+ const wanted = context.config.expose[qualified(table)] ?? context.config.expose[table.name] ?? {
1542
+ anon: [],
1543
+ authenticated: ["select"]
1544
+ };
1545
+ const granted = (role) => new Set(table.grants.filter((grant) => grant.role === role || grant.role === "PUBLIC").flatMap((grant) => grant.privileges.map((privilege) => privilege.toLowerCase())));
1546
+ return ["anon", "authenticated"].flatMap((role) => {
1547
+ const have = granted(role);
1548
+ const missing = wanted[role].filter((privilege) => !have.has(privilege));
1549
+ if (missing.length === 0) return [];
1550
+ return [{
1551
+ message: `${role} has no ${missing.join(", ")} on ${qualified(table)}, so the Data API answers 42501. Add it to \`expose\` and run \`better-supabase sql add grants\`, or run \`grant ${missing.join(", ")} on table ${qualified(table)} to ${role};\`.${note}`,
1552
+ target: `${qualified(table)}:${role}`,
1553
+ object: tableObject(table)
1554
+ }];
1555
+ });
1556
+ });
1557
+ }
1558
+ },
1559
+ {
1560
+ code: "BS107",
1561
+ severity: "warning",
1562
+ title: "Tenant table without a policy for every command",
1563
+ description: "A tenant-scoped table with policies for some commands but not all four denies the rest silently: an update or delete then affects 0 rows without an error. Add the missing policies, or a restrictive one, so the intent is explicit, and test it with `expectTenantIsolation`.",
1564
+ check: (context) => {
1565
+ const column = context.config.plugins.tenant?.column;
1566
+ const tables = exposed(context);
1567
+ const roots = new Set(tables.flatMap((table) => table.foreignKeys.filter((key) => key.columns.length === 1 && (column === void 0 ? TENANT_COLUMN.test(key.columns[0]) : key.columns[0] === column)).map((key) => `${key.refSchema}.${key.refTable}`)));
1568
+ return tables.flatMap((table) => {
1569
+ if (table.kind !== "table" || !table.rls) return [];
1570
+ if (roots.has(qualified(table))) return [];
1571
+ if (context.config.tables[table.name]?.exclude) return [];
1572
+ const granting = table.policies.filter((policy) => policy.permissive && policy.roles.some((role) => ["authenticated", "public"].includes(role)));
1573
+ if (granting.length === 0) return [];
1574
+ if (!(column !== void 0 && table.columns.some((entry) => entry.name === column) || granting.some((policy) => TENANT_HELPER.test([
1575
+ policy.using ?? "",
1576
+ policy.check ?? "",
1577
+ ...policy.functions ?? []
1578
+ ].join(" "))))) return [];
1579
+ const covered = new Set(granting.flatMap((policy) => policy.command === "all" ? COMMANDS : [policy.command]));
1580
+ const missing = COMMANDS.filter((command) => !covered.has(command));
1581
+ if (missing.length === 0) return [];
1582
+ return [{
1583
+ message: `${qualified(table)} is tenant-scoped but has no ${missing.join(", ")} policy for authenticated, so ${missing.join(" and ")} silently match no rows. Add the policies, or state the intent with a restrictive \`using (false)\` policy.`,
1584
+ target: qualified(table),
1585
+ object: tableObject(table)
1586
+ }];
1587
+ });
1588
+ }
1589
+ },
1590
+ {
1591
+ code: "BS108",
1592
+ severity: "error",
1593
+ title: "Policy reads auth.mfa_factors directly",
1594
+ description: "`authenticated` has no access to `auth.mfa_factors`, so a policy that reads it fails every request with 42501. Check the factor in a `security definer` function such as `better_supabase.mfa_satisfied()` from the `mfa` kit module.",
1595
+ check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => MFA_FACTORS.test(`${policy.using ?? ""} ${policy.check ?? ""}`)).map((policy) => ({
1596
+ message: `Policy "${policy.name}" on ${qualified(table)} reads auth.mfa_factors, which authenticated can't select, so every request fails with 42501. Use \`(select better_supabase.mfa_satisfied())\` from \`better-supabase sql add mfa\` instead.`,
1597
+ target: `${qualified(table)}.${policy.name}`,
1598
+ object: policyObject(table, policy)
1599
+ })))
1600
+ },
1601
+ advisorRule("BS200", "performance", "Supabase performance advisor", "Findings from the Performance Advisor (splinter): unindexed foreign keys, auth calls re-evaluated per row in policies, multiple permissive policies, unused and duplicate indexes and more.", coveredByOwnRules),
1602
+ {
1603
+ code: "BS204",
1604
+ severity: "warning",
1605
+ title: "Tenant column without an index",
1606
+ description: "The tenant plugin and tenant policies filter every query on this column.",
1607
+ check: (context) => {
1608
+ const tenant = context.config.plugins.tenant;
1609
+ if (!tenant) return [];
1610
+ return exposed(context).filter((table) => table.columns.some((column) => column.name === tenant.column) && !table.indexes.some((index) => !index.partial && index.columns[0] === tenant.column)).map((table) => ({
1611
+ message: `${qualified(table)}.${tenant.column} has no index starting with it.`,
1612
+ target: `${qualified(table)}.${tenant.column}`,
1613
+ object: tableObject(table)
1614
+ }));
1615
+ }
1616
+ },
1617
+ {
1618
+ code: "BS210",
1619
+ severity: "warning",
1620
+ title: "Aggregates used while PostgREST disables them",
1621
+ description: "PostgREST rejects `count()`, `sum()` and the other aggregates with PGRST123 unless `pgrst.db_aggregates_enabled` is on for the authenticator role. `aggregate()`, `_sum`/`_avg`/`_min`/`_max` includes and list `facetCounts` need it; `better-supabase/postgres` does not.",
1622
+ check: (context) => {
1623
+ const settings = context.snapshot.extras.roleSettings;
1624
+ if (!settings) return [];
1625
+ const enabled = settings["authenticator"]?.["pgrst.db_aggregates_enabled"];
1626
+ if (enabled === "true" || enabled === "on") return [];
1627
+ for (const file of context.sources) {
1628
+ const line = lineOf(file.text, AGGREGATE_USE);
1629
+ if (line === void 0) continue;
1630
+ return [{
1631
+ message: `${file.path} uses aggregates, but PostgREST has them off, so these requests fail with PGRST123. Run \`alter role authenticator set pgrst.db_aggregates_enabled = 'true'; notify pgrst, 'reload config';\` in a migration.`,
1632
+ target: "authenticator:pgrst.db_aggregates_enabled",
1633
+ location: {
1634
+ file: file.path,
1635
+ line
1636
+ }
1637
+ }];
1638
+ }
1639
+ return [];
1640
+ }
1641
+ },
1642
+ {
1643
+ code: "BS301",
1644
+ severity: "warning",
1645
+ title: "Soft delete hidden by a select policy",
1646
+ description: "PostgREST re-reads updated rows through the select policy. If it hides deleted rows, soft-deleting fails with an RLS error unless the update returns nothing.",
1647
+ check: (context) => {
1648
+ const softDelete = context.config.plugins.softDelete;
1649
+ if (!softDelete) return [];
1650
+ return exposed(context).flatMap((table) => {
1651
+ if (!table.columns.some((column) => column.name === softDelete.column)) return [];
1652
+ return table.policies.filter((policy) => (policy.command === "select" || policy.command === "all") && new RegExp(`\\b${softDelete.column}\\b\\s+IS\\s+NULL`, "i").test(policy.using ?? "")).map((policy) => ({
1653
+ message: `Policy "${policy.name}" on ${qualified(table)} hides rows where ${softDelete.column} is set, so \`softDelete()\` can't read the row back. Filter deleted rows in queries (the plugin does) instead of in the policy.`,
1654
+ target: `${qualified(table)}.${policy.name}`,
1655
+ object: policyObject(table, policy)
1656
+ }));
1657
+ });
1658
+ }
1659
+ },
1660
+ {
1661
+ code: "BS302",
1662
+ severity: "warning",
1663
+ title: "Bucket differs from the config",
1664
+ description: "A bucket in `buckets` is missing or has different settings in the database.",
1665
+ check: (context) => Object.entries(context.config.buckets).flatMap(([name, bucket]) => {
1666
+ const id = bucket.id ?? name.replaceAll(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
1667
+ const defined = defineBucket({
1668
+ id,
1669
+ path: bucket.path,
1670
+ ...bucket.public === void 0 ? {} : { public: bucket.public },
1671
+ ...bucket.fileSizeLimit === void 0 ? {} : { fileSizeLimit: bucket.fileSizeLimit },
1672
+ ...bucket.allowedMimeTypes === void 0 ? {} : { allowedMimeTypes: bucket.allowedMimeTypes }
1673
+ });
1674
+ const actual = catalogOf(context).buckets.find((entry) => entry.id === id);
1675
+ const inDatabase = defined.drift(actual).map((drift) => ({
1676
+ message: `Bucket ${id}: ${drift.message}`,
1677
+ target: `storage.buckets.${id}`
1678
+ }));
1679
+ const toml = context.configToml;
1680
+ const declared = toml ? tomlGet(toml.document, [
1681
+ "storage",
1682
+ "buckets",
1683
+ id
1684
+ ]) : void 0;
1685
+ if (!toml || !declared || typeof declared !== "object" || Array.isArray(declared)) return inDatabase;
1686
+ const entry = declared;
1687
+ const size = entry["file_size_limit"];
1688
+ const mimes = entry["allowed_mime_types"];
1689
+ const line = lineOf(toml.text, new RegExp(`^\\[storage\\.buckets\\.${id.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]`));
1690
+ const inToml = defined.drift({
1691
+ public: entry["public"] === true,
1692
+ fileSizeLimit: typeof size === "string" || typeof size === "number" ? parseSize(size) : null,
1693
+ allowedMimeTypes: Array.isArray(mimes) ? mimes.map(String) : null
1694
+ }).map((drift) => ({
1695
+ message: `${toml.path} [storage.buckets.${id}]: ${drift.message.replace(`Bucket "${id}" `, "")}`,
1696
+ target: `[storage.buckets.${id}]`,
1697
+ ...line ? { location: {
1698
+ file: toml.path,
1699
+ line
1700
+ } } : {}
1701
+ }));
1702
+ return [...inDatabase, ...inToml];
1703
+ })
1704
+ },
1705
+ {
1706
+ code: "BS303",
1707
+ severity: "error",
1708
+ title: "Generated code is out of date",
1709
+ description: "The generated module no longer matches the database, so types and metadata are wrong. Same check as `gen --check`.",
1710
+ check: async (context) => {
1711
+ const stale = [];
1712
+ for (const file of await renderFiles(context.config, context.snapshot)) {
1713
+ const current = await readFile(resolve(context.config.root, file.path), "utf8").catch(() => void 0);
1714
+ if (current !== file.contents) stale.push({
1715
+ message: `${file.path} is ${current === void 0 ? "missing" : "out of date"}. Run \`better-supabase gen\`.`,
1716
+ target: file.path,
1717
+ ...current === void 0 ? {} : { location: {
1718
+ file: file.path,
1719
+ line: 1
1720
+ } }
1721
+ });
1722
+ }
1723
+ return stale;
1724
+ }
1725
+ },
1726
+ {
1727
+ code: "BS304",
1728
+ severity: "warning",
1729
+ title: "SQL kit files are out of date",
1730
+ description: "A module listed in `sql.kit` differs from the version in this release. Same check as `sql sync --check`.",
1731
+ check: async (context) => {
1732
+ if (context.config.sql.kit.length === 0) return [];
1733
+ 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;
1740
+ 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)}\`.`,
1742
+ target: file.path,
1743
+ ...current === void 0 ? {} : { location: {
1744
+ file: file.path,
1745
+ line: 1
1746
+ } }
1747
+ });
1748
+ }
1749
+ return stale;
1750
+ }
1751
+ },
1752
+ {
1753
+ code: "BS305",
1754
+ severity: "warning",
1755
+ title: "Live query table without change broadcasts",
1756
+ description: "A table in `realtime.tables` has no `bs_realtime` trigger, so live queries never hear about its changes.",
1757
+ check: (context) => {
1758
+ const tables = catalogOf(context).tables;
1759
+ return context.config.realtime.tables.flatMap((name) => {
1760
+ const table = tables.find((entry) => entry.name === name || qualified(entry) === name);
1761
+ if (!table) return [{
1762
+ message: `realtime.tables lists "${name}", which doesn't exist.`,
1763
+ target: name
1764
+ }];
1765
+ if (table.triggers.some((trigger) => trigger.name.startsWith("bs_realtime"))) return [];
1766
+ return [{
1767
+ message: `${qualified(table)} is in realtime.tables but has no broadcast trigger. Run \`better-supabase sql add realtime-tables\`, then \`${migrationCommand(context.configToml)}\`.`,
1768
+ target: qualified(table),
1769
+ object: tableObject(table)
1770
+ }];
1771
+ });
1772
+ }
1773
+ },
1774
+ {
1775
+ code: "BS306",
1776
+ severity: "warning",
1777
+ title: "Realtime delete events without keys",
1778
+ description: "Tables in the `supabase_realtime` publication send the replica identity of deleted rows. With `nothing`, or `default` without a primary key, delete events carry no keys and clients cannot tell which row went away.",
1779
+ check: (context) => {
1780
+ const published = new Set(catalogOf(context).realtime);
1781
+ return catalogOf(context).tables.filter((table) => published.has(qualified(table)) && (table.replicaIdentity === "NOTHING" || table.replicaIdentity === "DEFAULT" && table.primaryKey.length === 0)).map((table) => ({
1782
+ message: `${qualified(table)} is published to Realtime with replica identity ${table.replicaIdentity?.toLowerCase() ?? "unknown"}${table.primaryKey.length === 0 ? " and no primary key" : ""}. Add a primary key or run \`alter table ${qualified(table)} replica identity full\`.`,
1783
+ target: qualified(table),
1784
+ object: tableObject(table)
1785
+ }));
1786
+ }
1787
+ },
1788
+ {
1789
+ code: "BS401",
1790
+ severity: "warning",
1791
+ title: "Refresh token reuse interval is 0",
1792
+ description: "Server instances refreshing the same session at once need a reuse interval, or all but one of them sign the user out.",
1793
+ check: (context) => {
1794
+ if (!context.configToml) return [];
1795
+ const rotation = authSetting(context, "enable_refresh_token_rotation");
1796
+ const interval = authSetting(context, "refresh_token_reuse_interval");
1797
+ if (rotation === false || rotation === "false" || interval === void 0 || Number(interval) > 0) return [];
1798
+ const location = tomlLocation(context, "refresh_token_reuse_interval");
1799
+ return [{
1800
+ message: "refresh_token_reuse_interval = 0 with refresh token rotation. Use 10 (the Supabase default) so concurrent refreshes succeed.",
1801
+ target: "[auth] refresh_token_reuse_interval",
1802
+ ...location ? { location } : {}
1803
+ }];
1804
+ }
1805
+ },
1806
+ {
1807
+ code: "BS402",
1808
+ severity: "info",
1809
+ title: "Long access token lifetime",
1810
+ description: "Tokens are verified locally without an auth call, so a revoked session stays valid until the token expires.",
1811
+ check: (context) => {
1812
+ if (!context.configToml) return [];
1813
+ const expiry = Number(authSetting(context, "jwt_expiry") ?? 3600);
1814
+ if (expiry <= 3600) return [];
1815
+ const location = tomlLocation(context, "jwt_expiry");
1816
+ return [{
1817
+ message: `jwt_expiry is ${expiry}s. Keep it at 3600 or less; sessions refresh in the proxy.`,
1818
+ target: "[auth] jwt_expiry",
1819
+ ...location ? { location } : {}
1820
+ }];
1821
+ }
1822
+ },
1823
+ {
1824
+ code: "BS403",
1825
+ severity: "info",
1826
+ title: "Local stack signs tokens with a shared secret",
1827
+ description: "Hosted projects sign with asymmetric keys. Using the same locally means local tokens verify through JWKS just like production.",
1828
+ check: (context) => {
1829
+ if (!context.configToml || authSetting(context, "signing_keys_path")) return [];
1830
+ const location = tomlLocation(context, "signing_keys_path");
1831
+ return [{
1832
+ message: "No [auth] signing_keys_path. Run `better-supabase keys` to sign local tokens with ES256.",
1833
+ target: "[auth] signing_keys_path",
1834
+ ...location ? { location } : {}
1835
+ }];
1836
+ }
1837
+ },
1838
+ {
1839
+ code: "BS406",
1840
+ severity: "warning",
1841
+ title: "Foreign key to auth.users blocks account deletion",
1842
+ description: "A foreign key to `auth.users` with `no action` or `restrict` makes `auth.admin.deleteUser` (and `deleteAccount`) fail with \"Database error deleting user\" while the user has rows. Use `on delete cascade` for data the user owns, or `on delete set null` for records that outlive them.",
1843
+ check: (context) => catalogOf(context).tables.flatMap((table) => table.schema === "auth" ? [] : table.foreignKeys.filter((key) => key.refSchema === "auth" && key.refTable === "users" && (key.onDelete === "no action" || key.onDelete === "restrict")).map((key) => ({
1844
+ message: `${qualified(table)}.${key.name} (${key.columns.join(", ")}) references auth.users with on delete ${key.onDelete}. Deleting the user fails while rows point at it; use on delete cascade or set null.`,
1845
+ target: `${qualified(table)}.${key.name}`,
1846
+ object: tableObject(table)
1847
+ })))
1848
+ },
1849
+ {
1850
+ code: "BS501",
1851
+ severity: "error",
1852
+ title: "Secret in a browser variable",
1853
+ description: "Variables with a public prefix are bundled into client code. A secret key there gives every visitor full access.",
1854
+ check: (context) => context.envFiles.flatMap((file) => envEntries(file).filter((entry) => PUBLIC_PREFIXES.some((prefix) => entry.key.startsWith(prefix)) && (entry.value.startsWith("sb_secret_") || /SECRET|SERVICE_ROLE/.test(entry.key))).map((entry) => ({
1855
+ message: `${entry.key} in ${file.path} is exposed to the browser but holds a secret. Rename it without the public prefix and rotate the key.`,
1856
+ target: `${file.path}:${entry.key}`,
1857
+ location: {
1858
+ file: file.path,
1859
+ line: entry.line
1860
+ }
1861
+ })))
1862
+ },
1863
+ {
1864
+ code: "BS502",
1865
+ severity: "warning",
1866
+ title: "Env file with secrets is not ignored by git",
1867
+ description: "Env files holding a secret key or database password should never be committed.",
1868
+ check: (context) => {
1869
+ const patterns = context.gitignore.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
1870
+ const ignored = (path) => patterns.some((pattern) => {
1871
+ const source = pattern.replace(/^\//, "").replaceAll(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
1872
+ return new RegExp(`^${source}$`).test(path) || new RegExp(`(^|/)${source}$`).test(path);
1873
+ });
1874
+ return context.envFiles.flatMap((file) => {
1875
+ const secret = envEntries(file).find((entry) => entry.value.startsWith("sb_secret_") || /SECRET_KEY|SERVICE_ROLE|DB_URL|DATABASE_URL/.test(entry.key));
1876
+ if (!secret || ignored(file.path) || file.path.endsWith(".example")) return [];
1877
+ return [{
1878
+ message: `${file.path} contains ${secret.key} but isn't in .gitignore.`,
1879
+ target: file.path,
1880
+ location: {
1881
+ file: file.path,
1882
+ line: secret.line
1883
+ }
1884
+ }];
1885
+ });
1886
+ }
1887
+ }
1888
+ ],
1889
+ ...RLS_RULES,
1890
+ ...POLICY_RULES,
1891
+ ...SCHEMA_DESIGN_RULES,
1892
+ ...HOOK_RULES,
1893
+ ...PERMDOCK_RULES,
1894
+ ...LIVE_RULES
1895
+ ].sort((a, b) => byCodePoint(a.code, b.code));
1896
+ const RULE_CODES = RULES.map((rule) => rule.code);
1897
+ async function runRules(context, rules = RULES) {
1898
+ const findings = [];
1899
+ const scoped = {
1900
+ ...context,
1901
+ codes: rules.map((rule) => rule.code)
1902
+ };
1903
+ for (const rule of rules) for (const input of await rule.check(scoped)) findings.push({
1904
+ code: rule.code,
1905
+ severity: rule.severity,
1906
+ title: rule.title,
1907
+ help: `${DOCS_URL}#${rule.code.toLowerCase()}`,
1908
+ ...input
1909
+ });
1910
+ return findings;
1911
+ }
1912
+ //#endregion
1913
+ //#region src/cli/doctor/format.ts
1914
+ const DOCTOR_FORMATS = [
1915
+ "text",
1916
+ "json",
1917
+ "sarif",
1918
+ "github"
1919
+ ];
1920
+ const DOCTOR_REPORT_SCHEMA_URL = "https://unpkg.com/better-supabase/schemas/doctor-report-v1.json";
1921
+ const SARIF_SCHEMA_URL = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json";
1922
+ function summarize(findings) {
1923
+ return {
1924
+ errors: findings.filter((finding) => finding.severity === "error").length,
1925
+ warnings: findings.filter((finding) => finding.severity === "warning").length,
1926
+ infos: findings.filter((finding) => finding.severity === "info").length
1927
+ };
1928
+ }
1929
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1930
+ const SEVERITY_FORMAT = {
1931
+ error: "red",
1932
+ warning: "yellow",
1933
+ info: "cyan"
1934
+ };
1935
+ function text(findings, paint) {
1936
+ if (findings.length === 0) return "No problems found.";
1937
+ const order = [
1938
+ "error",
1939
+ "warning",
1940
+ "info"
1941
+ ];
1942
+ const lines = [...findings].sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity) || byCodePoint(a.code, b.code)).flatMap((finding) => [
1943
+ `${paint(SEVERITY_FORMAT[finding.severity], finding.severity.padEnd(7))} ${paint("bold", finding.code)} ${finding.title}${finding.location ? ` ${finding.location.file}:${finding.location.line}` : ""}`,
1944
+ ` ${finding.message}`,
1945
+ ` ${finding.help}`
1946
+ ]);
1947
+ const summary = summarize(findings);
1948
+ lines.push("", `${plural(summary.errors, "error")}, ${plural(summary.warnings, "warning")}, ${plural(summary.infos, "note")}.`);
1949
+ return lines.join("\n");
1950
+ }
1951
+ function sarifLevel(severity) {
1952
+ switch (severity) {
1953
+ case "error": return "error";
1954
+ case "warning": return "warning";
1955
+ case "info": return "note";
1956
+ default: return severity;
1957
+ }
1958
+ }
1959
+ function sarif(findings, rules, version, fallback) {
1960
+ return {
1961
+ $schema: SARIF_SCHEMA_URL,
1962
+ version: "2.1.0",
1963
+ runs: [{
1964
+ tool: { driver: {
1965
+ name: "better-supabase doctor",
1966
+ version,
1967
+ informationUri: DOCS_URL,
1968
+ rules: rules.map((rule) => ({
1969
+ id: rule.code,
1970
+ name: rule.title.replaceAll(/[^A-Za-z0-9]+(.)?/g, (_, char) => (char ?? "").toUpperCase()),
1971
+ shortDescription: { text: rule.title },
1972
+ fullDescription: { text: rule.description },
1973
+ helpUri: `${DOCS_URL}#${rule.code.toLowerCase()}`,
1974
+ defaultConfiguration: { level: sarifLevel(rule.severity) }
1975
+ }))
1976
+ } },
1977
+ results: findings.map((finding) => ({
1978
+ ruleId: finding.code,
1979
+ ruleIndex: rules.findIndex((rule) => rule.code === finding.code),
1980
+ level: sarifLevel(finding.severity),
1981
+ message: { text: finding.message },
1982
+ locations: [{ physicalLocation: {
1983
+ artifactLocation: { uri: finding.location?.file ?? fallback },
1984
+ region: { startLine: finding.location?.line ?? 1 }
1985
+ } }],
1986
+ partialFingerprints: { target: `${finding.code}:${finding.target ?? finding.message}` }
1987
+ }))
1988
+ }]
1989
+ };
1990
+ }
1991
+ const escapeData = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
1992
+ const escapeProperty = (value) => escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
1993
+ function github(findings) {
1994
+ return findings.map((finding) => {
1995
+ return `::${finding.severity === "info" ? "notice" : finding.severity} ${[...finding.location ? [`file=${escapeProperty(finding.location.file)}`, `line=${finding.location.line}`] : [], `title=${escapeProperty(`${finding.code} ${finding.title}`)}`].join(",")}::${escapeData(`${finding.message} (${finding.help})`)}`;
1996
+ }).join("\n");
1997
+ }
1998
+ function jsonReport(findings, version) {
1999
+ return {
2000
+ $schema: DOCTOR_REPORT_SCHEMA_URL,
2001
+ version: 1,
2002
+ tool: {
2003
+ name: "better-supabase",
2004
+ version
2005
+ },
2006
+ summary: summarize(findings),
2007
+ findings
2008
+ };
2009
+ }
2010
+ function formatReport(findings, options) {
2011
+ switch (options.format) {
2012
+ case "text": return text(findings, options.paint ?? plain);
2013
+ case "json": return JSON.stringify(jsonReport(findings, options.version), null, 2);
2014
+ case "sarif": return JSON.stringify(sarif(findings, options.rules, options.version, options.fallbackFile), null, 2);
2015
+ case "github": return github(findings);
2016
+ default: return options.format;
2017
+ }
2018
+ }
2019
+ //#endregion
2020
+ //#region src/cli/commands/doctor.ts
2021
+ const ARGS = {
2022
+ format: {
2023
+ type: "string",
2024
+ description: "text (default), sarif (code scanning) or github (workflow annotations); --json for the JSON report",
2025
+ valueHint: "format"
2026
+ },
2027
+ out: {
2028
+ type: "string",
2029
+ description: "Write the report to a file",
2030
+ valueHint: "file"
2031
+ },
2032
+ only: {
2033
+ type: "string",
2034
+ description: "Run only these checks, e.g. --only BS100,BS304",
2035
+ valueHint: "codes"
2036
+ },
2037
+ ignore: {
2038
+ type: "string",
2039
+ description: "Skip checks; also doctor.ignore in the config",
2040
+ valueHint: "codes"
2041
+ },
2042
+ strict: {
2043
+ type: "boolean",
2044
+ description: "Fail on warnings; also doctor.strict"
2045
+ },
2046
+ snapshot: {
2047
+ type: "string",
2048
+ description: "Check a saved snapshot instead of the database",
2049
+ valueHint: "file"
2050
+ },
2051
+ "db-url-stdin": {
2052
+ type: "boolean",
2053
+ description: "Read the connection string from stdin instead of $DATABASE_URL or the config"
2054
+ },
2055
+ "project-ref": {
2056
+ type: "string",
2057
+ description: "Read a hosted project through the Management API",
2058
+ valueHint: "ref"
2059
+ },
2060
+ stats: {
2061
+ type: "boolean",
2062
+ description: "Report slow frequent statements from pg_stat_statements (BS209)"
2063
+ },
2064
+ explain: {
2065
+ type: "string",
2066
+ description: "EXPLAIN ANALYZE these tables under RLS, rolled back (BS212)",
2067
+ valueHint: "tables"
2068
+ },
2069
+ as: {
2070
+ type: "string",
2071
+ description: "Plan as this authenticated user, and measure the claims the custom access token hook returns for them (BS405)",
2072
+ valueHint: "uuid"
2073
+ },
2074
+ claims: {
2075
+ type: "string",
2076
+ description: "Plan with these JWT claims ({\"role\":\"authenticated\",...})",
2077
+ valueHint: "json"
2078
+ },
2079
+ "fix-grants": {
2080
+ type: "boolean",
2081
+ description: "Print the grant and revoke SQL BS404 asks for, to add to your schema or migration"
2082
+ }
2083
+ };
2084
+ const ENV_FILES = [
2085
+ ".env",
2086
+ ".env.local",
2087
+ ".env.development",
2088
+ ".env.development.local",
2089
+ ".env.production",
2090
+ ".env.production.local"
2091
+ ];
2092
+ async function readText(root, path) {
2093
+ const absolute = resolve(root, path);
2094
+ return existsSync(absolute) ? {
2095
+ path,
2096
+ text: await readFile(absolute, "utf8")
2097
+ } : void 0;
2098
+ }
2099
+ /** The declarative schemas in the diff engine's order, then the migrations newest first. */
2100
+ async function sqlFiles(root, toml) {
2101
+ const read = async (path) => ({
2102
+ path,
2103
+ text: await readFile(resolve(root, path), "utf8")
2104
+ });
2105
+ const schemas = await Promise.all((await schemaPaths(root, toml)).files.map(read));
2106
+ const migrationsDir = resolve(root, "supabase/migrations");
2107
+ const migrations = existsSync(migrationsDir) ? (await readdir(migrationsDir)).filter((name) => name.endsWith(".sql")).sort().reverse().map((name) => `supabase/migrations/${name}`) : [];
2108
+ return [...schemas, ...await Promise.all(migrations.map(read))];
2109
+ }
2110
+ /** Files matching `doctor.sources`, skipping dependencies and build output. */
2111
+ async function sourceFiles(config) {
2112
+ const files = [];
2113
+ for await (const path of glob([...config.doctor.sources], {
2114
+ cwd: config.root,
2115
+ exclude: (name) => name === "node_modules" || name === ".next"
2116
+ })) files.push({
2117
+ path,
2118
+ text: await readFile(resolve(config.root, path), "utf8")
2119
+ });
2120
+ return files.sort((a, b) => byCodePoint(a.path, b.path));
2121
+ }
2122
+ const escape = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
2123
+ /** Where a table, function or policy is declared: declarative schemas first, then the newest migration. */
2124
+ function locate(files, object) {
2125
+ const name = `"?${escape(object.name)}"?`;
2126
+ const qualifiedName = `(?:"?${escape(object.schema)}"?\\.)?${name}`;
2127
+ const pattern = object.kind === "table" ? new RegExp(`create\\s+(?:unlogged\\s+)?table\\s+(?:if\\s+not\\s+exists\\s+)?${qualifiedName}(?:\\s|\\(|$)`, "i") : object.kind === "function" ? new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${qualifiedName}\\s*\\(`, "i") : new RegExp(`create\\s+policy\\s+${name}`, "i");
2128
+ for (const file of files) {
2129
+ const line = lineOf(file.text, pattern);
2130
+ if (line) return {
2131
+ file: file.path,
2132
+ line
2133
+ };
2134
+ }
2135
+ }
2136
+ const FLAG_FORMATS = DOCTOR_FORMATS.filter((format) => format !== "json");
2137
+ /** `--json` picks the JSON report; `--format` picks between the others. */
2138
+ function reportFormat(args) {
2139
+ if (args.format === "json") throw new CliError("usage", "--format json is now the global --json flag");
2140
+ if (args.json === true) {
2141
+ if (args.format !== void 0) throw new CliError("usage", "Pass --json or --format, not both");
2142
+ return "json";
2143
+ }
2144
+ if (args.format === void 0) return "text";
2145
+ const format = FLAG_FORMATS.find((candidate) => candidate === args.format);
2146
+ if (!format) throw new CliError("usage", `--format must be one of ${FLAG_FORMATS.join(", ")}`);
2147
+ return format;
2148
+ }
2149
+ /** Checks that read the database itself rather than the snapshot. */
2150
+ const LIVE_CODES = /* @__PURE__ */ new Set([
2151
+ "BS100",
2152
+ "BS200",
2153
+ "BS208",
2154
+ "BS209",
2155
+ "BS212"
2156
+ ]);
2157
+ /**
2158
+ * The database doctor reads, for advisors and live checks: the Management
2159
+ * API for hosted projects, one lazily opened connection otherwise (splinter
2160
+ * runs over it). Saved snapshots skip both.
2161
+ */
2162
+ function openLive(config, env, source, pg) {
2163
+ const none = { close: () => Promise.resolve() };
2164
+ const file = snapshotFile(config, source);
2165
+ if (file) {
2166
+ const skipped = `reading the saved snapshot ${file}; set $DATABASE_URL, or pass --db-url-stdin or --project-ref, to check a database.`;
2167
+ return {
2168
+ ...none,
2169
+ advisors: { skipped },
2170
+ database: { skipped }
2171
+ };
2172
+ }
2173
+ let opened;
2174
+ const open = () => opened ??= openSource(config, env, source, pg);
2175
+ const target = managementTarget(config, env, source);
2176
+ const database = {
2177
+ describe: target ? `project ${target.projectRef} (Management API)` : "database",
2178
+ session: !target,
2179
+ async query(sql) {
2180
+ return (await (await open()).queryable.query(sql)).rows;
2181
+ }
2182
+ };
2183
+ const close = async () => {
2184
+ if (opened) await (await opened).close();
2185
+ };
2186
+ if (target) return {
2187
+ advisors: managementAdvisors(target),
2188
+ database,
2189
+ open,
2190
+ close
2191
+ };
2192
+ let splinter;
2193
+ const cacheDir = resolve(config.root, CACHE_DIR);
2194
+ return {
2195
+ advisors: {
2196
+ describe: "database (splinter)",
2197
+ async lints(category) {
2198
+ splinter ??= open().then((db) => splinterAdvisors(db.queryable, db.describe, { cacheDir }));
2199
+ return (await splinter).lints(category);
2200
+ }
2201
+ },
2202
+ database,
2203
+ open,
2204
+ close
2205
+ };
2206
+ }
2207
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2208
+ /** `--as <uuid>` or `--claims <json>` as `request.jwt.claims`. */
2209
+ function explainClaims(args) {
2210
+ const as = args.as;
2211
+ const claims = args.claims;
2212
+ if (as && claims) return { error: "Pass --as or --claims, not both" };
2213
+ if (as) {
2214
+ if (!UUID.test(as)) return { error: `--as takes a user id (uuid), got "${as}"` };
2215
+ return {
2216
+ sub: as,
2217
+ role: "authenticated"
2218
+ };
2219
+ }
2220
+ if (!claims) return { role: "anon" };
2221
+ try {
2222
+ const parsed = JSON.parse(claims);
2223
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { error: "--claims takes a JSON object" };
2224
+ return {
2225
+ role: "anon",
2226
+ ...parsed
2227
+ };
2228
+ } catch (cause) {
2229
+ return { error: `--claims is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}` };
2230
+ }
2231
+ }
2232
+ async function runDoctor(config, args, env, options = {}) {
2233
+ const format = reportFormat(args);
2234
+ const only = list(args.only);
2235
+ const ignore = /* @__PURE__ */ new Set([...config.doctor.ignore, ...list(args.ignore)]);
2236
+ const unknown = [...only, ...ignore].filter((code) => !RULE_CODES.includes(code));
2237
+ if (unknown.length > 0) return {
2238
+ code: 2,
2239
+ error: `Unknown check ${unknown.join(", ")}. Checks: ${RULE_CODES.join(", ")}`
2240
+ };
2241
+ const explainTables = list(args.explain);
2242
+ const stats = args.stats === true;
2243
+ const claims = explainClaims(args);
2244
+ if ("error" in claims) return {
2245
+ code: 2,
2246
+ error: String(claims.error)
2247
+ };
2248
+ const hookUser = args.as;
2249
+ const asked = /* @__PURE__ */ new Set([
2250
+ ...stats ? ["BS209"] : [],
2251
+ ...explainTables.length > 0 ? ["BS212"] : [],
2252
+ ...hookUser ? ["BS405"] : []
2253
+ ]);
2254
+ const fixGrants = args["fix-grants"] === true;
2255
+ const rules = fixGrants ? RULES.filter((rule) => rule.code === "BS404") : RULES.filter((rule) => (only.length === 0 || only.includes(rule.code) || asked.has(rule.code)) && !ignore.has(rule.code));
2256
+ const snapshotPath = args.snapshot;
2257
+ const dbUrl = options.dbUrl;
2258
+ const projectRef = args["project-ref"];
2259
+ const source = {
2260
+ ...stats || explainTables.length > 0 || hookUser ? { live: true } : {},
2261
+ ...snapshotPath ? { snapshotPath } : {},
2262
+ ...dbUrl ? { dbUrl } : {},
2263
+ ...projectRef ? { projectRef } : {},
2264
+ ...options.signal ? { signal: options.signal } : {}
2265
+ };
2266
+ const pg = options.connect ?? connect;
2267
+ const wantsLive = hookUser !== void 0 || rules.some((rule) => LIVE_CODES.has(rule.code));
2268
+ const opened = options.snapshot || !wantsLive ? {
2269
+ advisors: options.advisors,
2270
+ database: options.database,
2271
+ close: () => Promise.resolve()
2272
+ } : {
2273
+ ...openLive(config, env, source, pg),
2274
+ ...options.advisors === void 0 ? {} : { advisors: options.advisors },
2275
+ ...options.database === void 0 ? {} : { database: options.database }
2276
+ };
2277
+ const [snapshot, envFiles, permdock, [configToml, sql], gitignore, sources, readSets] = await Promise.all([
2278
+ options.snapshot ?? loadSnapshot(config, env, source, pg, opened.open),
2279
+ Promise.all(ENV_FILES.map((path) => readText(config.root, path))).then((files) => files.filter((file) => file !== void 0)),
2280
+ readPermdock(config.root, config.permdock),
2281
+ readSupabaseToml(config.root).then(async (toml) => [toml, await sqlFiles(config.root, toml)]),
2282
+ readText(config.root, ".gitignore"),
2283
+ sourceFiles(config),
2284
+ compiledReadSets(config).catch((cause) => ({ skipped: cause instanceof Error ? cause.message : String(cause) }))
2285
+ ]).catch(async (cause) => {
2286
+ await opened.close();
2287
+ throw cause;
2288
+ });
2289
+ const context = {
2290
+ config,
2291
+ snapshot,
2292
+ ...permdock ? { permdock } : {},
2293
+ sqlFiles: sql,
2294
+ configToml,
2295
+ envFiles,
2296
+ gitignore: gitignore?.text ?? "",
2297
+ sources,
2298
+ readSets,
2299
+ ...opened.advisors ? { advisors: opened.advisors } : {},
2300
+ ...opened.database ? { database: opened.database } : {},
2301
+ ...stats ? { stats } : {},
2302
+ ...hookUser ? { hookUser } : {},
2303
+ ...explainTables.length > 0 ? { explain: {
2304
+ tables: explainTables,
2305
+ claims
2306
+ } } : {}
2307
+ };
2308
+ if (fixGrants) {
2309
+ await opened.close();
2310
+ return {
2311
+ code: 0,
2312
+ output: hookGrantBlock(hookGrantProblems(context), diffEngine(context.configToml))
2313
+ };
2314
+ }
2315
+ let ran;
2316
+ try {
2317
+ ran = await runRules(context, rules);
2318
+ } finally {
2319
+ await opened.close();
2320
+ }
2321
+ const findings = ran.map((finding) => {
2322
+ if (finding.location || !finding.object) return finding;
2323
+ const location = locate(sql, finding.object);
2324
+ return location ? {
2325
+ ...finding,
2326
+ location
2327
+ } : finding;
2328
+ });
2329
+ const out = args.out;
2330
+ const report = formatReport(findings, {
2331
+ format,
2332
+ rules,
2333
+ version: VERSION,
2334
+ fallbackFile: context.configToml?.path ?? "package.json",
2335
+ ...options.paint && !out ? { paint: options.paint } : {}
2336
+ });
2337
+ const errors = findings.filter((finding) => finding.severity === "error").length;
2338
+ const warnings = findings.filter((finding) => finding.severity === "warning").length;
2339
+ const strict = args.strict === true || config.doctor.strict;
2340
+ const code = errors > 0 || strict && warnings > 0 ? 1 : 0;
2341
+ if (out) {
2342
+ await writeIfChanged(resolve(config.root, out), `${report}\n`);
2343
+ return {
2344
+ code,
2345
+ output: `Wrote ${out}: ${errors} errors, ${warnings} warnings.`,
2346
+ data: {
2347
+ out,
2348
+ errors,
2349
+ warnings
2350
+ }
2351
+ };
2352
+ }
2353
+ return format === "json" ? {
2354
+ code,
2355
+ data: jsonReport(findings, VERSION)
2356
+ } : {
2357
+ code,
2358
+ output: report
2359
+ };
2360
+ }
2361
+ const doctorCommand = defineCliCommand({
2362
+ meta: {
2363
+ name: "doctor",
2364
+ description: "Checks RLS, indexes, drift, auth config and env files; exits 1 on errors, or on warnings with --strict"
2365
+ },
2366
+ args: ARGS,
2367
+ lists: [
2368
+ "only",
2369
+ "ignore",
2370
+ "explain"
2371
+ ],
2372
+ run: async (args, { config, env, io, signal }) => {
2373
+ const dbUrl = await stdinDatabaseUrl(args["db-url-stdin"], io);
2374
+ return withSpinner(args.snapshot === void 0 ? io.prompts : void 0, "Checking the database", () => runDoctor(config, args, env, {
2375
+ paint: painter(io.color),
2376
+ ...dbUrl ? { dbUrl } : {},
2377
+ ...signal ? { signal } : {}
2378
+ }));
2379
+ }
2380
+ });
2381
+ //#endregion
2382
+ export { doctorCommand };