better-supabase 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/better-supabase.js +2 -1
- package/dist/claims-CoTobNC-.js +25 -0
- package/dist/cli/bin.js +1 -1
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/{cli-CoByWYja.js → cli-DrSgDOQC.js} +333 -159
- package/dist/client/index.d.ts +1 -1
- package/dist/{compiler-DL18ukPe.d.ts → compiler-CZ9nIuqy.d.ts} +2 -2
- package/dist/config/index.d.ts +3 -3
- package/dist/config/index.js +2 -2
- package/dist/{config-D-aR2ZoN.js → config-ClDXmVtY.js} +11 -10
- package/dist/{define-QGkq04oe.js → define-BXMYD9AY.js} +65 -62
- package/dist/{define-CTfWw-SS.d.ts → define-e3zI4_4g.d.ts} +27 -10
- package/dist/edge/index.d.ts +4 -4
- package/dist/edge/index.js +3 -3
- package/dist/entitlements-CTEBm7IB.js +20 -0
- package/dist/entitlements-Dng-ctuT.d.ts +42 -0
- package/dist/events/index.d.ts +1 -1
- package/dist/{events-2OJsVG_d.d.ts → events-DOt3nSO-.d.ts} +2 -2
- package/dist/{executor-Bje4suQF.d.ts → executor-BYXQ9AGn.d.ts} +1 -1
- package/dist/{executor-Bazhgsgq.d.ts → executor-CGdNQ27g.d.ts} +1 -1
- package/dist/{executor-Ceq60hPM.js → executor-DeppvirK.js} +1 -1
- package/dist/guards-BnzDvFrz.js +10 -0
- package/dist/hono/index.d.ts +5 -5
- package/dist/hono/index.js +3 -3
- package/dist/{index-JXHBuS53.d.ts → index-Bw1bwtC0.d.ts} +3 -3
- package/dist/{index-Hf8PP9sP.d.ts → index-CBDufH1M.d.ts} +15 -11
- package/dist/{index-cEeweTeM.d.ts → index-CG69s15v.d.ts} +3 -3
- package/dist/{index-DQVvHzjW.d.ts → index-Ciu5Xlml.d.ts} +3 -3
- package/dist/{index-Sw43J8HG.d.ts → index-D4v7sncu.d.ts} +6 -6
- package/dist/{index-BD6wKthp.d.ts → index-DOsjFvMf.d.ts} +28 -7
- package/dist/{index-DRid0qIv.d.ts → index-DbxFr7XG.d.ts} +2 -2
- package/dist/{index-Nxo3TJcm.d.ts → index-DuP14VHo.d.ts} +11 -3
- package/dist/{index-Dup-nSe9.d.ts → index-K-3WM2Hp.d.ts} +2 -2
- package/dist/index.d.ts +9 -9
- package/dist/index.js +7 -6
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.js +6 -5
- package/dist/{json-schema-BbwO2mZg.js → json-schema-BpYqkCKf.js} +2 -1
- package/dist/list/index.d.ts +1 -1
- package/dist/{live-6b9i72Ux.d.ts → live-DJyZMD6w.d.ts} +14 -3
- package/dist/logger-glM28NVN.js +21 -0
- package/dist/mcp/index.d.ts +41 -4
- package/dist/mcp/index.js +194 -61
- package/dist/{mfa-CpogP66z.js → mfa-CpLfJSde.js} +23 -3
- package/dist/next/index.d.ts +23 -23
- package/dist/next/index.js +9 -8
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +3 -3
- package/dist/orpc/index.d.ts +4 -4
- package/dist/orpc/index.js +2 -2
- package/dist/otel/index.d.ts +3 -3
- package/dist/otel/index.js +1 -1
- package/dist/{template-BeOGnynM.js → permdock-sql-CghlHPuH.js} +25 -1
- package/dist/{plugin-HvGqCurB.d.ts → plugin-C4TR9J0Z.d.ts} +2 -2
- package/dist/plugins/actor/index.d.ts +1 -1
- package/dist/plugins/rules/index.d.ts +4 -3
- package/dist/plugins/rules/index.js +4 -11
- package/dist/plugins/soft-delete/index.d.ts +2 -2
- package/dist/plugins/tenant/index.d.ts +10 -4
- package/dist/plugins/tenant/index.js +15 -13
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/validation/index.d.ts +1 -1
- package/dist/{pool-BFeqzwQS.d.ts → pool-R2m_DkXi.d.ts} +1 -1
- package/dist/postgres/index.d.ts +4 -4
- package/dist/postgres/index.js +2 -2
- package/dist/{postgrest-Dm-vhY6R.d.ts → postgrest-cAEQ_IQN.d.ts} +1 -1
- package/dist/{problem-DPu6Fh-t.js → problem-BTkTtRV9.js} +4 -4
- package/dist/query/index.d.ts +1 -1
- package/dist/react/index.d.ts +6 -6
- package/dist/react/index.js +3 -3
- package/dist/react/server.d.ts +5 -5
- package/dist/react/server.js +1 -1
- package/dist/react/session.d.ts +1 -1
- package/dist/react/session.js +1 -1
- package/dist/{react-CrOvrnIP.js → react-C83BdBGt.js} +9 -8
- package/dist/{read-set-DhX2d04c.js → read-set-4lI3W_gZ.js} +1 -1
- package/dist/realtime/index.d.ts +3 -2
- package/dist/realtime/index.js +26 -11
- package/dist/{resolve-DW8h9NPm.d.ts → resolve-CjHILk2w.d.ts} +21 -6
- package/dist/{resource-CYiu6Ygi.js → resource-BHEWmQJK.js} +1 -1
- package/dist/{resource-SkPTRJDD.d.ts → resource-CgZa3YG_.d.ts} +4 -4
- package/dist/{respond-4-dhoV7x.js → respond-DMcbQwfN.js} +7 -4
- package/dist/{respond-PoTpy5DT.d.ts → respond-Dne1ARSa.d.ts} +14 -14
- package/dist/server/index.d.ts +7 -7
- package/dist/server/index.js +5 -5
- package/dist/{session-C3osL21k.d.ts → session-C3LxnQ0e.d.ts} +4 -3
- package/dist/{session-CD-3orO_.js → session-CqEwzVup.js} +2 -1
- package/dist/{simplify-oHiDKRVQ.js → simplify-bwJpLnaS.js} +12 -12
- package/dist/{spec-pins-t6sPaHvl.js → spec-pins-C3oc-Tdp.js} +1 -1
- package/dist/{spec-pins-CJPVghHy.d.ts → spec-pins-CqorWDnO.d.ts} +1 -1
- package/dist/{sql-M8l3fnS0.js → sql-DTjA1LPj.js} +1 -1
- package/dist/ssr/index.d.ts +5 -5
- package/dist/ssr/index.js +3 -3
- package/dist/{stats-3iy8u3_P.js → stats-C5W-rBfR.js} +1 -1
- package/dist/storage/index.d.ts +3 -2
- package/dist/storage/index.js +1 -1
- package/dist/{storage-BWXVUXGk.js → storage-DuGn38fr.js} +72 -6
- package/dist/testing/index.d.ts +46 -15
- package/dist/testing/index.js +111 -43
- package/dist/{types-C2fslP1z.d.ts → types-C83v5SQz.d.ts} +55 -2
- package/dist/{view-B0ltUmTi.d.ts → view-D1s0wa0T.d.ts} +9 -3
- package/dist/{view-B6F9QbG-.js → view-aOYizTeE.js} +2 -1
- package/dist/webhooks/index.d.ts +3 -3
- package/dist/webhooks/index.js +1 -1
- package/dist/{webhooks-DbMTJJX2.js → webhooks-DMipjhSP.js} +1 -1
- package/package.json +8 -4
- package/schemas/config-v1.json +49 -4
- package/schemas/doctor-report-v1.json +1 -0
- package/schemas/snapshot-v2.json +4 -0
- package/skills/better-supabase/references/plugins.md +9 -0
- package/skills/better-supabase-api/SKILL.md +2 -2
- package/skills/better-supabase-testing/SKILL.md +4 -3
- package/dist/entitlements-BBg61DQZ.d.ts +0 -20
- package/dist/entitlements-BC_khwRy.js +0 -17
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { i as ok } from "./result-DWXatkd6.js";
|
|
2
|
-
import { a as hasPlaceholder, l as splitPlaceholders, o as isReadSet, s as placeholderOf } from "./read-set-
|
|
2
|
+
import { a as hasPlaceholder, l as splitPlaceholders, o as isReadSet, s as placeholderOf } from "./read-set-4lI3W_gZ.js";
|
|
3
3
|
import { n as toCamel, t as applyCasing } from "./casing-da0uRTqt.js";
|
|
4
|
-
import { i as
|
|
5
|
-
import { t as
|
|
6
|
-
import { i as
|
|
7
|
-
import {
|
|
4
|
+
import { i as tenantClaimPaths, t as DEFAULT_CLAIMS } from "./claims-CoTobNC-.js";
|
|
5
|
+
import { t as isList } from "./guards-BnzDvFrz.js";
|
|
6
|
+
import { i as resolveJsonSchema, r as resolveConfig } from "./config-ClDXmVtY.js";
|
|
7
|
+
import { t as compileSql } from "./sql-DTjA1LPj.js";
|
|
8
|
+
import { a as sqlString, i as sqlIdent } from "./permdock-sql-CghlHPuH.js";
|
|
9
|
+
import { i as parseSize, n as defineBucket } from "./storage-DuGn38fr.js";
|
|
8
10
|
import { t as VERSION } from "./version-Cqq1m3UY.js";
|
|
9
11
|
import { n as isSeed } from "./seed-CzecODcJ.js";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
10
13
|
import { chmod, glob, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
11
14
|
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
13
15
|
import { createHash } from "node:crypto";
|
|
14
16
|
import { introspect, parseGeneratorMetadata, serializeGeneratorMetadata, sortGeneratorMetadata } from "@supabase/postgrest-typegen";
|
|
15
17
|
import { generateTypescript, pgTypeToTsType } from "@supabase/postgrest-typegen/generation";
|
|
@@ -55,7 +57,7 @@ function parseArgs(argv) {
|
|
|
55
57
|
break;
|
|
56
58
|
}
|
|
57
59
|
if (token === "-h") {
|
|
58
|
-
flags
|
|
60
|
+
flags["help"] = true;
|
|
59
61
|
continue;
|
|
60
62
|
}
|
|
61
63
|
if (token.startsWith("--")) {
|
|
@@ -86,7 +88,7 @@ function setFlag(flags, name, value) {
|
|
|
86
88
|
if (ARRAY_FLAGS.has(name)) {
|
|
87
89
|
const current = flags[name];
|
|
88
90
|
const pieces = value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
89
|
-
flags[name] =
|
|
91
|
+
flags[name] = isList(current) ? [...current, ...pieces] : pieces;
|
|
90
92
|
return;
|
|
91
93
|
}
|
|
92
94
|
flags[name] = value;
|
|
@@ -100,7 +102,7 @@ function flagBool(flags, name) {
|
|
|
100
102
|
}
|
|
101
103
|
function flagList(flags, name) {
|
|
102
104
|
const value = flags[name];
|
|
103
|
-
if (
|
|
105
|
+
if (isList(value)) return value;
|
|
104
106
|
if (typeof value === "string") return [value];
|
|
105
107
|
return [];
|
|
106
108
|
}
|
|
@@ -112,6 +114,7 @@ function flagList(flags, name) {
|
|
|
112
114
|
* downloaded at this commit and checked against the hash instead of bundled.
|
|
113
115
|
*/
|
|
114
116
|
const SPLINTER_COMMIT = "e74a9e36cb12258cb67d1464bc1cb196e9cd8446";
|
|
117
|
+
const SPLINTER_SHA256 = "d8d558baad3e03832e521c527907fa50a9a172fabd899dd0f5c2504a5a0e9349";
|
|
115
118
|
const SPLINTER_URL = `https://raw.githubusercontent.com/supabase/splinter/${SPLINTER_COMMIT}/splinter.sql`;
|
|
116
119
|
function isLint(value) {
|
|
117
120
|
const lint = value;
|
|
@@ -140,12 +143,12 @@ async function splinterSql(options) {
|
|
|
140
143
|
const file = resolve(options.cacheDir, `splinter-${SPLINTER_COMMIT}.sql`);
|
|
141
144
|
if (existsSync(file)) {
|
|
142
145
|
const cached = await readFile(file, "utf8");
|
|
143
|
-
if (sha256(cached) ===
|
|
146
|
+
if (sha256(cached) === SPLINTER_SHA256) return cached;
|
|
144
147
|
}
|
|
145
148
|
const response = await (options.fetch ?? fetch)(SPLINTER_URL);
|
|
146
149
|
if (!response.ok) throw new Error(`Downloading splinter.sql failed (${response.status}) from ${SPLINTER_URL}`);
|
|
147
150
|
const text = await response.text();
|
|
148
|
-
if (sha256(text) !==
|
|
151
|
+
if (sha256(text) !== SPLINTER_SHA256) throw new Error(`splinter.sql from ${SPLINTER_URL} does not match the pinned hash; refusing to run it.`);
|
|
149
152
|
await mkdir(dirname(file), { recursive: true });
|
|
150
153
|
await writeFile(file, text);
|
|
151
154
|
return text;
|
|
@@ -176,6 +179,9 @@ function splinterAdvisors(queryable, describe, options) {
|
|
|
176
179
|
}
|
|
177
180
|
//#endregion
|
|
178
181
|
//#region src/sql/kit.ts
|
|
182
|
+
function jwtClaim(name) {
|
|
183
|
+
return `coalesce(auth.jwt() ->> ${sqlString(name)}, auth.jwt() -> 'app_metadata' ->> ${sqlString(name)})`;
|
|
184
|
+
}
|
|
179
185
|
const SCHEMA = `create schema if not exists better_supabase;
|
|
180
186
|
grant usage on schema better_supabase to anon, authenticated, service_role;`;
|
|
181
187
|
const UPDATED_AT = {
|
|
@@ -396,13 +402,8 @@ begin
|
|
|
396
402
|
end;
|
|
397
403
|
$$;`
|
|
398
404
|
};
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
title: "Tenant memberships and permission helper",
|
|
402
|
-
description: "Memberships with roles and has_org_role() for RLS policies. A template: edit the roles to fit your app.",
|
|
403
|
-
requires: [],
|
|
404
|
-
target: "schema",
|
|
405
|
-
sql: `${SCHEMA}
|
|
405
|
+
const tenantSql = (claims) => `${SCHEMA}
|
|
406
|
+
grant usage on schema better_supabase to supabase_auth_admin;
|
|
406
407
|
|
|
407
408
|
create table if not exists better_supabase.memberships (
|
|
408
409
|
org_id uuid not null,
|
|
@@ -417,15 +418,16 @@ alter table better_supabase.memberships enable row level security;
|
|
|
417
418
|
grant select on better_supabase.memberships to authenticated;
|
|
418
419
|
grant all on better_supabase.memberships to service_role;
|
|
419
420
|
|
|
420
|
-
-- The
|
|
421
|
-
-- token hook) or \`app_metadata.
|
|
422
|
-
|
|
421
|
+
-- The tenant of the current request: the top-level \`${claims.tenant}\` claim
|
|
422
|
+
-- (custom access token hook) or \`app_metadata.${claims.tenant}\` (Auth admin API).
|
|
423
|
+
-- Never user_metadata: users can write it.
|
|
424
|
+
create or replace function better_supabase.current_tenant_id()
|
|
423
425
|
returns uuid
|
|
424
426
|
language sql
|
|
425
427
|
stable
|
|
426
428
|
set search_path = ''
|
|
427
429
|
as $$
|
|
428
|
-
select nullif(
|
|
430
|
+
select nullif(${jwtClaim(claims.tenant)}, '')::uuid
|
|
429
431
|
$$;
|
|
430
432
|
|
|
431
433
|
-- using ((select better_supabase.has_org_role(organization_id, '{owner,admin}')))
|
|
@@ -448,7 +450,39 @@ $$;
|
|
|
448
450
|
drop policy if exists bs_memberships_read on better_supabase.memberships;
|
|
449
451
|
create policy bs_memberships_read on better_supabase.memberships
|
|
450
452
|
for select to authenticated
|
|
451
|
-
using (user_id = (select auth.uid()) or (select better_supabase.has_org_role(org_id)))
|
|
453
|
+
using (user_id = (select auth.uid()) or (select better_supabase.has_org_role(org_id)));
|
|
454
|
+
|
|
455
|
+
-- The memberships claim in PermDock's shape: [{ scope, id, roles }]. With
|
|
456
|
+
-- PermDock, \`permdock supabase hook generate\` writes the hook instead.
|
|
457
|
+
-- Otherwise call it from your custom access token hook:
|
|
458
|
+
-- return jsonb_set(event, '{claims,memberships}',
|
|
459
|
+
-- better_supabase.membership_claims((event ->> 'user_id')::uuid));
|
|
460
|
+
create or replace function better_supabase.membership_claims(user_id uuid)
|
|
461
|
+
returns jsonb
|
|
462
|
+
language sql
|
|
463
|
+
stable
|
|
464
|
+
security definer
|
|
465
|
+
set search_path = ''
|
|
466
|
+
as $$
|
|
467
|
+
select coalesce(jsonb_agg(jsonb_build_object(
|
|
468
|
+
'scope', ${sqlString(claims.scope)},
|
|
469
|
+
'id', m.org_id,
|
|
470
|
+
'roles', jsonb_build_array(m.role)
|
|
471
|
+
) order by m.created_at, m.org_id), '[]'::jsonb)
|
|
472
|
+
from better_supabase.memberships m
|
|
473
|
+
where m.user_id = membership_claims.user_id
|
|
474
|
+
$$;
|
|
475
|
+
|
|
476
|
+
revoke execute on function better_supabase.membership_claims(uuid) from public, anon, authenticated;
|
|
477
|
+
grant execute on function better_supabase.membership_claims(uuid) to service_role, supabase_auth_admin;`;
|
|
478
|
+
const TENANT = {
|
|
479
|
+
name: "tenant",
|
|
480
|
+
title: "Tenant memberships and permission helper",
|
|
481
|
+
description: "Memberships with roles, has_org_role() for RLS policies and membership_claims() for the access token hook. A template: edit the roles to fit your app.",
|
|
482
|
+
requires: [],
|
|
483
|
+
target: "schema",
|
|
484
|
+
sql: tenantSql(DEFAULT_CLAIMS),
|
|
485
|
+
render: tenantSql
|
|
452
486
|
};
|
|
453
487
|
const MFA = {
|
|
454
488
|
name: "mfa",
|
|
@@ -484,13 +518,7 @@ grant execute on function better_supabase.mfa_satisfied() to authenticated;
|
|
|
484
518
|
-- using ((select better_supabase.mfa_satisfied()))
|
|
485
519
|
-- with check ((select better_supabase.mfa_satisfied()));`
|
|
486
520
|
};
|
|
487
|
-
const
|
|
488
|
-
name: "entitlements",
|
|
489
|
-
title: "Stripe entitlements",
|
|
490
|
-
description: "Active Stripe entitlements per tenant from the Stripe Sync Engine, membership claims for the access token hook, and has_entitlement() for RLS.",
|
|
491
|
-
requires: ["tenant"],
|
|
492
|
-
target: "schema",
|
|
493
|
-
sql: `${SCHEMA}
|
|
521
|
+
const entitlementsSql = (claims) => `${SCHEMA}
|
|
494
522
|
grant usage on schema better_supabase to supabase_auth_admin;
|
|
495
523
|
|
|
496
524
|
-- Lookup keys of the tenant's active entitlements, from the Stripe Sync
|
|
@@ -535,28 +563,35 @@ $$;
|
|
|
535
563
|
revoke execute on function better_supabase.has_entitlement(uuid, text) from public, anon;
|
|
536
564
|
grant execute on function better_supabase.has_entitlement(uuid, text) to authenticated, service_role;
|
|
537
565
|
|
|
538
|
-
-- The
|
|
566
|
+
-- The \`${claims.features}\` claim: { [tenant id]: lookup keys }, read by
|
|
567
|
+
-- hasEntitlement(). Tenants without entitlements are left out. Call it from
|
|
539
568
|
-- your custom access token hook:
|
|
540
|
-
-- return jsonb_set(event, '{claims
|
|
541
|
-
-- better_supabase.
|
|
542
|
-
create or replace function better_supabase.
|
|
569
|
+
-- return jsonb_set(event, '{claims,${claims.features}}',
|
|
570
|
+
-- better_supabase.feature_claims((event ->> 'user_id')::uuid));
|
|
571
|
+
create or replace function better_supabase.feature_claims(user_id uuid)
|
|
543
572
|
returns jsonb
|
|
544
573
|
language sql
|
|
545
574
|
stable
|
|
546
575
|
security definer
|
|
547
576
|
set search_path = ''
|
|
548
577
|
as $$
|
|
549
|
-
select coalesce(
|
|
550
|
-
'tenant_id', m.org_id,
|
|
551
|
-
'roles', jsonb_build_array(m.role),
|
|
552
|
-
'entitlements', to_jsonb(better_supabase.tenant_entitlements(m.org_id))
|
|
553
|
-
) order by m.created_at, m.org_id), '[]'::jsonb)
|
|
578
|
+
select coalesce(jsonb_object_agg(m.org_id::text, to_jsonb(e.keys)), '{}'::jsonb)
|
|
554
579
|
from better_supabase.memberships m
|
|
555
|
-
|
|
580
|
+
cross join lateral (select better_supabase.tenant_entitlements(m.org_id) as keys) e
|
|
581
|
+
where m.user_id = feature_claims.user_id
|
|
582
|
+
and cardinality(e.keys) > 0
|
|
556
583
|
$$;
|
|
557
584
|
|
|
558
|
-
revoke execute on function better_supabase.
|
|
559
|
-
grant execute on function better_supabase.
|
|
585
|
+
revoke execute on function better_supabase.feature_claims(uuid) from public, anon, authenticated;
|
|
586
|
+
grant execute on function better_supabase.feature_claims(uuid) to service_role, supabase_auth_admin;`;
|
|
587
|
+
const ENTITLEMENTS = {
|
|
588
|
+
name: "entitlements",
|
|
589
|
+
title: "Stripe entitlements",
|
|
590
|
+
description: "Active Stripe entitlements per tenant from the Stripe Sync Engine, feature_claims() for the access token hook, and has_entitlement() for RLS.",
|
|
591
|
+
requires: ["tenant"],
|
|
592
|
+
target: "schema",
|
|
593
|
+
sql: entitlementsSql(DEFAULT_CLAIMS),
|
|
594
|
+
render: entitlementsSql
|
|
560
595
|
};
|
|
561
596
|
const INVITATIONS = {
|
|
562
597
|
name: "invitations",
|
|
@@ -1210,13 +1245,7 @@ begin
|
|
|
1210
1245
|
end;
|
|
1211
1246
|
$$;`
|
|
1212
1247
|
};
|
|
1213
|
-
const
|
|
1214
|
-
name: "realtime-tables",
|
|
1215
|
-
title: "Realtime table changes",
|
|
1216
|
-
description: "Broadcasts a change signal (no row data) once per statement on bs:t:<schema>.<table>[:<tenant>] for live queries.",
|
|
1217
|
-
requires: [],
|
|
1218
|
-
target: "schema",
|
|
1219
|
-
sql: `${SCHEMA}
|
|
1248
|
+
const realtimeTablesSql = (claims) => `${SCHEMA}
|
|
1220
1249
|
|
|
1221
1250
|
-- Clients refetch through RLS, so the payload carries no row data.
|
|
1222
1251
|
create or replace function better_supabase.broadcast_changes()
|
|
@@ -1303,7 +1332,7 @@ $$;
|
|
|
1303
1332
|
revoke execute on function better_supabase.track_realtime(regclass, text) from public, anon, authenticated;
|
|
1304
1333
|
revoke execute on function better_supabase.untrack_realtime(regclass) from public, anon, authenticated;
|
|
1305
1334
|
|
|
1306
|
-
-- Signed-in users receive unscoped topics, and
|
|
1335
|
+
-- Signed-in users receive unscoped topics, and topics of their active tenant.
|
|
1307
1336
|
drop policy if exists bs_realtime_tables_receive on realtime.messages;
|
|
1308
1337
|
create policy bs_realtime_tables_receive on realtime.messages for select to authenticated
|
|
1309
1338
|
using (
|
|
@@ -1312,12 +1341,20 @@ create policy bs_realtime_tables_receive on realtime.messages for select to auth
|
|
|
1312
1341
|
and (
|
|
1313
1342
|
split_part((select realtime.topic()), ':', 4) = ''
|
|
1314
1343
|
or split_part((select realtime.topic()), ':', 4) = coalesce(
|
|
1315
|
-
(select auth.jwt()) ->>
|
|
1316
|
-
(select auth.jwt()) -> 'app_metadata' ->>
|
|
1344
|
+
(select auth.jwt()) ->> ${sqlString(claims.tenant)},
|
|
1345
|
+
(select auth.jwt()) -> 'app_metadata' ->> ${sqlString(claims.tenant)},
|
|
1317
1346
|
''
|
|
1318
1347
|
)
|
|
1319
1348
|
)
|
|
1320
|
-
)
|
|
1349
|
+
);`;
|
|
1350
|
+
const REALTIME_TABLES = {
|
|
1351
|
+
name: "realtime-tables",
|
|
1352
|
+
title: "Realtime table changes",
|
|
1353
|
+
description: "Broadcasts a change signal (no row data) once per statement on bs:t:<schema>.<table>[:<tenant>] for live queries.",
|
|
1354
|
+
requires: [],
|
|
1355
|
+
target: "schema",
|
|
1356
|
+
sql: realtimeTablesSql(DEFAULT_CLAIMS),
|
|
1357
|
+
render: realtimeTablesSql
|
|
1321
1358
|
};
|
|
1322
1359
|
const JSONB_SCHEMAS = {
|
|
1323
1360
|
name: "jsonb-schemas",
|
|
@@ -1364,7 +1401,7 @@ begin
|
|
|
1364
1401
|
end;
|
|
1365
1402
|
$$;
|
|
1366
1403
|
|
|
1367
|
-
-- Runs the rest of the transaction as this user, like PostgREST does. Extra claims (
|
|
1404
|
+
-- Runs the rest of the transaction as this user, like PostgREST does. Extra claims (tenant_id, ...) go in the JWT.
|
|
1368
1405
|
create or replace function tests.authenticate_as(user_id uuid, claims jsonb default '{}')
|
|
1369
1406
|
returns void
|
|
1370
1407
|
language plpgsql
|
|
@@ -1708,7 +1745,7 @@ as $$
|
|
|
1708
1745
|
select ${key} from ${target} t where ${column} = customer
|
|
1709
1746
|
$$;
|
|
1710
1747
|
|
|
1711
|
-
-- Users whose
|
|
1748
|
+
-- Users whose features claim carries the customer's entitlements, for
|
|
1712
1749
|
-- invalidating their sessions after entitlements.active_entitlement_summary.updated.
|
|
1713
1750
|
create or replace function better_supabase.entitlement_members(customer text)
|
|
1714
1751
|
returns setof uuid
|
|
@@ -1762,10 +1799,11 @@ function renderKit(names, layout = {}) {
|
|
|
1762
1799
|
"-- Managed by `better-supabase sql add`; re-running it overwrites this file."
|
|
1763
1800
|
].join("\n");
|
|
1764
1801
|
const extra = moduleExtras(module, layout);
|
|
1802
|
+
const sql = module.render && layout.claims ? module.render(layout.claims) : module.sql;
|
|
1765
1803
|
return {
|
|
1766
1804
|
module: module.name,
|
|
1767
1805
|
path,
|
|
1768
|
-
contents: `${header}\n\n${
|
|
1806
|
+
contents: `${header}\n\n${sql.trim()}\n${extra}`
|
|
1769
1807
|
};
|
|
1770
1808
|
});
|
|
1771
1809
|
}
|
|
@@ -1882,7 +1920,7 @@ function argEntries(fn) {
|
|
|
1882
1920
|
function enrichedDatabase(model) {
|
|
1883
1921
|
const schemas = model.config.schemas.flatMap((schema) => {
|
|
1884
1922
|
const tables = model.tables.filter((t) => t.snapshot.schema === schema).flatMap((table) => {
|
|
1885
|
-
const overrides = table.columns.filter((column) => column.values && !column.snapshot.isEnum || column.json && column.tsType !== "Json" || column.storage !== void 0).map((column) => `${prop(column.db)}: ${column.tsType};`);
|
|
1923
|
+
const overrides = table.columns.filter((column) => column.values !== void 0 && !column.snapshot.isEnum || column.json && column.tsType !== "Json" || column.storage !== void 0).map((column) => `${prop(column.db)}: ${column.tsType};`);
|
|
1886
1924
|
return overrides.length > 0 ? block(prop(table.snapshot.name), overrides) : [];
|
|
1887
1925
|
});
|
|
1888
1926
|
return tables.length > 0 ? block(prop(schema), tables) : [];
|
|
@@ -2642,6 +2680,9 @@ function buildModel(snapshot, config) {
|
|
|
2642
2680
|
for (const entry of enums) enumsMeta[entry.name] = entry.values;
|
|
2643
2681
|
const functionsMeta = {};
|
|
2644
2682
|
for (const fn of functions) functionsMeta[fn.key] = fn.meta;
|
|
2683
|
+
const claimOverrides = {};
|
|
2684
|
+
for (const key of Object.keys(DEFAULT_CLAIMS)) if (config.claims[key] !== DEFAULT_CLAIMS[key]) claimOverrides[key] = config.claims[key];
|
|
2685
|
+
const tenantClaim = config.claims.tenant === DEFAULT_CLAIMS.tenant ? void 0 : tenantClaimPaths(config.claims.tenant);
|
|
2645
2686
|
const meta = {
|
|
2646
2687
|
version: 1,
|
|
2647
2688
|
casing: config.casing,
|
|
@@ -2653,11 +2694,13 @@ function buildModel(snapshot, config) {
|
|
|
2653
2694
|
public: bucket.public ?? false,
|
|
2654
2695
|
path: bucket.path,
|
|
2655
2696
|
...bucket.policy ? { policy: bucket.policy } : {},
|
|
2697
|
+
...bucket.policy === "tenant" && tenantClaim ? { tenant: { claim: tenantClaim } } : {},
|
|
2656
2698
|
...bucket.fileSizeLimit ? { fileSizeLimit: bucket.fileSizeLimit } : {},
|
|
2657
2699
|
...bucket.allowedMimeTypes ? { allowedMimeTypes: bucket.allowedMimeTypes } : {}
|
|
2658
2700
|
}])) } : {},
|
|
2659
2701
|
...Object.keys(config.topics).length > 0 ? { topics: config.topics } : {},
|
|
2660
|
-
...config.realtime.tables.length > 0 ? { realtime: realtimeMeta(config.realtime.tables, tableModels) } : {}
|
|
2702
|
+
...config.realtime.tables.length > 0 ? { realtime: realtimeMeta(config.realtime.tables, tableModels) } : {},
|
|
2703
|
+
...Object.keys(claimOverrides).length > 0 ? { claims: claimOverrides } : {}
|
|
2661
2704
|
};
|
|
2662
2705
|
return {
|
|
2663
2706
|
tables: tableModels,
|
|
@@ -2870,7 +2913,7 @@ function parseTomlSubset(text) {
|
|
|
2870
2913
|
table = root;
|
|
2871
2914
|
for (const part of header[1].split(".").map((p) => p.trim().replace(/^"(.*)"$/, "$1"))) {
|
|
2872
2915
|
const next = table[part];
|
|
2873
|
-
if (typeof next !== "object" ||
|
|
2916
|
+
if (typeof next !== "object" || Array.isArray(next)) table[part] = {};
|
|
2874
2917
|
table = table[part];
|
|
2875
2918
|
}
|
|
2876
2919
|
continue;
|
|
@@ -2912,7 +2955,7 @@ async function readSupabaseToml(root) {
|
|
|
2912
2955
|
function tomlGet(document, path) {
|
|
2913
2956
|
let value = document;
|
|
2914
2957
|
for (const key of path) {
|
|
2915
|
-
if (typeof value !== "object" ||
|
|
2958
|
+
if (typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
2916
2959
|
value = value[key];
|
|
2917
2960
|
}
|
|
2918
2961
|
return value;
|
|
@@ -2933,9 +2976,9 @@ function parsePgFunctionUri(uri) {
|
|
|
2933
2976
|
/** The enabled Auth hooks with a `pg-functions://` URI, by hook name. */
|
|
2934
2977
|
function pgFunctionHooks(document) {
|
|
2935
2978
|
const hooks = tomlGet(document, ["auth", "hook"]);
|
|
2936
|
-
if (typeof hooks !== "object" ||
|
|
2979
|
+
if (typeof hooks !== "object" || Array.isArray(hooks)) return [];
|
|
2937
2980
|
return Object.entries(hooks).flatMap(([hook, table]) => {
|
|
2938
|
-
if (typeof table !== "object" ||
|
|
2981
|
+
if (typeof table !== "object" || Array.isArray(table)) return [];
|
|
2939
2982
|
const { enabled, uri } = table;
|
|
2940
2983
|
if (enabled !== true && enabled !== "true" || typeof uri !== "string") return [];
|
|
2941
2984
|
const target = parsePgFunctionUri(uri);
|
|
@@ -2997,7 +3040,7 @@ async function readSupabasePort(root, section) {
|
|
|
2997
3040
|
async function databaseUrl(config, env, flag) {
|
|
2998
3041
|
if (flag) return flag;
|
|
2999
3042
|
if (config.source.dbUrl) return config.source.dbUrl;
|
|
3000
|
-
if (env
|
|
3043
|
+
if (env["DATABASE_URL"]) return env["DATABASE_URL"];
|
|
3001
3044
|
return `postgresql://postgres:postgres@127.0.0.1:${await readSupabasePort(config.root, "db") ?? 54322}/postgres`;
|
|
3002
3045
|
}
|
|
3003
3046
|
//#endregion
|
|
@@ -3119,7 +3162,8 @@ select n.nspname as schema, p.proname as name,
|
|
|
3119
3162
|
where a.grantee = 0 and a.privilege_type = 'EXECUTE') as public_execute,
|
|
3120
3163
|
array(select r.rolname::text from pg_roles r
|
|
3121
3164
|
where r.rolname = any(${HOOK_ROLES}) and has_schema_privilege(r.oid, n.oid, 'usage')
|
|
3122
|
-
order by 1) as schema_usage
|
|
3165
|
+
order by 1) as schema_usage,
|
|
3166
|
+
p.prosrc as source
|
|
3123
3167
|
from pg_proc p
|
|
3124
3168
|
join pg_namespace n on n.oid = p.pronamespace
|
|
3125
3169
|
join pg_language l on l.oid = p.prolang
|
|
@@ -3211,7 +3255,8 @@ async function readHooks(db, targets) {
|
|
|
3211
3255
|
...functionOf(row),
|
|
3212
3256
|
execute: row.execute,
|
|
3213
3257
|
publicExecute: row.public_execute,
|
|
3214
|
-
schemaUsage: row.schema_usage
|
|
3258
|
+
schemaUsage: row.schema_usage,
|
|
3259
|
+
...row.source === null ? {} : { source: row.source }
|
|
3215
3260
|
}))
|
|
3216
3261
|
}));
|
|
3217
3262
|
}
|
|
@@ -3422,12 +3467,12 @@ function managementTarget(config, env, source) {
|
|
|
3422
3467
|
if (source.dbUrl) return void 0;
|
|
3423
3468
|
const projectRef = source.projectRef ?? (config.source.dbUrl ? void 0 : config.source.projectRef);
|
|
3424
3469
|
if (!projectRef) return void 0;
|
|
3425
|
-
const accessToken = config.source.accessToken ?? env
|
|
3470
|
+
const accessToken = config.source.accessToken ?? env["SUPABASE_ACCESS_TOKEN"] ?? void 0;
|
|
3426
3471
|
if (!accessToken) throw new Error(`Reading project ${projectRef} needs a Supabase access token. Set SUPABASE_ACCESS_TOKEN (https://supabase.com/dashboard/account/tokens) or pass --db-url.`);
|
|
3427
3472
|
return {
|
|
3428
3473
|
projectRef,
|
|
3429
3474
|
accessToken,
|
|
3430
|
-
...env
|
|
3475
|
+
...env["SUPABASE_API_URL"] ? { apiUrl: env["SUPABASE_API_URL"] } : {}
|
|
3431
3476
|
};
|
|
3432
3477
|
}
|
|
3433
3478
|
/**
|
|
@@ -3495,6 +3540,18 @@ function serializeSnapshot(snapshot) {
|
|
|
3495
3540
|
}, null, 2)}\n`;
|
|
3496
3541
|
}
|
|
3497
3542
|
//#endregion
|
|
3543
|
+
//#region src/cli/permdock.ts
|
|
3544
|
+
const PERMDOCK_CONFIGS = [
|
|
3545
|
+
"permdock.config.ts",
|
|
3546
|
+
"permdock.config.mts",
|
|
3547
|
+
"permdock.config.js",
|
|
3548
|
+
"permdock.config.mjs"
|
|
3549
|
+
];
|
|
3550
|
+
/** The PermDock config file in `root`, if there is one. */
|
|
3551
|
+
function permdockConfig(root) {
|
|
3552
|
+
return PERMDOCK_CONFIGS.find((file) => existsSync(resolve(root, file)));
|
|
3553
|
+
}
|
|
3554
|
+
//#endregion
|
|
3498
3555
|
//#region src/cli/commands/sql.ts
|
|
3499
3556
|
const SQL_HELP = `Usage: better-supabase sql <list|add|sync|print> [modules...]
|
|
3500
3557
|
|
|
@@ -3505,7 +3562,10 @@ const SQL_HELP = `Usage: better-supabase sql <list|add|sync|print> [modules...]
|
|
|
3505
3562
|
|
|
3506
3563
|
Options
|
|
3507
3564
|
--tests-dir <dir> Where the pgtap module goes. Defaults to sql.testsDir.
|
|
3508
|
-
--dry-run Show what would be written
|
|
3565
|
+
--dry-run Show what would be written
|
|
3566
|
+
--force Write tenant even though a permdock.config.ts is present`;
|
|
3567
|
+
/** Modules that fill a claim PermDock's hook also writes (`memberships`). */
|
|
3568
|
+
const PERMDOCK_OWNED = /* @__PURE__ */ new Set(["tenant"]);
|
|
3509
3569
|
/** Where and how `sql add` writes kit files for this config. */
|
|
3510
3570
|
function kitLayout(config, testsDir = config.sql.testsDir, readSets = []) {
|
|
3511
3571
|
return {
|
|
@@ -3516,6 +3576,7 @@ function kitLayout(config, testsDir = config.sql.testsDir, readSets = []) {
|
|
|
3516
3576
|
readSets,
|
|
3517
3577
|
realtimeTables: config.realtime.tables,
|
|
3518
3578
|
entitlements: config.entitlements,
|
|
3579
|
+
claims: config.claims,
|
|
3519
3580
|
vectorSearch: config.vectorSearch,
|
|
3520
3581
|
grants: Object.entries(config.expose).flatMap(([table, roles]) => [{
|
|
3521
3582
|
table,
|
|
@@ -3588,7 +3649,19 @@ async function runSql(config, args) {
|
|
|
3588
3649
|
code: 2,
|
|
3589
3650
|
error: `Unknown module ${unknown.join(", ")}. Available: ${Object.keys(SQL_MODULES).join(", ")}`
|
|
3590
3651
|
};
|
|
3652
|
+
const permdock = permdockConfig(config.root);
|
|
3653
|
+
const hookModules = names.filter((name) => PERMDOCK_OWNED.has(name));
|
|
3654
|
+
if (permdock && hookModules.length > 0 && !flagBool(args.flags, "force")) return {
|
|
3655
|
+
code: 1,
|
|
3656
|
+
error: [
|
|
3657
|
+
`${permdock} is present, so PermDock owns the access token hook and the memberships claim.`,
|
|
3658
|
+
`${hookModules.join(" and ")} would add a second source for them. Use \`permdock supabase hook generate\` instead,`,
|
|
3659
|
+
"or pass --force to write the modules anyway."
|
|
3660
|
+
].join("\n")
|
|
3661
|
+
};
|
|
3591
3662
|
const lines = await write(config, args, names);
|
|
3663
|
+
const pulledIn = resolveModules(names).map((module) => module.name).filter((name) => PERMDOCK_OWNED.has(name) && !names.includes(name));
|
|
3664
|
+
if (permdock && pulledIn.length > 0) lines.push("", `${pulledIn.join(" and ")} came along as a dependency: its memberships table backs has_org_role() and has_entitlement().`, `PermDock's hook still owns the memberships claim, so don't call better_supabase.membership_claims from a hook.`, "List better_supabase.memberships as a PermDock membership source if both should agree.");
|
|
3592
3665
|
const untracked = resolveModules(names).map((module) => module.name).filter((name) => !config.sql.kit.includes(name));
|
|
3593
3666
|
if (untracked.length > 0) lines.push("", `Add them to your config so \`sql sync --check\` keeps them current:`, ` sql: { kit: [${[...config.sql.kit, ...untracked].map((name) => `'${name}'`).join(", ")}] }`);
|
|
3594
3667
|
lines.push("", "Then create a migration: supabase db diff -f better_supabase_kit");
|
|
@@ -3625,7 +3698,7 @@ async function runSql(config, args) {
|
|
|
3625
3698
|
};
|
|
3626
3699
|
return {
|
|
3627
3700
|
code: 0,
|
|
3628
|
-
output: module.sql
|
|
3701
|
+
output: module.render ? module.render(config.claims) : module.sql
|
|
3629
3702
|
};
|
|
3630
3703
|
}
|
|
3631
3704
|
case void 0: return {
|
|
@@ -3745,6 +3818,10 @@ function lineOf(text, pattern) {
|
|
|
3745
3818
|
const index = text.split("\n").findIndex((line) => pattern.test(line));
|
|
3746
3819
|
return index === -1 ? void 0 : index + 1;
|
|
3747
3820
|
}
|
|
3821
|
+
//#endregion
|
|
3822
|
+
//#region src/cli/doctor/live.ts
|
|
3823
|
+
/** Statements slower than this (ms, mean) with more calls than `SLOW_CALLS` are BS209. */
|
|
3824
|
+
const SLOW_MEAN_MS = 50;
|
|
3748
3825
|
const SLOW_CALLS = 1e3;
|
|
3749
3826
|
/** Rows `--explain` reads, like a `findMany()` under Supabase's default `max_rows`. */
|
|
3750
3827
|
const EXPLAIN_LIMIT = 1e3;
|
|
@@ -3884,7 +3961,7 @@ const LIVE_RULES = [
|
|
|
3884
3961
|
code: "BS209",
|
|
3885
3962
|
severity: "warning",
|
|
3886
3963
|
title: "Slow frequent statements",
|
|
3887
|
-
description: `Statements from \`pg_stat_statements\` with a mean time above
|
|
3964
|
+
description: `Statements from \`pg_stat_statements\` with a mean time above ${SLOW_MEAN_MS} ms and more than ${SLOW_CALLS} calls. Runs with \`--stats\`.`,
|
|
3888
3965
|
async check(context) {
|
|
3889
3966
|
if (!context.stats) return [];
|
|
3890
3967
|
const db = live(context);
|
|
@@ -3899,7 +3976,7 @@ const LIVE_RULES = [
|
|
|
3899
3976
|
severity: "info",
|
|
3900
3977
|
message: "pg_stat_statements is not installed, so --stats has nothing to read. Run `create extension pg_stat_statements with schema extensions;`."
|
|
3901
3978
|
}];
|
|
3902
|
-
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 >
|
|
3979
|
+
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`);
|
|
3903
3980
|
const tables = exposed(context);
|
|
3904
3981
|
return statements.map((statement) => {
|
|
3905
3982
|
const table = tableIn(statement.query, tables);
|
|
@@ -3971,6 +4048,45 @@ const LIVE_RULES = [
|
|
|
3971
4048
|
//#region src/cli/doctor/hooks.ts
|
|
3972
4049
|
/** Claims past this size make every request carry a large cookie and header. */
|
|
3973
4050
|
const HOOK_CLAIMS_LIMIT = 2048;
|
|
4051
|
+
/** PermDock's default `supabase.hook.budget`, over `memberships` plus `attrs`. */
|
|
4052
|
+
const PERMDOCK_CLAIMS_LIMIT = 1024;
|
|
4053
|
+
/**
|
|
4054
|
+
* The BS405 limits. With PermDock, `doctor.claimsLimit` is its budget for
|
|
4055
|
+
* `memberships` plus `attrs` and the whole token keeps 2048; without it,
|
|
4056
|
+
* `doctor.claimsLimit` limits the whole token.
|
|
4057
|
+
*/
|
|
4058
|
+
function claimsLimits(context) {
|
|
4059
|
+
const configured = context.config.doctor.claimsLimit;
|
|
4060
|
+
return context.permdock ? {
|
|
4061
|
+
token: HOOK_CLAIMS_LIMIT,
|
|
4062
|
+
budget: configured ?? PERMDOCK_CLAIMS_LIMIT
|
|
4063
|
+
} : {
|
|
4064
|
+
token: configured ?? HOOK_CLAIMS_LIMIT,
|
|
4065
|
+
budget: void 0
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
/** The kit function that fills PermDock's `memberships` claim. */
|
|
4069
|
+
const KIT_MEMBERSHIPS = /better_supabase\s*\.\s*membership_claims\b/i;
|
|
4070
|
+
const PERMDOCK_CALL = /\bpermdock\w*\s*\(|permdock\s*\./i;
|
|
4071
|
+
/** Only PermDock's generated hook sets this claim, so it marks that hook's body. */
|
|
4072
|
+
const PERMDOCK_HOOK = /'\{\s*(?:claims\s*,\s*)?memberships_truncated\s*\}'/i;
|
|
4073
|
+
const escapeRegExp = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4074
|
+
/** The claims PermDock's hook writes that a second writer would contradict. */
|
|
4075
|
+
function ownedClaims(tenant) {
|
|
4076
|
+
return [
|
|
4077
|
+
"roles",
|
|
4078
|
+
"user_role",
|
|
4079
|
+
"memberships",
|
|
4080
|
+
tenant
|
|
4081
|
+
];
|
|
4082
|
+
}
|
|
4083
|
+
/** Which of `claims` the hook body writes through `jsonb_set` or `jsonb_build_object`. */
|
|
4084
|
+
function writtenClaims(source, claims) {
|
|
4085
|
+
return claims.filter((claim) => {
|
|
4086
|
+
const name = escapeRegExp(claim);
|
|
4087
|
+
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);
|
|
4088
|
+
});
|
|
4089
|
+
}
|
|
3974
4090
|
const API_ROLES = ["authenticated", "anon"];
|
|
3975
4091
|
/** Hooks enabled in `config.toml`, matched with their introspected functions. */
|
|
3976
4092
|
function configuredHooks(context) {
|
|
@@ -4017,11 +4133,7 @@ const emptySearchPath = (fn) => {
|
|
|
4017
4133
|
const value = fn.settings["search_path"];
|
|
4018
4134
|
return value === "" || value === "\"\"" || value === "''";
|
|
4019
4135
|
};
|
|
4020
|
-
|
|
4021
|
-
* Runs the hook as Auth does for `userId` (as `supabase_auth_admin` when the
|
|
4022
|
-
* connecting role may switch to it), in a transaction that is rolled back.
|
|
4023
|
-
*/
|
|
4024
|
-
async function hookClaimsBytes(db, fn, userId) {
|
|
4136
|
+
async function hookClaimsSize(db, fn, userId) {
|
|
4025
4137
|
const call = `${ident(fn.schema)}.${ident(fn.name)}`;
|
|
4026
4138
|
await db.query("begin");
|
|
4027
4139
|
try {
|
|
@@ -4045,97 +4157,154 @@ async function hookClaimsBytes(db, fn, userId) {
|
|
|
4045
4157
|
const [role] = await db.query(`select case when exists(select 1 from pg_roles where rolname = 'supabase_auth_admin')
|
|
4046
4158
|
then pg_has_role('supabase_auth_admin', 'member') else false end as member`);
|
|
4047
4159
|
if (role?.member) await db.query("set local role supabase_auth_admin");
|
|
4048
|
-
const [row] = await db.query(`select octet_length(
|
|
4049
|
-
|
|
4160
|
+
const [row] = await db.query(`select octet_length(c::text) as bytes,
|
|
4161
|
+
octet_length((c -> 'memberships')::text) as memberships,
|
|
4162
|
+
octet_length((c -> 'attrs')::text) as attrs,
|
|
4163
|
+
c ->> 'memberships_truncated' = 'true' as truncated
|
|
4164
|
+
from (select ${call}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims' as c) h`);
|
|
4165
|
+
const memberships = Number(row?.memberships ?? 0);
|
|
4166
|
+
return {
|
|
4167
|
+
bytes: Number(row?.bytes ?? 0),
|
|
4168
|
+
memberships,
|
|
4169
|
+
budget: memberships + Number(row?.attrs ?? 0),
|
|
4170
|
+
truncated: row?.truncated === true
|
|
4171
|
+
};
|
|
4050
4172
|
} finally {
|
|
4051
4173
|
await db.query("rollback");
|
|
4052
4174
|
}
|
|
4053
4175
|
}
|
|
4054
|
-
const HOOK_RULES = [
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
return extras.functions.flatMap((fn) => {
|
|
4069
|
-
const { problems, fix } = grantProblems(fn);
|
|
4070
|
-
if (problems.length === 0) return [];
|
|
4071
|
-
return [{
|
|
4072
|
-
message: `${signatureOf(fn)} ([auth.hook.${config.hook}]): ${problems.join("; ")}. Run:\n${fix.join("\n")}`,
|
|
4073
|
-
target: signatureOf(fn),
|
|
4074
|
-
object: {
|
|
4075
|
-
kind: "function",
|
|
4076
|
-
schema: fn.schema,
|
|
4077
|
-
name: fn.name
|
|
4078
|
-
}
|
|
4176
|
+
const HOOK_RULES = [
|
|
4177
|
+
{
|
|
4178
|
+
code: "BS404",
|
|
4179
|
+
severity: "error",
|
|
4180
|
+
title: "Auth hook function grants",
|
|
4181
|
+
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.",
|
|
4182
|
+
check: (context) => configuredHooks(context).flatMap(({ config, extras }) => {
|
|
4183
|
+
if (!extras) return [];
|
|
4184
|
+
const location = hookLocation(context, config.hook);
|
|
4185
|
+
const where = location ? { location } : {};
|
|
4186
|
+
if (extras.functions.length === 0) return [{
|
|
4187
|
+
message: `[auth.hook.${config.hook}] calls ${config.schema}.${config.name}, which does not exist. Sign-ins fail until it does.`,
|
|
4188
|
+
target: `[auth.hook.${config.hook}]`,
|
|
4189
|
+
...where
|
|
4079
4190
|
}];
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
title: "Custom access token hook shape",
|
|
4086
|
-
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 and warns above ${HOOK_CLAIMS_LIMIT} bytes of claims.`,
|
|
4087
|
-
async check(context) {
|
|
4088
|
-
const findings = [];
|
|
4089
|
-
for (const { config, extras } of configuredHooks(context)) {
|
|
4090
|
-
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
4091
|
-
for (const fn of extras.functions) {
|
|
4092
|
-
const object = {
|
|
4093
|
-
kind: "function",
|
|
4094
|
-
schema: fn.schema,
|
|
4095
|
-
name: fn.name
|
|
4096
|
-
};
|
|
4097
|
-
const shape = [];
|
|
4098
|
-
if (fn.volatility !== "stable") shape.push(`it is ${fn.volatility}; declare it \`stable\``);
|
|
4099
|
-
if (!emptySearchPath(fn)) shape.push("it has no `set search_path = ''`; add it and qualify every table");
|
|
4100
|
-
if (shape.length > 0) findings.push({
|
|
4101
|
-
message: `${signatureOf(fn)}: ${shape.join("; ")}.`,
|
|
4191
|
+
return extras.functions.flatMap((fn) => {
|
|
4192
|
+
const { problems, fix } = grantProblems(fn);
|
|
4193
|
+
if (problems.length === 0) return [];
|
|
4194
|
+
return [{
|
|
4195
|
+
message: `${signatureOf(fn)} ([auth.hook.${config.hook}]): ${problems.join("; ")}. Run:\n${fix.join("\n")}`,
|
|
4102
4196
|
target: signatureOf(fn),
|
|
4103
|
-
object
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4197
|
+
object: {
|
|
4198
|
+
kind: "function",
|
|
4199
|
+
schema: fn.schema,
|
|
4200
|
+
name: fn.name
|
|
4201
|
+
}
|
|
4202
|
+
}];
|
|
4203
|
+
});
|
|
4204
|
+
})
|
|
4205
|
+
},
|
|
4206
|
+
{
|
|
4207
|
+
code: "BS405",
|
|
4208
|
+
severity: "warning",
|
|
4209
|
+
title: "Custom access token hook shape",
|
|
4210
|
+
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.`,
|
|
4211
|
+
async check(context) {
|
|
4212
|
+
const findings = [];
|
|
4213
|
+
const limits = claimsLimits(context);
|
|
4214
|
+
for (const { config, extras } of configuredHooks(context)) {
|
|
4215
|
+
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
4216
|
+
for (const fn of extras.functions) {
|
|
4217
|
+
const object = {
|
|
4218
|
+
kind: "function",
|
|
4219
|
+
schema: fn.schema,
|
|
4220
|
+
name: fn.name
|
|
4221
|
+
};
|
|
4222
|
+
const shape = [];
|
|
4223
|
+
if (fn.volatility !== "stable") shape.push(`it is ${fn.volatility}; declare it \`stable\``);
|
|
4224
|
+
if (!emptySearchPath(fn)) shape.push("it has no `set search_path = ''`; add it and qualify every table");
|
|
4225
|
+
if (shape.length > 0) findings.push({
|
|
4226
|
+
message: `${signatureOf(fn)}: ${shape.join("; ")}.`,
|
|
4227
|
+
target: signatureOf(fn),
|
|
4131
4228
|
object
|
|
4132
4229
|
});
|
|
4230
|
+
if (!context.hookUser) continue;
|
|
4231
|
+
const db = context.database;
|
|
4232
|
+
if (!db || "skipped" in db || !db.session) {
|
|
4233
|
+
findings.push({
|
|
4234
|
+
severity: "info",
|
|
4235
|
+
message: `Measuring the hook's claims needs a direct database connection (local stack or --db-url)${db && "skipped" in db ? `: ${db.skipped}` : "."}`,
|
|
4236
|
+
target: `${signatureOf(fn)}:claims`
|
|
4237
|
+
});
|
|
4238
|
+
continue;
|
|
4239
|
+
}
|
|
4240
|
+
try {
|
|
4241
|
+
const size = await hookClaimsSize(db, fn, context.hookUser);
|
|
4242
|
+
if (size === void 0) {
|
|
4243
|
+
findings.push({
|
|
4244
|
+
severity: "info",
|
|
4245
|
+
message: `--as ${context.hookUser}: no such user in auth.users, so the hook was not called.`,
|
|
4246
|
+
target: `${signatureOf(fn)}:claims`
|
|
4247
|
+
});
|
|
4248
|
+
continue;
|
|
4249
|
+
}
|
|
4250
|
+
if (limits.budget !== void 0 && size.budget > limits.budget) findings.push({
|
|
4251
|
+
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.`,
|
|
4252
|
+
target: `${signatureOf(fn)}:budget`,
|
|
4253
|
+
object
|
|
4254
|
+
});
|
|
4255
|
+
if (size.bytes > limits.token) findings.push({
|
|
4256
|
+
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.`,
|
|
4257
|
+
target: `${signatureOf(fn)}:claims`,
|
|
4258
|
+
object
|
|
4259
|
+
});
|
|
4260
|
+
if (size.truncated) findings.push({
|
|
4261
|
+
severity: "info",
|
|
4262
|
+
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).`,
|
|
4263
|
+
target: `${signatureOf(fn)}:truncated`,
|
|
4264
|
+
object
|
|
4265
|
+
});
|
|
4266
|
+
} catch (cause) {
|
|
4267
|
+
findings.push({
|
|
4268
|
+
message: `Calling ${signatureOf(fn)} for ${context.hookUser} failed: ${errorText(cause)}`,
|
|
4269
|
+
target: `${signatureOf(fn)}:claims`,
|
|
4270
|
+
object
|
|
4271
|
+
});
|
|
4272
|
+
}
|
|
4133
4273
|
}
|
|
4134
4274
|
}
|
|
4275
|
+
return findings;
|
|
4135
4276
|
}
|
|
4136
|
-
|
|
4277
|
+
},
|
|
4278
|
+
{
|
|
4279
|
+
code: "BS407",
|
|
4280
|
+
severity: "error",
|
|
4281
|
+
title: "Two authorization hooks",
|
|
4282
|
+
description: "A `permdock.config.ts` (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. Other claims, such as the entitlements module's `features`, are not PermDock's and are not reported.",
|
|
4283
|
+
check: (context) => configuredHooks(context).flatMap(({ config, extras }) => {
|
|
4284
|
+
if (config.hook !== "custom_access_token" || !extras) return [];
|
|
4285
|
+
return extras.functions.flatMap((fn) => {
|
|
4286
|
+
if (fn.source === void 0) return [];
|
|
4287
|
+
const kit = KIT_MEMBERSHIPS.test(fn.source);
|
|
4288
|
+
const written = PERMDOCK_HOOK.test(fn.source) ? [] : writtenClaims(fn.source, ownedClaims(context.config.claims.tenant));
|
|
4289
|
+
if (!kit && written.length === 0) return [];
|
|
4290
|
+
const permdock = context.permdock ?? (PERMDOCK_CALL.test(fn.source) ? "the hook body" : void 0);
|
|
4291
|
+
if (!permdock) return [];
|
|
4292
|
+
const location = hookLocation(context, config.hook);
|
|
4293
|
+
const what = [...kit ? ["calls better_supabase.membership_claims"] : [], ...written.length > 0 ? [`writes ${written.join(", ")}`] : []].join(" and ");
|
|
4294
|
+
return [{
|
|
4295
|
+
message: `${signatureOf(fn)} ${what}, and ${permdock} says PermDock owns those claims. Keep one source: run \`permdock supabase hook generate\` and remove these writes from your hook.`,
|
|
4296
|
+
target: signatureOf(fn),
|
|
4297
|
+
object: {
|
|
4298
|
+
kind: "function",
|
|
4299
|
+
schema: fn.schema,
|
|
4300
|
+
name: fn.name
|
|
4301
|
+
},
|
|
4302
|
+
...location ? { location } : {}
|
|
4303
|
+
}];
|
|
4304
|
+
});
|
|
4305
|
+
})
|
|
4137
4306
|
}
|
|
4138
|
-
|
|
4307
|
+
];
|
|
4139
4308
|
//#endregion
|
|
4140
4309
|
//#region src/cli/doctor/rls.ts
|
|
4141
4310
|
const escape$1 = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -4425,8 +4594,11 @@ function coveredByBS207(lint, context) {
|
|
|
4425
4594
|
* with `facetCounts: true`.
|
|
4426
4595
|
*/
|
|
4427
4596
|
const AGGREGATE_USE = /\.aggregate\(|\b_(?:sum|avg|min|max)\s*:\s*\{|\bfacetCounts\s*:\s*true\b/;
|
|
4428
|
-
/**
|
|
4429
|
-
|
|
4597
|
+
/**
|
|
4598
|
+
* A policy that reads memberships or the tenant claim: `is_member(...)`,
|
|
4599
|
+
* `current_tenant_id()`, `memberships`, PermDock's `permitted_*_ids()`.
|
|
4600
|
+
*/
|
|
4601
|
+
const TENANT_HELPER = /member|tenant|current_org|org_id|permitted_\w+_ids/i;
|
|
4430
4602
|
/** A foreign key to the tenant table itself, whose own policies are usually read-only. */
|
|
4431
4603
|
const TENANT_COLUMN = /^(?:org|organization|tenant|team|workspace|account)_id$/;
|
|
4432
4604
|
const COMMANDS$1 = [
|
|
@@ -5146,9 +5318,11 @@ async function runDoctor(config, args, env, options = {}) {
|
|
|
5146
5318
|
...options.database !== void 0 ? { database: options.database } : {}
|
|
5147
5319
|
};
|
|
5148
5320
|
const envFiles = (await Promise.all(ENV_FILES.map((path) => readText(config.root, path)))).filter((file) => file !== void 0);
|
|
5321
|
+
const permdock = permdockConfig(config.root);
|
|
5149
5322
|
const context = {
|
|
5150
5323
|
config,
|
|
5151
5324
|
snapshot,
|
|
5325
|
+
...permdock ? { permdock } : {},
|
|
5152
5326
|
configToml: await readSupabaseToml(config.root),
|
|
5153
5327
|
envFiles,
|
|
5154
5328
|
gitignore: (await readText(config.root, ".gitignore"))?.text ?? "",
|
|
@@ -5235,7 +5409,7 @@ function exec(command, args, cwd) {
|
|
|
5235
5409
|
}
|
|
5236
5410
|
/** Runs the Supabase CLI: `$SUPABASE_BIN`, then `supabase`, then `npx supabase`. */
|
|
5237
5411
|
async function supabaseCli(args, cwd, env) {
|
|
5238
|
-
const result = await exec(env
|
|
5412
|
+
const result = await exec(env["SUPABASE_BIN"] ?? "supabase", args, cwd);
|
|
5239
5413
|
if (result.code !== 127) return result;
|
|
5240
5414
|
return exec("npx", [
|
|
5241
5415
|
"--yes",
|
|
@@ -6069,7 +6243,7 @@ async function runSkills(config, args, env) {
|
|
|
6069
6243
|
error: action ? `Unknown skills action "${action}".\n\n${SKILLS_HELP}` : SKILLS_HELP
|
|
6070
6244
|
};
|
|
6071
6245
|
const global = flagBool(args.flags, "global");
|
|
6072
|
-
const base = global ? env
|
|
6246
|
+
const base = global ? env["HOME"] ?? homedir() : config.root;
|
|
6073
6247
|
const requested = flagList(args.flags, "agent");
|
|
6074
6248
|
const unknown = requested.filter((name) => !isAgent(name));
|
|
6075
6249
|
if (unknown.length > 0) return {
|