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