better-supabase 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/bin/better-supabase.js +2 -1
  2. package/dist/claims-CoTobNC-.js +25 -0
  3. package/dist/cli/bin.js +1 -1
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.js +1 -1
  6. package/dist/{cli-CoByWYja.js → cli-DrSgDOQC.js} +333 -159
  7. package/dist/client/index.d.ts +1 -1
  8. package/dist/{compiler-DL18ukPe.d.ts → compiler-CZ9nIuqy.d.ts} +2 -2
  9. package/dist/config/index.d.ts +3 -3
  10. package/dist/config/index.js +2 -2
  11. package/dist/{config-D-aR2ZoN.js → config-ClDXmVtY.js} +11 -10
  12. package/dist/{define-QGkq04oe.js → define-BXMYD9AY.js} +65 -62
  13. package/dist/{define-CTfWw-SS.d.ts → define-e3zI4_4g.d.ts} +27 -10
  14. package/dist/edge/index.d.ts +4 -4
  15. package/dist/edge/index.js +3 -3
  16. package/dist/entitlements-CTEBm7IB.js +20 -0
  17. package/dist/entitlements-Dng-ctuT.d.ts +42 -0
  18. package/dist/events/index.d.ts +1 -1
  19. package/dist/{events-2OJsVG_d.d.ts → events-DOt3nSO-.d.ts} +2 -2
  20. package/dist/{executor-Bje4suQF.d.ts → executor-BYXQ9AGn.d.ts} +1 -1
  21. package/dist/{executor-Bazhgsgq.d.ts → executor-CGdNQ27g.d.ts} +1 -1
  22. package/dist/{executor-Ceq60hPM.js → executor-DeppvirK.js} +1 -1
  23. package/dist/guards-BnzDvFrz.js +10 -0
  24. package/dist/hono/index.d.ts +5 -5
  25. package/dist/hono/index.js +3 -3
  26. package/dist/{index-JXHBuS53.d.ts → index-Bw1bwtC0.d.ts} +3 -3
  27. package/dist/{index-Hf8PP9sP.d.ts → index-CBDufH1M.d.ts} +15 -11
  28. package/dist/{index-cEeweTeM.d.ts → index-CG69s15v.d.ts} +3 -3
  29. package/dist/{index-DQVvHzjW.d.ts → index-Ciu5Xlml.d.ts} +3 -3
  30. package/dist/{index-Sw43J8HG.d.ts → index-D4v7sncu.d.ts} +6 -6
  31. package/dist/{index-BD6wKthp.d.ts → index-DOsjFvMf.d.ts} +28 -7
  32. package/dist/{index-DRid0qIv.d.ts → index-DbxFr7XG.d.ts} +2 -2
  33. package/dist/{index-Nxo3TJcm.d.ts → index-DuP14VHo.d.ts} +11 -3
  34. package/dist/{index-Dup-nSe9.d.ts → index-K-3WM2Hp.d.ts} +2 -2
  35. package/dist/index.d.ts +9 -9
  36. package/dist/index.js +7 -6
  37. package/dist/jobs/index.d.ts +1 -1
  38. package/dist/jobs/index.js +6 -5
  39. package/dist/{json-schema-BbwO2mZg.js → json-schema-BpYqkCKf.js} +2 -1
  40. package/dist/list/index.d.ts +1 -1
  41. package/dist/{live-6b9i72Ux.d.ts → live-DJyZMD6w.d.ts} +14 -3
  42. package/dist/logger-glM28NVN.js +21 -0
  43. package/dist/mcp/index.d.ts +41 -4
  44. package/dist/mcp/index.js +194 -61
  45. package/dist/{mfa-CpogP66z.js → mfa-CpLfJSde.js} +23 -3
  46. package/dist/next/index.d.ts +23 -23
  47. package/dist/next/index.js +9 -8
  48. package/dist/openapi/index.d.ts +1 -1
  49. package/dist/openapi/index.js +3 -3
  50. package/dist/orpc/index.d.ts +4 -4
  51. package/dist/orpc/index.js +2 -2
  52. package/dist/otel/index.d.ts +3 -3
  53. package/dist/otel/index.js +1 -1
  54. package/dist/{template-BeOGnynM.js → permdock-sql-CghlHPuH.js} +25 -1
  55. package/dist/{plugin-HvGqCurB.d.ts → plugin-C4TR9J0Z.d.ts} +2 -2
  56. package/dist/plugins/actor/index.d.ts +1 -1
  57. package/dist/plugins/rules/index.d.ts +4 -3
  58. package/dist/plugins/rules/index.js +4 -11
  59. package/dist/plugins/soft-delete/index.d.ts +2 -2
  60. package/dist/plugins/tenant/index.d.ts +10 -4
  61. package/dist/plugins/tenant/index.js +15 -13
  62. package/dist/plugins/timestamps/index.d.ts +1 -1
  63. package/dist/plugins/validation/index.d.ts +1 -1
  64. package/dist/{pool-BFeqzwQS.d.ts → pool-R2m_DkXi.d.ts} +1 -1
  65. package/dist/postgres/index.d.ts +4 -4
  66. package/dist/postgres/index.js +2 -2
  67. package/dist/{postgrest-Dm-vhY6R.d.ts → postgrest-cAEQ_IQN.d.ts} +1 -1
  68. package/dist/{problem-DPu6Fh-t.js → problem-BTkTtRV9.js} +4 -4
  69. package/dist/query/index.d.ts +1 -1
  70. package/dist/react/index.d.ts +6 -6
  71. package/dist/react/index.js +3 -3
  72. package/dist/react/server.d.ts +5 -5
  73. package/dist/react/server.js +1 -1
  74. package/dist/react/session.d.ts +1 -1
  75. package/dist/react/session.js +1 -1
  76. package/dist/{react-CrOvrnIP.js → react-C83BdBGt.js} +9 -8
  77. package/dist/{read-set-DhX2d04c.js → read-set-4lI3W_gZ.js} +1 -1
  78. package/dist/realtime/index.d.ts +3 -2
  79. package/dist/realtime/index.js +26 -11
  80. package/dist/{resolve-DW8h9NPm.d.ts → resolve-CjHILk2w.d.ts} +21 -6
  81. package/dist/{resource-CYiu6Ygi.js → resource-BHEWmQJK.js} +1 -1
  82. package/dist/{resource-SkPTRJDD.d.ts → resource-CgZa3YG_.d.ts} +4 -4
  83. package/dist/{respond-4-dhoV7x.js → respond-DMcbQwfN.js} +7 -4
  84. package/dist/{respond-PoTpy5DT.d.ts → respond-Dne1ARSa.d.ts} +14 -14
  85. package/dist/server/index.d.ts +7 -7
  86. package/dist/server/index.js +5 -5
  87. package/dist/{session-C3osL21k.d.ts → session-C3LxnQ0e.d.ts} +4 -3
  88. package/dist/{session-CD-3orO_.js → session-CqEwzVup.js} +2 -1
  89. package/dist/{simplify-oHiDKRVQ.js → simplify-bwJpLnaS.js} +12 -12
  90. package/dist/{spec-pins-t6sPaHvl.js → spec-pins-C3oc-Tdp.js} +1 -1
  91. package/dist/{spec-pins-CJPVghHy.d.ts → spec-pins-CqorWDnO.d.ts} +1 -1
  92. package/dist/{sql-M8l3fnS0.js → sql-DTjA1LPj.js} +1 -1
  93. package/dist/ssr/index.d.ts +5 -5
  94. package/dist/ssr/index.js +3 -3
  95. package/dist/{stats-3iy8u3_P.js → stats-C5W-rBfR.js} +1 -1
  96. package/dist/storage/index.d.ts +3 -2
  97. package/dist/storage/index.js +1 -1
  98. package/dist/{storage-BWXVUXGk.js → storage-DuGn38fr.js} +72 -6
  99. package/dist/testing/index.d.ts +46 -15
  100. package/dist/testing/index.js +111 -43
  101. package/dist/{types-C2fslP1z.d.ts → types-C83v5SQz.d.ts} +55 -2
  102. package/dist/{view-B0ltUmTi.d.ts → view-D1s0wa0T.d.ts} +9 -3
  103. package/dist/{view-B6F9QbG-.js → view-aOYizTeE.js} +2 -1
  104. package/dist/webhooks/index.d.ts +3 -3
  105. package/dist/webhooks/index.js +1 -1
  106. package/dist/{webhooks-DbMTJJX2.js → webhooks-DMipjhSP.js} +1 -1
  107. package/package.json +8 -4
  108. package/schemas/config-v1.json +49 -4
  109. package/schemas/doctor-report-v1.json +1 -0
  110. package/schemas/snapshot-v2.json +4 -0
  111. package/skills/better-supabase/references/plugins.md +9 -0
  112. package/skills/better-supabase-api/SKILL.md +2 -2
  113. package/skills/better-supabase-testing/SKILL.md +4 -3
  114. package/dist/entitlements-BBg61DQZ.d.ts +0 -20
  115. package/dist/entitlements-BC_khwRy.js +0 -17
@@ -1,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-DhX2d04c.js";
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 resolveJsonSchema, r as resolveConfig } from "./config-D-aR2ZoN.js";
5
- import { t as compileSql } from "./sql-M8l3fnS0.js";
6
- import { i as sqlString, r as sqlIdent } from "./template-BeOGnynM.js";
7
- import { i as parseSize, n as defineBucket } from "./storage-BWXVUXGk.js";
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.help = true;
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] = Array.isArray(current) ? [...current, ...pieces] : pieces;
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 (Array.isArray(value)) return value;
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) === "d8d558baad3e03832e521c527907fa50a9a172fabd899dd0f5c2504a5a0e9349") return 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) !== "d8d558baad3e03832e521c527907fa50a9a172fabd899dd0f5c2504a5a0e9349") throw new Error(`splinter.sql from ${SPLINTER_URL} does not match the pinned hash; refusing to run it.`);
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 TENANT = {
400
- name: "tenant",
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 org of the current request: a top-level \`org_id\` claim (custom access
421
- -- token hook) or \`app_metadata.org_id\` (set through the Auth admin API).
422
- create or replace function better_supabase.current_org_id()
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(coalesce(auth.jwt() ->> 'org_id', auth.jwt() -> 'app_metadata' ->> 'org_id'), '')::uuid
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 ENTITLEMENTS = {
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 memberships claim: [{ tenant_id, roles, entitlements }]. Call it from
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,memberships}',
541
- -- better_supabase.membership_claims((event ->> 'user_id')::uuid));
542
- create or replace function better_supabase.membership_claims(user_id uuid)
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(jsonb_agg(jsonb_build_object(
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
- where m.user_id = membership_claims.user_id
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.membership_claims(uuid) from public, anon, authenticated;
559
- grant execute on function better_supabase.membership_claims(uuid) to service_role, supabase_auth_admin;`
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 REALTIME_TABLES = {
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 tenant topics of their own org.
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()) ->> 'org_id',
1316
- (select auth.jwt()) -> 'app_metadata' ->> 'org_id',
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 (org_id, ...) go in the JWT.
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 memberships claim carries the customer's entitlements, for
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${module.sql.trim()}\n${extra}`
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" || next === null || Array.isArray(next)) table[part] = {};
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" || value === null || Array.isArray(value)) return void 0;
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" || hooks === null || Array.isArray(hooks)) return [];
2979
+ if (typeof hooks !== "object" || Array.isArray(hooks)) return [];
2937
2980
  return Object.entries(hooks).flatMap(([hook, table]) => {
2938
- if (typeof table !== "object" || table === null || Array.isArray(table)) return [];
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.DATABASE_URL) return env.DATABASE_URL;
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.SUPABASE_ACCESS_TOKEN ?? void 0;
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.SUPABASE_API_URL ? { apiUrl: env.SUPABASE_API_URL } : {}
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 50 ms and more than ${SLOW_CALLS} calls. Runs with \`--stats\`.`,
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 > 50 order by total_exec_time desc limit 20`);
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((${call}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims')::text) as bytes`);
4049
- return row?.bytes === null || row?.bytes === void 0 ? 0 : Number(row.bytes);
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
- code: "BS404",
4056
- severity: "error",
4057
- title: "Auth hook function grants",
4058
- 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.",
4059
- check: (context) => configuredHooks(context).flatMap(({ config, extras }) => {
4060
- if (!extras) return [];
4061
- const location = hookLocation(context, config.hook);
4062
- const where = location ? { location } : {};
4063
- if (extras.functions.length === 0) return [{
4064
- message: `[auth.hook.${config.hook}] calls ${config.schema}.${config.name}, which does not exist. Sign-ins fail until it does.`,
4065
- target: `[auth.hook.${config.hook}]`,
4066
- ...where
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
- code: "BS405",
4084
- severity: "warning",
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
- if (!context.hookUser) continue;
4106
- const db = context.database;
4107
- if (!db || "skipped" in db || !db.session) {
4108
- findings.push({
4109
- severity: "info",
4110
- message: `Measuring the hook's claims needs a direct database connection (local stack or --db-url)${db && "skipped" in db ? `: ${db.skipped}` : "."}`,
4111
- target: `${signatureOf(fn)}:claims`
4112
- });
4113
- continue;
4114
- }
4115
- try {
4116
- const bytes = await hookClaimsBytes(db, fn, context.hookUser);
4117
- if (bytes === void 0) findings.push({
4118
- severity: "info",
4119
- message: `--as ${context.hookUser}: no such user in auth.users, so the hook was not called.`,
4120
- target: `${signatureOf(fn)}:claims`
4121
- });
4122
- else if (bytes > 2048) findings.push({
4123
- message: `${signatureOf(fn)} returns ${bytes} bytes of claims for ${context.hookUser} (limit ${HOOK_CLAIMS_LIMIT}). Every request carries them twice (cookie and header); keep ids and roles in the token and look up the rest.`,
4124
- target: `${signatureOf(fn)}:claims`,
4125
- object
4126
- });
4127
- } catch (cause) {
4128
- findings.push({
4129
- message: `Calling ${signatureOf(fn)} for ${context.hookUser} failed: ${errorText(cause)}`,
4130
- target: `${signatureOf(fn)}:claims`,
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
- return findings;
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
- /** A policy that reads memberships or the tenant claim: `is_member(...)`, `current_org_id()`, `memberships`. */
4429
- const TENANT_HELPER = /member|tenant|current_org|org_id/i;
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.SUPABASE_BIN ?? "supabase", args, cwd);
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.HOME ?? homedir() : config.root;
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 {