permdock 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 (349) hide show
  1. package/README.md +106 -47
  2. package/dist/a2a/index.d.ts +7 -16
  3. package/dist/a2a/index.js +59 -98
  4. package/dist/adapter-context-mlWsVGGg.js +23 -0
  5. package/dist/ai-sdk/index.d.ts +37 -16
  6. package/dist/ai-sdk/index.js +30 -13
  7. package/dist/approvals/index.d.ts +3 -3
  8. package/dist/approvals/index.js +5 -4
  9. package/dist/{approvals-DaxY-Bdg.js → approvals-DbYAuNkG.js} +17 -11
  10. package/dist/approvers-DT4AVyIg.js +52 -0
  11. package/dist/{arazzo-CTeVuXr5.js → arazzo-BAgw9ap6.js} +2 -2
  12. package/dist/{arazzo-aoTkqYsJ.js → arazzo-cmvX2jDe.js} +2 -2
  13. package/dist/authzen/index.d.ts +4 -13
  14. package/dist/authzen/index.js +16 -50
  15. package/dist/better-auth/index.d.ts +20 -3
  16. package/dist/better-auth/index.js +27 -7
  17. package/dist/better-supabase/index.d.ts +206 -0
  18. package/dist/better-supabase/index.js +620 -0
  19. package/dist/bind-CVy3Espv.d.ts +25 -0
  20. package/dist/bind-VnYn2HKq.js +97 -0
  21. package/dist/boundary-BM8a_P6E.js +13 -0
  22. package/dist/boundary-BmHxVH1J.d.ts +15 -0
  23. package/dist/canonical-json-DemiK4Vp.js +26 -0
  24. package/dist/{capability-B68OFnZx.js → capability-4qPdO9gk.js} +7 -5
  25. package/dist/{capability-BDG_nkxt.d.ts → capability-Dq43tT1u.d.ts} +2 -2
  26. package/dist/catalog/index.d.ts +1 -1
  27. package/dist/catalog/index.js +1 -1
  28. package/dist/catalog-BjZSovXQ.js +2 -0
  29. package/dist/{catalog-doc-BSo_Q_-6.js → catalog-doc-Dsc9y7sd.js} +23 -12
  30. package/dist/{catalog-B0E6bggc.js → catalog-h2tlKFkq.js} +2 -2
  31. package/dist/{catalog-H-vFGwWK.js → catalog-z8q6bi_r.js} +4 -4
  32. package/dist/claude-agent/index.d.ts +5 -14
  33. package/dist/claude-agent/index.js +4 -10
  34. package/dist/clerk/index.d.ts +12 -3
  35. package/dist/clerk/index.js +26 -5
  36. package/dist/cli/bin.js +1 -1
  37. package/dist/cli/index.d.ts +3 -3
  38. package/dist/cli/index.js +2 -2
  39. package/dist/clients-C7zLFuWT.d.ts +12 -0
  40. package/dist/clients-DjU5ABMy.js +23 -0
  41. package/dist/cloud/index.d.ts +4 -5
  42. package/dist/cloud/index.js +20 -6
  43. package/dist/{cloud-BNQTrmrr.js → cloud-BGhHS-lY.js} +2 -2
  44. package/dist/{cloud-Cuxr6Czw.js → cloud-Bo3e9Ak3.js} +7 -6
  45. package/dist/{collect-O-tOtbnk.js → collect-DDDv_ESs.js} +194 -42
  46. package/dist/collect-DLlujdHC.js +83 -0
  47. package/dist/collect-rG2IQ3t0.js +2 -0
  48. package/dist/{commands-BMh_Etm2.js → commands-DWOrSQic.js} +16 -12
  49. package/dist/compile/index.d.ts +3 -0
  50. package/dist/compile/index.js +2 -0
  51. package/dist/{compile-D22iwvuV.js → compile-Ba4blROR.js} +85 -30
  52. package/dist/compile-CNnl6uIt.d.ts +94 -0
  53. package/dist/config-BJiM-xAm.js +157 -0
  54. package/dist/config-Bn00Z3ks.js +36 -0
  55. package/dist/config-report-Dw_JcUqT.js +44 -0
  56. package/dist/{connection-JvqwHODC.js → connection-CU8MQxRi.js} +1 -1
  57. package/dist/{context-CKEAabd7.js → context-Ttp0ekMT.js} +36 -27
  58. package/dist/convex/index.d.ts +9 -4
  59. package/dist/convex/index.js +14 -4
  60. package/dist/create-BWyx3jua.d.ts +245 -0
  61. package/dist/create-CPhDL6qx.js +253 -0
  62. package/dist/{credential-B_rkUNlY.js → credential-8P81ff4u.js} +26 -46
  63. package/dist/delegation-D8pUEuce.js +103 -0
  64. package/dist/{describe-Bt1YD5uS.js → describe-B_gVZEVi.js} +10 -15
  65. package/dist/{describe-DernyItf.d.ts → describe-PdoqESvg.d.ts} +9 -10
  66. package/dist/{diff-x5Il8LVa.js → diff-B8Tunc3o.js} +44 -11
  67. package/dist/{diff-BUzf5Poi.js → diff-CzqLxZ77.js} +2 -2
  68. package/dist/doctor-WpvG6p4k.js +1194 -0
  69. package/dist/doctor-collect-ByfYlNv1.js +664 -0
  70. package/dist/{doctor-Bqo6FwUa.js → doctor-rOr-4ZfS.js} +2 -2
  71. package/dist/drizzle/index.d.ts +4 -3
  72. package/dist/drizzle/index.js +3 -3
  73. package/dist/elysia/index.d.ts +6 -28
  74. package/dist/elysia/index.js +16 -60
  75. package/dist/{errors-DSMnAVNa.js → errors-CWfoNg9N.js} +8 -4
  76. package/dist/{errors-Iy71s7Oj.js → errors-Cz5zn0mW.js} +17 -71
  77. package/dist/{errors-DbZ1YOnb.d.ts → errors-DAqevpNk.d.ts} +8 -21
  78. package/dist/{errors-m3u9TmDu.js → errors-DRnGZHzF.js} +4 -1
  79. package/dist/evaluation-items-CfCCejQd.js +40 -0
  80. package/dist/{evaluations-DsAU9ppl.js → evaluations-tz6D9A2G.js} +28 -46
  81. package/dist/eve/index.d.ts +6 -15
  82. package/dist/eve/index.js +7 -12
  83. package/dist/expiry-BoBJ8Tvc.js +7 -0
  84. package/dist/express/index.d.ts +6 -29
  85. package/dist/express/index.js +28 -58
  86. package/dist/fastify/index.d.ts +6 -29
  87. package/dist/fastify/index.js +16 -65
  88. package/dist/fields-BMl1RxoX.js +26 -0
  89. package/dist/{files-B_wAGYmt.js → files-DWUN9PzI.js} +5 -1
  90. package/dist/{fixtures-Cvaw0gDG.js → fixtures-ht28Cplr.js} +5 -2
  91. package/dist/{freeze-y1_jQL4v.js → freeze-CrU8nQeX.js} +31 -2
  92. package/dist/{from-snapshot-BMGnhmgv.js → from-snapshot-C3sHpZNS.js} +65 -48
  93. package/dist/{grantee-Bv04_Vzf.js → grantee-Cv5Sogfv.js} +88 -7
  94. package/dist/graph-sql-B-dsAT6j.d.ts +29 -0
  95. package/dist/{headers-B5RRv3Xm.js → headers-BZSDieNT.js} +1 -1
  96. package/dist/{headers-C8SHQccD.d.ts → headers-Cf-zWvsO.d.ts} +1 -1
  97. package/dist/helpers-H_KARDVC.js +421 -0
  98. package/dist/hono/index.d.ts +6 -28
  99. package/dist/hono/index.js +11 -43
  100. package/dist/{hosted-BwKQ1BTA.js → hosted-DJQw46hn.js} +6 -5
  101. package/dist/{http-GgVApX1k.js → http-BIhmLfCc.js} +4 -20
  102. package/dist/http-SO7qGttf.js +44 -0
  103. package/dist/ids-DC4V4CFu.js +18 -0
  104. package/dist/{index-LF_hS-yn.d.ts → index-A0YNL8jZ.d.ts} +1 -1
  105. package/dist/index-CtE07CBF.d.ts +6 -0
  106. package/dist/{index-xIxJLwOQ.d.ts → index-D3vbkoSI.d.ts} +75 -7
  107. package/dist/{index-CltcQ4Bf.d.ts → index-D6_cSa1z.d.ts} +68 -5
  108. package/dist/index.d.ts +14 -9
  109. package/dist/index.js +22 -17
  110. package/dist/instance-options-BK_rNOSC.d.ts +11 -0
  111. package/dist/instance-options-Dhhcnm3y.js +22 -0
  112. package/dist/{parse-DPfIvFMU.js → json-schema-BsAflUGW.js} +28 -57
  113. package/dist/jwt/index.d.ts +10 -6
  114. package/dist/jwt/index.js +32 -13
  115. package/dist/kernel-GSQnMz3O.js +208 -0
  116. package/dist/kysely/index.d.ts +4 -3
  117. package/dist/kysely/index.js +3 -3
  118. package/dist/live-options-H2eG8dfJ.js +60 -0
  119. package/dist/{load-BZ24CpZP.js → load-DhWEB9P1.js} +16 -10
  120. package/dist/load-DwJUafYN.js +2 -0
  121. package/dist/manifest-iaoBXHKc.js +783 -0
  122. package/dist/manifest-uDS9y5de.d.ts +206 -0
  123. package/dist/{map-DowSO96j.js → map-AcxK9o_D.js} +4 -15
  124. package/dist/{map-claims-B_6I1Rn-.js → map-claims-Z4QZOiFF.js} +13 -4
  125. package/dist/map-error-pgUWNNtG.js +23 -0
  126. package/dist/may-use-DndQ5tQ-.js +77 -0
  127. package/dist/mcp/index.d.ts +54 -22
  128. package/dist/mcp/index.js +218 -178
  129. package/dist/nest/index.d.ts +26 -31
  130. package/dist/nest/index.js +75 -84
  131. package/dist/next/client.d.ts +7 -8
  132. package/dist/next/client.js +11 -9
  133. package/dist/next/index.d.ts +30 -47
  134. package/dist/next/index.js +45 -60
  135. package/dist/next/plugin.d.ts +1 -1
  136. package/dist/next/plugin.js +32 -1
  137. package/dist/node/index.d.ts +9 -28
  138. package/dist/node/index.js +11 -43
  139. package/dist/openai/index.d.ts +6 -15
  140. package/dist/openai/index.js +7 -20
  141. package/dist/openapi/index.d.ts +87 -4
  142. package/dist/openapi/index.js +3 -3
  143. package/dist/{openapi-CwePaF7C.js → openapi-Bbg4wGVL.js} +3 -3
  144. package/dist/{openapi-DcqM8nU7.js → openapi-BqRBvAng.js} +237 -5
  145. package/dist/{openapi-DWD0Uc_4.js → openapi-CqqEbHsL.js} +5 -5
  146. package/dist/{openapi-import-BWRUNNeW.js → openapi-import-cmwetbzw.js} +1 -1
  147. package/dist/{openapi-schema-D8j_T9tO.js → openapi-schema--ARjyfQr.js} +2 -2
  148. package/dist/orpc/index.d.ts +15 -28
  149. package/dist/orpc/index.js +74 -117
  150. package/dist/otel/index.d.ts +2 -2
  151. package/dist/parse-BuyjxhZV.js +53 -0
  152. package/dist/parse-local-manifest-BkGQNzAh.d.ts +10 -0
  153. package/dist/parse-local-manifest-BrMozJCo.js +36 -0
  154. package/dist/pdp/index.d.ts +2 -2
  155. package/dist/pdp/index.js +14 -127
  156. package/dist/{permdock-yoZ7EjxX.js → permdock-CzqXjCpU.js} +641 -126
  157. package/dist/{permissions-B8vCg_bI.d.ts → permissions-CDgvKG5L.d.ts} +2 -2
  158. package/dist/{permissions-CofRqesP.js → permissions-CpZrivYv.js} +3 -3
  159. package/dist/{permissions-DC7GROPA.js → permissions-DVoF6dMt.js} +77 -15
  160. package/dist/{permissions-B4apnahQ.d.ts → permissions-DttnqqxC.d.ts} +34 -5
  161. package/dist/{pins-CqE1jZuF.js → pins-8ILTftC9.js} +2 -2
  162. package/dist/{policy-CInmNGkM.d.ts → policy-CFZAr_Al.d.ts} +330 -37
  163. package/dist/{policy-Cq2fB8Q5.d.ts → policy-D8UFyERw.d.ts} +2 -2
  164. package/dist/{policy-ANp36PnI.js → policy-xyWg7QdR.js} +173 -22
  165. package/dist/powersync-A8x_Yyvh.js +2 -0
  166. package/dist/powersync-C7kLglqX.js +540 -0
  167. package/dist/powersync-yW1UUFzp.js +62 -0
  168. package/dist/prisma/index.d.ts +3 -2
  169. package/dist/prisma/index.js +45 -20
  170. package/dist/{problem-Cgp9-KZA.js → problem-B43ztKIm.js} +74 -109
  171. package/dist/problem-details-CkL50EEf.d.ts +7 -0
  172. package/dist/problem-details-_AMOYz-w.js +144 -0
  173. package/dist/{protected-BuPAycJT.d.ts → protected-CNsMzrVd.d.ts} +5 -4
  174. package/dist/protected-Cs6kTaHo.js +100 -0
  175. package/dist/protected-bZs6rbT1.d.ts +40 -0
  176. package/dist/{provider-wTAUda2v.d.ts → provider-Cjr62YMV.d.ts} +1 -1
  177. package/dist/provider-DLzpb2Xz.js +52 -0
  178. package/dist/provider-store-DNyC1y48.js +43 -0
  179. package/dist/react/index.d.ts +7 -5
  180. package/dist/react/index.js +5 -4
  181. package/dist/react/provider-client.d.ts +1 -1
  182. package/dist/react/provider-client.js +1 -1
  183. package/dist/react-native/index.d.ts +166 -10
  184. package/dist/react-native/index.js +607 -85
  185. package/dist/{refs-ChLSxbST.js → refs-KYb4ntaz.js} +4 -3
  186. package/dist/{relations-r4soIFZB.js → relations-cjOL01hZ.js} +110 -31
  187. package/dist/replay-BsOyTrdV.js +28 -0
  188. package/dist/replay-sqBGMnNs.d.ts +5 -0
  189. package/dist/resource-ref-CaS9yVPi.js +20 -0
  190. package/dist/revocations-Bhr0aMIC.js +27 -0
  191. package/dist/rls-BMNydpHk.js +2521 -0
  192. package/dist/{rls-BOFU3chG.js → rls-CEvs64Kj.js} +8 -3
  193. package/dist/rls-fields-BMasREhR.js +3502 -0
  194. package/dist/{rls-graph-DxQ39PJj.js → rls-graph-pc9fuXYm.js} +100 -36
  195. package/dist/{rls-import-CXV6LBlS.js → rls-import-D8F7j35Q.js} +7 -6
  196. package/dist/{rls-migrate-DrFPbf8p.js → rls-migrate-7wikfrVI.js} +6 -5
  197. package/dist/rls-ownership-B5Lkq62W.js +843 -0
  198. package/dist/rls-shared-aOQ4hGTO.js +493 -0
  199. package/dist/rls-verify-D8B7dOTh.js +745 -0
  200. package/dist/{row-check-BajTgQZ-.d.ts → row-check-Dt35czLy.d.ts} +12 -15
  201. package/dist/rpc-DOK54_CS.js +89 -0
  202. package/dist/{run-CJ6UvS3a.js → run-YXCJDNYk.js} +31 -12
  203. package/dist/runtime-CB8gQFvS.js +11 -0
  204. package/dist/{saas-BRrZzJkM.js → saas-Bj_FFkjN.js} +17 -7
  205. package/dist/{schema-P8yoma__.js → schema-D3MnDMxa.js} +8 -4
  206. package/dist/scim/index.d.ts +1 -1
  207. package/dist/scim/index.js +1 -1
  208. package/dist/{scim-9NFa0XVZ.js → scim-BFGBBAqR.js} +1 -1
  209. package/dist/{scopes-cKVp_WTw.js → scopes-CVdPrWN-.js} +35 -8
  210. package/dist/server/index.d.ts +22 -11
  211. package/dist/server/index.js +11 -8
  212. package/dist/{credentials-BgRX3WEg.js → server-DlZ28Oz5.js} +1 -1
  213. package/dist/shared-DRu8VFVM.js +123 -0
  214. package/dist/{sink-Cfr9-7YC.d.ts → sink-Gs6TTVa-.d.ts} +1 -1
  215. package/dist/skills-Biq5uup3.js +192 -0
  216. package/dist/{skills-C_lpV4Jx.js → skills-JaymNCdK.js} +10 -4
  217. package/dist/{snapshot-DE8dE_hz.js → snapshot-Enxd0n-s.js} +138 -214
  218. package/dist/snapshot-cache-D6jLelln.js +44 -0
  219. package/dist/snapshot-cache-DwQH8ufx.d.ts +45 -0
  220. package/dist/solid/index.d.ts +39 -9
  221. package/dist/solid/index.js +76 -85
  222. package/dist/sources-B0UvrJ21.js +534 -0
  223. package/dist/sources-BcJgw0lt.d.ts +192 -0
  224. package/dist/{sql-Bll1d7y0.js → sql-xdg5f4Fi.js} +6 -1
  225. package/dist/{src-CTNT-PBW.js → src-CSbUiozF.js} +149 -11
  226. package/dist/ssf/index.d.ts +3 -2
  227. package/dist/ssf/index.js +3 -29
  228. package/dist/store-DMAWwAJV.js +371 -0
  229. package/dist/{store-Bq90395i.d.ts → store-D_pjn91-.d.ts} +13 -4
  230. package/dist/store-options-yO0V6gRn.js +716 -0
  231. package/dist/{stores-CkG2HrSg.js → stores-4_sqiOA1.js} +10 -63
  232. package/dist/{stores-CvOnu_2T.d.ts → stores-DVR5NHLE.d.ts} +20 -9
  233. package/dist/{stream-Q_xlB6Ko.js → stream-DDpvynSB.js} +16 -2
  234. package/dist/{stream-BhMfZOwr.d.ts → stream-DlvG2hs-.d.ts} +2 -2
  235. package/dist/subject-settings-BwBmCdTR.js +128 -0
  236. package/dist/supabase/index.d.ts +122 -6
  237. package/dist/supabase/index.js +4 -3
  238. package/dist/supabase/middleware.d.ts +32 -27
  239. package/dist/supabase/middleware.js +56 -22
  240. package/dist/{supabase-D8UlBA7Z.js → supabase-CwafwxxF.js} +3 -3
  241. package/dist/{supabase-DTxGtYdi.js → supabase-DTBaC20O.js} +345 -367
  242. package/dist/supabase-config-Bs44dare.js +472 -0
  243. package/dist/{supabase-hook-D_OqGb_V.js → supabase-hook-BuxLQLpU.js} +994 -708
  244. package/dist/supabase-hook-D5qVd_D8.js +2 -0
  245. package/dist/supautils-DYZnzRWV.js +91 -0
  246. package/dist/svelte/PermissionBoundary.svelte +37 -0
  247. package/dist/svelte/Protected.svelte +8 -2
  248. package/dist/svelte/index.d.ts +9 -6
  249. package/dist/svelte/index.js +61 -7
  250. package/dist/svelte/runtime.d.ts +4 -3
  251. package/dist/svelte/runtime.js +4 -3
  252. package/dist/svelte/source.d.ts +9 -6
  253. package/dist/svelte/source.js +7 -4
  254. package/dist/terminal/index.d.ts +6 -14
  255. package/dist/terminal/index.js +44 -32
  256. package/dist/testing/index.d.ts +105 -16
  257. package/dist/testing/index.js +504 -40
  258. package/dist/testing/saas/index.d.ts +4 -4
  259. package/dist/testing/saas/index.js +2 -2
  260. package/dist/testing/saas/permissions.d.ts +1 -1
  261. package/dist/testing/saas/permissions.js +1 -1
  262. package/dist/token-CIWxJOCh.js +50 -0
  263. package/dist/trpc/index.d.ts +7 -26
  264. package/dist/trpc/index.js +17 -100
  265. package/dist/types-9AWO9EGt.d.ts +197 -0
  266. package/dist/{types-D6Z6-rKJ.d.ts → types-BPxgszlT.d.ts} +260 -22
  267. package/dist/{types-BvBk1wgn.d.ts → types-BVyImy5I.d.ts} +5 -3
  268. package/dist/{types-CxquUByI.d.ts → types-Bvc4oWT6.d.ts} +1 -1
  269. package/dist/types-C3s9xVJG.d.ts +48 -0
  270. package/dist/{types-BBK-exvJ.d.ts → types-Dnemwd77.d.ts} +7 -1
  271. package/dist/types-kxznc7cn.d.ts +52 -0
  272. package/dist/{types-B8w5PFtS.d.ts → types-vKyAMtcX.d.ts} +17 -1
  273. package/dist/{types-B9dmiiab.d.ts → types-vNSDF7Dq.d.ts} +1 -1
  274. package/dist/{sources-DOpW5Ila.d.ts → types-yF5LmQVD.d.ts} +86 -127
  275. package/dist/unplugin/index.d.ts +1 -1
  276. package/dist/unplugin/index.js +10 -9
  277. package/dist/{usage-1ozMubLY.js → usage-D07N9QJ3.js} +2 -2
  278. package/dist/usage-D82l1CwK.js +2 -0
  279. package/dist/{usage-CUnK6Ou9.js → usage-DG1oeCca.js} +225 -62
  280. package/dist/validation-error-CnjZ4t92.js +33 -0
  281. package/dist/validation-error-CtF23SrC.d.ts +26 -0
  282. package/dist/{validity-D0uRmJKJ.js → validity-uOp8UpYn.js} +57 -36
  283. package/dist/{verifier-B90agnhl.js → verifier-BxBo6I44.js} +10 -5
  284. package/dist/version-BvacVTZR.js +17 -0
  285. package/dist/views-Soe7bGSC.js +95 -0
  286. package/dist/{vocabulary-Dk0fs-Jk.d.ts → vocabulary-DA8a8zif.d.ts} +1 -1
  287. package/dist/vue/index.d.ts +35 -13
  288. package/dist/vue/index.js +86 -93
  289. package/dist/watch-Etoa_Mvr.js +2 -0
  290. package/dist/watch-UihESpsJ.js +128 -0
  291. package/dist/webmcp/index.d.ts +2 -2
  292. package/dist/webmcp/index.js +7 -13
  293. package/package.json +62 -36
  294. package/schemas/approval-request-v1.json +175 -0
  295. package/schemas/catalog-v1.json +29 -1
  296. package/schemas/supabase-claims-v1.json +6 -1
  297. package/schemas/supabase-manifest-v1.json +150 -6
  298. package/skills/permdock/SKILL.md +7 -7
  299. package/skills/permdock-agents/SKILL.md +5 -5
  300. package/skills/permdock-agents/references/runtimes.md +12 -10
  301. package/skills/permdock-approvals/SKILL.md +4 -4
  302. package/skills/permdock-approvals/references/stores-and-resume.md +15 -4
  303. package/skills/permdock-audit/SKILL.md +3 -3
  304. package/skills/permdock-credentials/SKILL.md +4 -4
  305. package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
  306. package/skills/permdock-data/SKILL.md +4 -4
  307. package/skills/permdock-data/references/queries-and-rls.md +14 -6
  308. package/skills/permdock-data/references/relationships.md +5 -3
  309. package/skills/permdock-tenancy/SKILL.md +9 -6
  310. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +14 -8
  311. package/skills/permdock-wire/SKILL.md +4 -4
  312. package/skills/permdock-wire/references/adapters.md +42 -30
  313. package/skills/permdock-wire/references/policy-options.md +5 -3
  314. package/dist/approvers-aH0PJkmf.js +0 -20
  315. package/dist/catalog-idsHc0vo.js +0 -2
  316. package/dist/collect-B7_TIfmg.js +0 -49
  317. package/dist/collect-fRPAQIWb.js +0 -2
  318. package/dist/compile-CQbvyIBr.d.ts +0 -30
  319. package/dist/config-BPYmjTOp.js +0 -26
  320. package/dist/context-gtAwAILx.js +0 -6
  321. package/dist/create-BKF3dgFk.d.ts +0 -162
  322. package/dist/create-CSkhDU9T.js +0 -222
  323. package/dist/doctor-BGle8Gqi.js +0 -1314
  324. package/dist/helpers-DnSOrhB9.js +0 -682
  325. package/dist/index-fG6NSXlc.d.ts +0 -9
  326. package/dist/kernel-YOGCWNLf.js +0 -211
  327. package/dist/load-QOOm1z70.js +0 -2
  328. package/dist/manifest-DDQbC6K0.d.ts +0 -118
  329. package/dist/map-error-RJ3ZsUyA.js +0 -16
  330. package/dist/may-use-CnFEjVEp.js +0 -37
  331. package/dist/pg-4GJWDUxG.js +0 -953
  332. package/dist/plugin-4gr7IxFJ.js +0 -65
  333. package/dist/protected-B4K3BKoX.js +0 -138
  334. package/dist/protected-wY7nXhY9.d.ts +0 -23
  335. package/dist/provider-_wx46HpB.js +0 -48
  336. package/dist/rls-CJVja9Ww.js +0 -2119
  337. package/dist/rls-helpers-BxRuif0l.js +0 -1346
  338. package/dist/runtime-DP9ttFh1.js +0 -27
  339. package/dist/skills-Dx3QLnMY.js +0 -110
  340. package/dist/source-DoBBYJi2.js +0 -6
  341. package/dist/sources-DVHM3Mwq.js +0 -284
  342. package/dist/store-C6hFW6ta.js +0 -508
  343. package/dist/subject-settings-xt7V845g.js +0 -82
  344. package/dist/supabase-hook-DW-xP4hU.js +0 -2
  345. package/dist/types-DbWdEfcm.d.ts +0 -87
  346. package/dist/types-bva3i4Gf.d.ts +0 -98
  347. package/dist/types-i4BAOvdb.d.ts +0 -97
  348. package/dist/usage-PyEor6GA.js +0 -2
  349. package/dist/version-CnSh33Fj.js +0 -16
@@ -1,404 +1,238 @@
1
- import { a as resolveScope, c as scopeChain, s as rootScope, u as scopeList } from "./scopes-cKVp_WTw.js";
2
- import { r as SUPABASE_MANIFEST_SCHEMA } from "./version-CnSh33Fj.js";
3
- import { i as sqlFiles, r as rel } from "./files-B_wAGYmt.js";
4
- import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-BZ24CpZP.js";
1
+ import { a as hasConditionOp } from "./refs-KYb4ntaz.js";
2
+ import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CVdPrWN-.js";
3
+ import { i as SUPABASE_MANIFEST_SCHEMA } from "./version-BvacVTZR.js";
4
+ import { a as sqlFiles } from "./files-DWUN9PzI.js";
5
+ import { a as loadModule, n as asPolicy, o as pickNamed } from "./load-DhWEB9P1.js";
5
6
  import { t as shortDiff } from "./text-diff-BdkkYSbi.js";
6
- import { r as scopeColumn } from "./compile-D22iwvuV.js";
7
- import { o as authorizeSql, r as readMemberships } from "./supabase-DTxGtYdi.js";
7
+ import { r as scopeColumn } from "./compile-Ba4blROR.js";
8
+ import { t as authorizeSql } from "./supabase-DTBaC20O.js";
8
9
  import "./budget-NbEtbJC_.js";
9
- import { t as AUTHZ_VERSION_TABLE } from "./sources-DVHM3Mwq.js";
10
+ import { c as roleManifest, l as keptKeys, t as AUTHZ_VERSION_TABLE } from "./sources-B0UvrJ21.js";
11
+ import { i as readMemberships } from "./manifest-iaoBXHKc.js";
10
12
  import { a as hookMarkerFields, n as HOOK_MARKER, t as GRANTS_MARKER } from "./markers-B0hYLvMD.js";
11
- import { A as memberForSources, E as hasMemberFor, H as scopeTypeOf, I as quoteIdent, L as quoteLiteral, M as memberIdsHelper, R as quoteTable, h as activeRowSql, j as memberForTable, k as memberForHelper, p as globalRoleSource, y as checkSuspension } from "./rls-helpers-BxRuif0l.js";
12
- import { r as FIELD_VIEWS, t as connectPg, y as roleNames } from "./pg-4GJWDUxG.js";
13
+ import { f as splitUndiffed, h as tableKey, m as decidingColumns, n as supabaseConfig, r as MIGRATION_DIRS } from "./supabase-config-Bs44dare.js";
14
+ import { E as hasMemberFor, F as memberForSources, G as quoteLiteral, H as permittedIdsHelper, I as memberForTable, K as quoteTable, L as memberIdsHelper, P as memberForHelper, R as memberRoleOf, U as qualifiedTable, V as permittedForHelper, W as quoteIdent, Z as scopeTypeOf, f as USER_ID_HELPER, h as activeRowSql, it as tenantTypeOf, n as HELPERS, v as checkSuspension } from "./rls-shared-aOQ4hGTO.js";
15
+ import { G as API_KEY_ALLOWS, K as apiKeyFields, U as roleNames, _ as permissionKeysForHelper, w as connectPg, z as globalRoleSource } from "./rls-fields-BMasREhR.js";
16
+ import { a as ownershipRules, i as guardedTables, n as OWNERSHIP, t as ASSIGNMENTS } from "./rls-ownership-B5Lkq62W.js";
13
17
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
14
- import { dirname, join, posix, resolve } from "node:path";
15
- import { createRequire } from "node:module";
16
- import { parse } from "smol-toml";
17
- //#region src/cli/deciding-columns.ts
18
- /** `schema.table`, unquoted and lower case; `public` when unqualified. */
19
- function tableKey(name) {
20
- const parts = name.split(".").map((part) => part.replaceAll("\"", ""));
21
- return (parts.length === 1 ? ["public", ...parts] : parts).join(".").toLowerCase();
22
- }
23
- /**
24
- * The columns of each membership source's table that decide who holds which
25
- * membership (user, scope, id, `within`, role, `via`, expiry), keyed by
26
- * {@link tableKey}. A client that can write one can give itself a membership.
27
- */
28
- function membershipColumns(config) {
29
- const sources = config.supabase?.hook?.memberships ?? config.rls?.membershipSources ?? [];
30
- const byTable = /* @__PURE__ */ new Map();
31
- for (const source of sources) {
32
- const table = tableKey(source.sql.table);
33
- const columns = byTable.get(table) ?? /* @__PURE__ */ new Set();
34
- for (const column of source.sql.columns) columns.add(column);
35
- byTable.set(table, columns);
36
- }
37
- return byTable;
38
- }
39
- /**
40
- * Every `schema.table.column` a claim or a membership is computed from: the
41
- * membership columns and the `attrs` table columns. Sorted.
42
- */
43
- function decidingColumns(config, attrs) {
44
- const entries = /* @__PURE__ */ new Set();
45
- for (const [table, columns] of membershipColumns(config)) for (const column of columns) entries.add(`${table}.${column}`);
46
- if (attrs?.table !== void 0) {
47
- const table = tableKey(attrs.table);
48
- for (const column of attrs.columns) entries.add(`${table}.${column}`);
18
+ import { dirname, join, resolve } from "node:path";
19
+ /** `--check`: one entry per file that is missing or differs, naming its part; a differing file carries a short diff. */
20
+ function driftOf(cwd, files) {
21
+ const drift = [];
22
+ for (const file of files) {
23
+ if (file.rel === "-") continue;
24
+ const path = resolve(cwd, file.rel);
25
+ if (!existsSync(path)) drift.push(`${file.part}: missing ${file.rel}`);
26
+ else {
27
+ const onDisk = readFileSync(path, "utf8");
28
+ if (onDisk !== file.text) drift.push(`${file.part}: ${file.rel}\n${shortDiff(file.rel, onDisk, file.text)}`);
29
+ }
49
30
  }
50
- return [...entries].toSorted();
31
+ return drift;
51
32
  }
52
- //#endregion
53
- //#region src/cli/sql-statements.ts
54
- function blankComments(sql) {
55
- return sql.replaceAll(/--[^\n]*/gu, (comment) => " ".repeat(comment.length)).replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replaceAll(/[^\n]/gu, " "));
56
- }
57
- /** Split on `;` outside string literals and dollar-quoted bodies. */
58
- function sqlStatements(sql) {
59
- const text = blankComments(sql);
60
- const statements = [];
61
- const push = (start, end) => {
62
- const body = text.slice(start, end);
63
- const lead = body.length - body.trimStart().length;
64
- const trimmed = body.trim();
65
- if (trimmed !== "") statements.push({
66
- line: text.slice(0, start + lead).split("\n").length,
67
- text: trimmed
68
- });
69
- };
70
- let start = 0;
71
- let index = 0;
72
- while (index < text.length) {
73
- const char = text[index];
74
- if (char === "'") {
75
- const end = text.indexOf("'", index + 1);
76
- index = end === -1 ? text.length : end + 1;
33
+ /** Writes every file but the stdout ones, whose text it returns for printing. */
34
+ function writeSqlFiles(cwd, files) {
35
+ const wrote = [];
36
+ const printed = [];
37
+ for (const file of files) {
38
+ if (file.rel === "-") {
39
+ printed.push(file.text.trimEnd());
77
40
  continue;
78
41
  }
79
- if (char === "$") {
80
- const tag = /^\$[A-Za-z_]*\$/u.exec(text.slice(index, index + 64));
81
- if (tag !== null) {
82
- const end = text.indexOf(tag[0], index + tag[0].length);
83
- index = end === -1 ? text.length : end + tag[0].length;
84
- continue;
85
- }
86
- }
87
- if (char === ";") {
88
- push(start, index);
89
- start = index + 1;
90
- }
91
- index += 1;
92
- }
93
- push(start, text.length);
94
- return statements;
95
- }
96
- /** Capture group `index` of a match, or '' when an optional group took no part. */
97
- function group(match, index) {
98
- return match[index] ?? "";
99
- }
100
- //#endregion
101
- //#region src/cli/doctor-project.ts
102
- function pd005(cwd) {
103
- const lockPath = join(cwd, ".permdock/skills-lock.json");
104
- if (![
105
- join(cwd, ".agents/skills"),
106
- join(cwd, ".claude/skills"),
107
- join(cwd, ".cursor/skills")
108
- ].some((folder) => existsSync(join(folder, "permdock/SKILL.md")))) return [{
109
- code: "PD005",
110
- severity: "warning",
111
- message: "Agent Skills are not installed",
112
- fix: "pnpm exec permdock skills install"
113
- }];
114
- if (!existsSync(lockPath)) return [{
115
- code: "PD005",
116
- severity: "warning",
117
- message: "skills lock is missing",
118
- fix: "pnpm exec permdock skills install"
119
- }];
120
- return [];
121
- }
122
- function pd006(cwd) {
123
- try {
124
- const pkg = createRequire(resolve(cwd, "package.json"))("typescript/package.json");
125
- const major = Number(pkg.version.split(".")[0]);
126
- if (major < 5 || major === 5 && Number(pkg.version.split(".")[1]) < 9) return [{
127
- code: "PD006",
128
- severity: "error",
129
- message: `TypeScript ${pkg.version} is below the supported matrix (5.9, 6, 7)`,
130
- fix: "upgrade typescript to 5.9 or later"
131
- }];
132
- if (major > 7) return [{
133
- code: "PD006",
134
- severity: "error",
135
- message: `TypeScript ${pkg.version} is not in the supported matrix (5.9, 6, 7)`,
136
- fix: "use TypeScript 5.9, 6 or 7"
137
- }];
138
- return [];
139
- } catch {
140
- return [{
141
- code: "PD006",
142
- severity: "error",
143
- message: "typescript is not installed",
144
- fix: "add typescript 5.9, 6 or 7"
145
- }];
42
+ const path = resolve(cwd, file.rel);
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, file.text);
45
+ wrote.push(file.rel);
146
46
  }
47
+ return {
48
+ wrote,
49
+ printed: printed.join("\n")
50
+ };
147
51
  }
148
- function pd009(cwd) {
149
- const copies = [];
150
- function walk(dir, depth) {
151
- if (depth > 6 || !existsSync(dir)) return;
152
- const pkg = join(dir, "node_modules/permdock/package.json");
153
- if (existsSync(pkg)) copies.push(pkg);
154
- if (!existsSync(join(dir, "node_modules"))) return;
155
- for (const name of readdirSync(join(dir, "node_modules"))) {
156
- if (name.startsWith(".")) continue;
157
- const nested = join(dir, "node_modules", name);
158
- try {
159
- if (statSync(nested).isDirectory()) walk(nested, depth + 1);
160
- } catch {}
161
- }
162
- }
163
- walk(cwd, 0);
164
- if (copies.length > 1) return [{
165
- code: "PD009",
166
- severity: "error",
167
- message: `duplicate permdock copies: ${copies.map((item) => rel(cwd, dirname(item))).join(", ")}`,
168
- fix: "dedupe so only one permdock version is installed"
169
- }];
170
- return [];
52
+ const PARTS = [
53
+ "helpers",
54
+ "seeds",
55
+ "indexes",
56
+ "policies",
57
+ "hook"
58
+ ];
59
+ /** `--split helpers,seeds,indexes,policies,hook`: the parts in that fixed order, or an error. */
60
+ function parseSplit(raw) {
61
+ if (raw === void 0) return;
62
+ const names = raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
63
+ if (names.filter((name) => !PARTS.some((part) => part === name)).length > 0 || names.length === 0) return `rls generate --split takes helpers, seeds, indexes, policies and hook, got '${raw}'`;
64
+ return PARTS.filter((part) => names.includes(part));
171
65
  }
172
- function pd012(cwd, config) {
173
- const docs = config.openapi?.doc ?? [];
174
- const findings = [];
175
- for (const doc of docs) {
176
- const abs = resolve(cwd, doc);
177
- if (!existsSync(abs)) continue;
178
- const text = readFileSync(abs, "utf8");
179
- if (text.includes("\"drafts\"") && !text.includes("overlay")) findings.push({
180
- code: "PD012",
181
- severity: "warning",
182
- message: `${doc} carries a draft pin the CLI no longer emits`,
183
- fix: "regenerate with permdock openapi"
184
- });
185
- }
186
- return findings;
66
+ /** The path of one split part: `{part}` in `out` replaced by the part name. */
67
+ function partPath(out, part) {
68
+ return out.replaceAll("{part}", part);
187
69
  }
188
- const MIGRATION_DIRS = [
189
- "supabase/migrations",
190
- "supabase/schemas",
191
- "migrations",
192
- "drizzle",
193
- "prisma/migrations",
194
- "db/migrations"
195
- ];
196
- const VIEW_NAME = String.raw`((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)`;
197
- const CREATE_VIEW = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?:temp(?:orary)?\s+)?(?:recursive\s+)?view\s+${VIEW_NAME}([\s\S]*?)\bas\b`, "giu");
198
- const ALTER_VIEW = new RegExp(String.raw`\balter\s+view\s+(?:if\s+exists\s+)?${VIEW_NAME}\s+set\s*\(([^)]*)\)`, "giu");
199
- const INVOKER = /\bsecurity_invoker\s*(?:=\s*(?:true|on|'true'|'on'|1)\b|[,)]|$)/iu;
200
- const COMPANION = new RegExp(String.raw`\bcomment\s+on\s+view\s+${VIEW_NAME}\s+is\s+'${FIELD_VIEWS.comment}\b`, "giu");
201
70
  /**
202
- * Views run as their owner unless `security_invoker` is set, so they read
203
- * past RLS on the tables beneath them. The `<table>_visible_fields`
204
- * companion `rls generate --revoke-columns` writes reads as its owner on
205
- * purpose and carries a comment that says so.
71
+ * pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
72
+ * their dependencies, so no file needs a number to apply first.
206
73
  */
207
- function pd022(cwd, config) {
208
- if (config.rls === void 0 && config.doctor?.migrations === void 0) return [];
209
- const created = /* @__PURE__ */ new Map();
210
- const invoker = /* @__PURE__ */ new Set();
211
- const companions = /* @__PURE__ */ new Set();
212
- for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) {
213
- const raw = readFileSync(file, "utf8");
214
- const text = raw.replaceAll(/--[^\n]*/gu, "").replaceAll(/\/\*[\s\S]*?\*\//gu, "");
215
- for (const [, name = "", options = ""] of text.matchAll(CREATE_VIEW)) {
216
- const key = tableKey(name);
217
- created.set(key, rel(cwd, file));
218
- if (INVOKER.test(options)) invoker.add(key);
219
- else invoker.delete(key);
220
- }
221
- for (const [, name = "", options = ""] of text.matchAll(ALTER_VIEW)) if (INVOKER.test(options)) invoker.add(tableKey(name));
222
- for (const [, name = ""] of raw.matchAll(COMPANION)) companions.add(tableKey(name));
74
+ function pgDeltaPath(root, part, schema) {
75
+ switch (part) {
76
+ case "helpers":
77
+ case "indexes":
78
+ case "seeds": return `${root}/${schema}/${part}.sql`;
79
+ case "policies": return `${root}/public/policies/permdock.sql`;
80
+ case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
81
+ default: return part;
223
82
  }
224
- return [...created].filter(([key]) => !invoker.has(key) && !companions.has(key)).map(([key, file]) => ({
225
- code: "PD022",
226
- severity: "warning",
227
- message: `view ${key} in ${file} is not security_invoker, so it reads past row level security`,
228
- fix: `create the view with (security_invoker = true), or alter view ${key} set (security_invoker = true); Postgres 15 or later. For column-level reads, generate field views with permdock rls generate --fields views`
229
- }));
230
83
  }
231
- const AUTH_ROLE = /\bauth\.role\s*\(\s*\)/giu;
232
- /**
233
- * PD040: `auth.role()` in a migration. Supabase deprecated it; a policy
234
- * names its Postgres roles with `to authenticated` instead, and the role
235
- * claim it reads is the one PostgREST already switched to.
236
- */
237
- function pd040(cwd, config) {
238
- const findings = [];
239
- for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) {
240
- const text = readFileSync(file, "utf8").replaceAll(/--[^\n]*/gu, (comment) => " ".repeat(comment.length)).replaceAll(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replaceAll(/[^\n]/gu, " "));
241
- for (const match of text.matchAll(AUTH_ROLE)) {
242
- const line = text.slice(0, match.index).split("\n").length;
243
- findings.push({
244
- code: "PD040",
245
- severity: "warning",
246
- message: `${rel(cwd, file)}:${String(line)} calls auth.role(), which Supabase deprecated`,
247
- fix: "name the roles on the policy (create policy ... to authenticated) and drop the auth.role() = 'authenticated' test; call permdock_has or permitted_<scope>_ids for permissions"
248
- });
249
- }
250
- }
251
- return findings;
84
+ /** Whether `--seeds-out` names a migrations directory: it ends with \`/\` or is an existing directory. */
85
+ function isSeedsDirectory(cwd, out) {
86
+ if (out === "-") return false;
87
+ if (out.endsWith("/")) return true;
88
+ const path = resolve(cwd, out);
89
+ return existsSync(path) && statSync(path).isDirectory();
90
+ }
91
+ /** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
92
+ function migrationVersion(now) {
93
+ return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
252
94
  }
253
- const GRANT = /\b(grant|revoke)\s+([\s\S]+?)\s+on\s+(?:table\s+)?([\w."]+)\s+(?:to|from)\s+([\w\s,"]+?)(?:\s+with\s+grant\s+option|\s+cascade|\s+restrict)?\s*;/giu;
254
- const CLIENT_ROLES = /* @__PURE__ */ new Set([
255
- "anon",
256
- "authenticated",
257
- "public"
258
- ]);
259
- function applyPrivilege(writable, verb, part) {
260
- const match = /^\s*(all(?:\s+privileges)?|insert|update)\s*(?:\(([^)]*)\))?\s*$/iu.exec(part);
261
- if (match === null) return;
262
- const kind = (match[1] ?? "").toLowerCase();
263
- const sets = kind === "insert" ? [writable.insert] : kind === "update" ? [writable.update] : [writable.insert, writable.update];
264
- const columns = match[2] === void 0 ? ["*"] : match[2].split(",").map((column) => column.trim().replaceAll("\"", ""));
265
- for (const set of sets) for (const column of columns) if (verb.toLowerCase() === "grant") set.add(column);
266
- else if (column === "*") set.clear();
267
- else set.delete(column);
268
- }
269
- const CREATE_TABLE = /^create\s+(?:(?:global|local)\s+)?(?:(?:temp|temporary|unlogged)\s+)?table\s+(?:if\s+not\s+exists\s+)?((?:"[^"]+"|\w+)(?:\.(?:"[^"]+"|\w+))?)\s*\(/iu;
270
- const REVOKE_DEFAULT_TABLES = /^alter\s+default\s+privileges\s+(?:for\s+(?:role|user)\s+[\w\s,"]+?\s+)?in\s+schema\s+"?public"?\s+revoke\s+[\s\S]+?\s+on\s+tables\s+from\s+[\s\S]*\b(?:anon|authenticated)\b/iu;
271
95
  /**
272
- * Columns of `table` a client role may insert or update, per the migrations
273
- * in order; `*` for every column. On Supabase a table created in `public`
274
- * starts writable: the default privileges grant `anon` and `authenticated`
275
- * everything, until a migration revokes them.
96
+ * The seeds part in a migrations directory: the newest migration there that
97
+ * starts with \`marker\` when its text is \`text\` already, otherwise a new
98
+ * \`<version>_permdock_seeds.sql\`. \`current\` is false when a new one is due.
276
99
  */
277
- function clientWritable(cwd, config, target) {
278
- const writable = {
279
- insert: /* @__PURE__ */ new Set(),
280
- update: /* @__PURE__ */ new Set()
100
+ function seedsMigration(cwd, dir, marker, text, now) {
101
+ const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
102
+ const path = resolve(cwd, base);
103
+ const latest = existsSync(path) ? readdirSync(path).filter((name) => name.endsWith(".sql")).toSorted().toReversed().find((name) => readFileSync(resolve(path, name), "utf8").startsWith(marker)) : void 0;
104
+ if (latest !== void 0 && readFileSync(resolve(path, latest), "utf8") === text) return {
105
+ rel: `${base}/${latest}`,
106
+ current: true
107
+ };
108
+ return {
109
+ rel: `${base}/${migrationVersion(now)}_permdock_seeds.sql`,
110
+ current: false
281
111
  };
282
- let defaults = config.supabase !== void 0 && target.startsWith("public.");
283
- for (const file of sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS)) for (const { text } of sqlStatements(readFileSync(file, "utf8"))) {
284
- if (REVOKE_DEFAULT_TABLES.test(text)) {
285
- defaults = false;
286
- continue;
287
- }
288
- const created = CREATE_TABLE.exec(text);
289
- if (created !== null) {
290
- if (defaults && tableKey(created[1] ?? "") === target) {
291
- writable.insert.add("*");
292
- writable.update.add("*");
293
- }
294
- continue;
295
- }
296
- for (const [, verb = "", privileges = "", name = "", roles = ""] of `${text};`.matchAll(GRANT)) {
297
- if (tableKey(name) !== target) continue;
298
- if (!roles.split(",").map((role) => role.trim().replaceAll("\"", "").toLowerCase()).some((role) => CLIENT_ROLES.has(role))) continue;
299
- for (const part of privileges.split(/,(?![^(]*\))/u)) applyPrivilege(writable, verb, part);
300
- }
301
- }
302
- return /* @__PURE__ */ new Set([...writable.insert, ...writable.update]);
303
- }
304
- /** Membership columns a client can write: a user who sets `user_id` or `role` grants themselves access. */
305
- function membershipFindings(cwd, config) {
306
- const findings = [];
307
- for (const [table, columns] of membershipColumns(config)) {
308
- const writable = clientWritable(cwd, config, table);
309
- const exposed = [...columns].filter((column) => writable.has("*") || writable.has(column));
310
- if (exposed.length > 0) findings.push({
311
- code: "PD028",
312
- severity: "warning",
313
- message: `${table}.${exposed.join(", ")} decide${exposed.length === 1 ? "s" : ""} memberships, and the migrations let anon or authenticated insert or update ${exposed.length === 1 ? "it" : "them"}: a user could give themselves a membership`,
314
- fix: `revoke insert, update on ${table} from anon, authenticated, then grant update (<columns clients edit>) on ${table} to authenticated; a column-level revoke alone leaves a table-level grant in place`
315
- });
316
- }
317
- return findings;
318
112
  }
113
+ //#endregion
114
+ //#region src/cli/claims-schema.ts
319
115
  /**
320
- * `attrs` claims must come from server-owned columns: never `user_metadata`,
321
- * never a column clients can write. Membership sources' deciding columns are
322
- * held to the same rule.
116
+ * The PermDock claims of `schemas/supabase-claims-v1.json` (its
117
+ * `permdockClaims` and `membership` definitions), self-contained so
118
+ * `supabase.hook.validate` can inline it into the hook for pg_jsonschema.
323
119
  */
324
- function pd028(cwd, config, plan) {
325
- const memberships = membershipFindings(cwd, config);
326
- const attrs = config.supabase?.hook?.attrs;
327
- if (attrs === void 0) return memberships;
328
- const planned = plan(attrs);
329
- const findings = planned.errors.map((message) => ({
330
- code: "PD028",
331
- severity: "error",
332
- message,
333
- fix: "list server-owned columns or app_metadata.<key> entries; user_metadata is user-editable"
334
- }));
335
- if (planned.table !== void 0 && planned.columns.length > 0) {
336
- const writable = clientWritable(cwd, config, tableKey(planned.table));
337
- const exposed = planned.columns.filter((column) => writable.has("*") || writable.has(column));
338
- if (exposed.length > 0) findings.push({
339
- code: "PD028",
340
- severity: "warning",
341
- message: `attrs reads ${exposed.join(", ")} from ${tableKey(planned.table)}, which the migrations let anon or authenticated insert or update: a user could set their own attribute`,
342
- fix: `revoke insert, update on ${tableKey(planned.table)} from anon, authenticated, and grant column-level update only on columns that are not attributes; the generated hook migration refuses to install otherwise`
343
- });
344
- }
345
- return [...findings, ...memberships];
346
- }
347
- //#endregion
348
- //#region src/cli/supabase-config.ts
349
- const SUPABASE_CONFIG = "supabase/config.toml";
350
- function table$1(value) {
351
- if (value === null || typeof value !== "object" || Array.isArray(value)) return;
352
- return value;
353
- }
354
- function strings(value) {
355
- return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
356
- }
357
- /** `supabase/config.toml`, parsed; `undefined` when the project has none. */
358
- function readSupabaseConfig(cwd) {
359
- const file = join(cwd, SUPABASE_CONFIG);
360
- if (!existsSync(file)) return;
361
- let root;
362
- try {
363
- root = parse(readFileSync(file, "utf8"));
364
- } catch (error) {
365
- return {
366
- ok: false,
367
- error: error instanceof Error ? error.message : String(error)
368
- };
369
- }
370
- const expiry = table$1(root["auth"])?.["jwt_expiry"];
371
- const paths = table$1(table$1(root["db"])?.["migrations"])?.["schema_paths"];
372
- const schemaPaths = strings(paths);
373
- const apiSchemas = strings(table$1(root["api"])?.["schemas"]);
374
- const pgDelta = table$1(table$1(root["experimental"])?.["pgdelta"]);
375
- const schemaPath = pgDelta?.["declarative_schema_path"];
376
- return {
377
- ok: true,
378
- config: {
379
- ...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
380
- ...schemaPaths === void 0 ? {} : { schemaPaths },
381
- ...apiSchemas === void 0 ? {} : { apiSchemas },
382
- ...pgDelta?.["enabled"] === true ? { pgDelta: { schemaDir: posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "") } } : {}
120
+ const PERMDOCK_CLAIMS_SCHEMA = {
121
+ $schema: "https://json-schema.org/draft/2020-12/schema",
122
+ $ref: "#/$defs/permdockClaims",
123
+ $defs: {
124
+ permdockClaims: {
125
+ type: "object",
126
+ properties: {
127
+ user_role: {
128
+ description: "Global roles: one name, a list, or null for none.",
129
+ anyOf: [
130
+ { type: "string" },
131
+ {
132
+ type: "array",
133
+ items: { type: "string" }
134
+ },
135
+ { type: "null" }
136
+ ]
137
+ },
138
+ roles: {
139
+ type: "array",
140
+ items: { type: "string" }
141
+ },
142
+ memberships: {
143
+ type: "array",
144
+ items: { $ref: "#/$defs/membership" }
145
+ },
146
+ memberships_truncated: {
147
+ description: "true when the size budget cut memberships or attrs.",
148
+ type: "boolean"
149
+ },
150
+ tenant_id: {
151
+ description: "The active tenant (the default rls.tenantClaim): the canonical text of one instance of the first scope.",
152
+ type: "string"
153
+ },
154
+ attrs: {
155
+ description: "Allow-listed server-owned attributes, read as principal.claims.attrs.<key>.",
156
+ type: "object"
157
+ },
158
+ authz_ver: {
159
+ description: "The principal's authorization version.",
160
+ type: "integer"
161
+ }
162
+ }
163
+ },
164
+ membership: {
165
+ type: "object",
166
+ required: ["roles"],
167
+ properties: {
168
+ scope: { type: "string" },
169
+ id: {
170
+ description: "Canonical text of the scope instance id (uuid::text for a uuid column); compared exactly, never lower-cased.",
171
+ type: "string"
172
+ },
173
+ within: {
174
+ description: "The id of every ancestor scope instance, by scope name.",
175
+ type: "object",
176
+ additionalProperties: { type: "string" }
177
+ },
178
+ on: {
179
+ type: "object",
180
+ required: ["resource", "id"],
181
+ properties: {
182
+ resource: { type: "string" },
183
+ id: { type: "string" }
184
+ }
185
+ },
186
+ tenant: { type: "string" },
187
+ team: { type: "string" },
188
+ roles: {
189
+ type: "array",
190
+ minItems: 1,
191
+ items: { type: "string" }
192
+ },
193
+ via: { type: "string" },
194
+ expiresAt: {
195
+ description: "Seconds since the epoch.",
196
+ type: "number"
197
+ },
198
+ grantedBy: { type: "string" },
199
+ reason: { type: "string" },
200
+ member: {
201
+ description: "The subgroup a membership source's group fills.",
202
+ type: "object",
203
+ required: ["group"],
204
+ properties: { group: {
205
+ type: "string",
206
+ minLength: 1
207
+ } }
208
+ },
209
+ managedBy: { const: "idp" },
210
+ entitlements: {
211
+ type: "array",
212
+ items: { type: "string" }
213
+ },
214
+ keep: {
215
+ description: "Set when the instance or an ancestor is suspended: the permission keys the membership still grants.",
216
+ type: "array",
217
+ items: {
218
+ type: "string",
219
+ minLength: 1
220
+ }
221
+ },
222
+ grants: {
223
+ description: "Custom roles in compact form, read by the helpers in jwt mode.",
224
+ type: "object"
225
+ }
226
+ },
227
+ anyOf: [
228
+ { required: ["scope", "id"] },
229
+ { required: ["tenant"] },
230
+ { required: ["team"] },
231
+ { required: ["on"] }
232
+ ]
383
233
  }
384
- };
385
- }
386
- /** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
387
- function supabaseConfig(cwd) {
388
- const read = readSupabaseConfig(cwd);
389
- return read?.ok === true ? read.config : {};
390
- }
391
- /** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
392
- function pd045(cwd) {
393
- const read = readSupabaseConfig(cwd);
394
- if (read === void 0 || read.ok) return [];
395
- return [{
396
- code: "PD045",
397
- severity: "warning",
398
- message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
399
- fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then doctor assumes Supabase's defaults for jwt_expiry and schema_paths`
400
- }];
401
- }
234
+ }
235
+ };
402
236
  //#endregion
403
237
  //#region src/cli/rls-rbac.ts
404
238
  /**
@@ -483,196 +317,8 @@ revoke all on table ${q("user_roles")} from authenticated, anon, public;
483
317
  warnings: []
484
318
  };
485
319
  }
486
- /** `--check`: one entry per file that is missing or differs, naming its part; a differing file carries a short diff. */
487
- function driftOf(cwd, files) {
488
- const drift = [];
489
- for (const file of files) {
490
- if (file.rel === "-") continue;
491
- const path = resolve(cwd, file.rel);
492
- if (!existsSync(path)) drift.push(`${file.part}: missing ${file.rel}`);
493
- else {
494
- const onDisk = readFileSync(path, "utf8");
495
- if (onDisk !== file.text) drift.push(`${file.part}: ${file.rel}\n${shortDiff(file.rel, onDisk, file.text)}`);
496
- }
497
- }
498
- return drift;
499
- }
500
- /** Writes every file but the stdout ones, whose text it returns for printing. */
501
- function writeSqlFiles(cwd, files) {
502
- const wrote = [];
503
- const printed = [];
504
- for (const file of files) {
505
- if (file.rel === "-") {
506
- printed.push(file.text.trimEnd());
507
- continue;
508
- }
509
- const path = resolve(cwd, file.rel);
510
- mkdirSync(dirname(path), { recursive: true });
511
- writeFileSync(path, file.text);
512
- wrote.push(file.rel);
513
- }
514
- return {
515
- wrote,
516
- printed: printed.join("\n")
517
- };
518
- }
519
- const PARTS = [
520
- "helpers",
521
- "seeds",
522
- "indexes",
523
- "policies",
524
- "hook"
525
- ];
526
- /** `--split helpers,seeds,indexes,policies,hook`: the parts in that fixed order, or an error. */
527
- function parseSplit(raw) {
528
- if (raw === void 0) return;
529
- const names = raw.split(",").map((name) => name.trim()).filter((name) => name !== "");
530
- if (names.filter((name) => !PARTS.some((part) => part === name)).length > 0 || names.length === 0) return `rls generate --split takes helpers, seeds, indexes, policies and hook, got '${raw}'`;
531
- return PARTS.filter((part) => names.includes(part));
532
- }
533
- /** The path of one split part: `{part}` in `out` replaced by the part name. */
534
- function partPath(out, part) {
535
- return out.replaceAll("{part}", part);
536
- }
537
- /**
538
- * pg-delta's per-schema, unnumbered layout: pg-delta orders statements by
539
- * their dependencies, so no file needs a number to apply first.
540
- */
541
- function pgDeltaPath(root, part, schema) {
542
- switch (part) {
543
- case "helpers":
544
- case "indexes":
545
- case "seeds": return `${root}/${schema}/${part}.sql`;
546
- case "policies": return `${root}/public/policies/permdock.sql`;
547
- case "hook": return `${root}/${schema}/functions/custom_access_token_hook.sql`;
548
- default: return part;
549
- }
550
- }
551
320
  //#endregion
552
- //#region src/cli/supabase-setup.ts
553
- const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
554
- function escape(text) {
555
- return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
556
- }
557
- /** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
558
- function helpersDefined(sql, schema) {
559
- const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
560
- const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
561
- const names = /* @__PURE__ */ new Set();
562
- for (const match of sql.matchAll(create)) {
563
- const name = (match[1] ?? "").toLowerCase();
564
- if (HELPER_NAME.test(name)) names.add(name);
565
- }
566
- return names;
567
- }
568
- /** SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`) and the migration folders, not the hook itself. */
569
- function helperSources(cwd, config, hookOut) {
570
- const hook = resolve(cwd, hookOut);
571
- const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
572
- const rlsOut = resolve(cwd, config.rls?.out ?? "rls.sql");
573
- if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
574
- files.delete(hook);
575
- return [...files];
576
- }
577
- function missingHelpersInFiles(cwd, config, manifest) {
578
- const found = /* @__PURE__ */ new Set();
579
- for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
580
- return manifest.helpers.functions.filter((name) => !found.has(name));
581
- }
582
- async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
583
- const client = await connect(db);
584
- try {
585
- const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
586
- where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
587
- const found = new Set(result.rows.map((row) => row["proname"]));
588
- return manifest.helpers.functions.filter((name) => !found.has(name));
589
- } finally {
590
- await client.end();
591
- }
592
- }
593
- function missingHelpersMessage(manifest, missing) {
594
- return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
595
- }
596
- /** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
597
- function oversizedClaims(samples, manifest) {
598
- const encoder = new TextEncoder();
599
- const oversized = [];
600
- for (const claim of manifest.claims) {
601
- if (claim.source === "permdock") continue;
602
- let bytes = 0;
603
- for (const sample of samples) {
604
- if (sample === null || typeof sample !== "object") continue;
605
- const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
606
- if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
607
- }
608
- if (bytes > manifest.budget.bytes) oversized.push({
609
- name: claim.name,
610
- bytes
611
- });
612
- }
613
- return oversized;
614
- }
615
- /** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
616
- function droppedMemberships(samples) {
617
- const out = [];
618
- for (const [index, sample] of samples.entries()) {
619
- if (sample === null || typeof sample !== "object") continue;
620
- const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
621
- const { dropped } = readMemberships(claim);
622
- if (dropped.length > 0) out.push({
623
- sample: index,
624
- entries: dropped
625
- });
626
- }
627
- return out;
628
- }
629
- function pd039(input) {
630
- const findings = [];
631
- const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
632
- if (missing.length > 0) findings.push({
633
- code: "PD039",
634
- severity: "warning",
635
- message: missingHelpersMessage(input.manifest, missing).slice(6),
636
- fix: "run permdock rls generate, or set rls.out to the migration that holds the helpers"
637
- });
638
- const fixture = input.config.doctor?.claims;
639
- if (fixture !== void 0) {
640
- const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
641
- const samples = Array.isArray(parsed) ? parsed : [];
642
- for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
643
- code: "PD039",
644
- severity: "warning",
645
- message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
646
- fix: "return less from the claim function, or read the data per request instead of from the token"
647
- });
648
- for (const { sample, entries } of droppedMemberships(samples)) findings.push({
649
- code: "PD039",
650
- severity: "warning",
651
- message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
652
- fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
653
- });
654
- }
655
- return findings;
656
- }
657
- //#endregion
658
- //#region src/cli/supabase-hook.ts
659
- const SUPABASE_HELP = `permdock supabase hook generate | inspect
660
-
661
- hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
662
- [--active-from app_metadata.active_<scope>|<table>.<column>]
663
- [--budget 1024] [--schema public] [--grants-out <file>|-]
664
- inspect [--json] [--out [permdock.manifest.json]] [--check]
665
-
666
- Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
667
- passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
668
- needs (in --grants-out instead, for declarative schemas), the permdock_authz_version table
669
- and its triggers. Never grants anything to
670
- service_role. inspect prints the manifest: helper schema, names and signatures, tenant claim,
671
- budget, the claims the hook writes, the membership sources and the columns that decide them.
672
- --out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
673
- file differs.
674
- `;
675
- const MANIFEST_FILE = "permdock.manifest.json";
321
+ //#region src/cli/supabase-hook-sql.ts
676
322
  const MANAGED_TRIGGER = "permdock_protect_managed";
677
323
  const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
678
324
  const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
@@ -767,7 +413,7 @@ function extraClaimsPlan(claims, tenantClaim) {
767
413
  continue;
768
414
  }
769
415
  if (typeof fn !== "string" || !CLAIM_FUNCTION.test(fn)) {
770
- errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as better_supabase.feature_claims`);
416
+ errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as public.feature_claims`);
771
417
  continue;
772
418
  }
773
419
  planned.push({
@@ -780,6 +426,50 @@ function extraClaimsPlan(claims, tenantClaim) {
780
426
  errors
781
427
  };
782
428
  }
429
+ function beforePlan(before) {
430
+ const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
431
+ const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
432
+ if (bad.length > 0) throw new Error(`PermDock CLI: supabase.hook.before must name schema-qualified functions such as auth_checks.before_token (got ${bad.map(String).join(", ")})`);
433
+ return [...new Set(list)];
434
+ }
435
+ function beforeSql(before) {
436
+ return before.map((fn) => `
437
+ checked := ${quoteTable(fn)}(event);
438
+ if checked is null or jsonb_typeof(checked) is distinct from 'object' then
439
+ return jsonb_build_object('error', jsonb_build_object('http_code', 500, 'message', ${quoteLiteral(`${fn} returned no event`)}));
440
+ end if;
441
+ if checked ? 'error' then
442
+ return checked;
443
+ end if;
444
+ event := checked;`).join("").concat(before.length === 0 ? "" : "\n claims := event -> 'claims';");
445
+ }
446
+ /**
447
+ * `hook.validate`: the claims the hook writes, dropped together when they do
448
+ * not match `supabase-claims-v1.json`, so a bad claim denies instead of
449
+ * failing sign-in. `hook.claims` entries belong to other packages and stay.
450
+ */
451
+ function validateSql(parts) {
452
+ if (parts.hook.validate !== true) return "";
453
+ const keys = /* @__PURE__ */ new Set([
454
+ "user_role",
455
+ "roles",
456
+ "memberships",
457
+ "memberships_truncated",
458
+ "attrs",
459
+ "authz_ver",
460
+ parts.tenantClaim
461
+ ]);
462
+ return `
463
+ if not extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(PERMDOCK_CLAIMS_SCHEMA))}::json, claims) then
464
+ claims := claims${[...keys].map((key) => ` - ${quoteLiteral(key)}`).join("")};
465
+ end if;`;
466
+ }
467
+ function validateGrantsSql(parts) {
468
+ return parts.hook.validate === true ? `
469
+ create extension if not exists pg_jsonschema with schema extensions;
470
+ grant usage on schema extensions to supabase_auth_admin;
471
+ grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
472
+ }
783
473
  const VERSION_TRIGGER = "permdock_authz_version";
784
474
  const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
785
475
  function memberForPlan(input) {
@@ -806,23 +496,26 @@ function mappedMembership(mapped, scopes, name) {
806
496
  const column = scopeColumn(mapped, scopes, ancestor);
807
497
  if (column !== void 0) within[ancestor] = column;
808
498
  }
499
+ const role = memberRoleOf(mapped);
809
500
  const columns = [
810
501
  mapped.user,
811
502
  id,
812
- mapped.role,
503
+ ...role.columns,
813
504
  ...Object.values(within),
814
- ...mapped.via === void 0 ? [] : [mapped.via],
815
- ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt]
505
+ ...typeof mapped.via === "string" ? [mapped.via] : [],
506
+ ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
507
+ ...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
816
508
  ];
817
509
  return {
818
510
  table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
819
511
  user: { column: mapped.user },
820
512
  scope: { value: name },
821
513
  id: { column: id },
822
- role: { column: mapped.role },
514
+ role: roleManifest(role),
823
515
  ...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
824
- ...mapped.via === void 0 ? {} : { via: { column: mapped.via } },
516
+ ...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
825
517
  ...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
518
+ ...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
826
519
  columns: [...new Set(columns)]
827
520
  };
828
521
  }
@@ -893,7 +586,7 @@ function entriesSql(parts) {
893
586
  'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
894
587
  'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
895
588
  'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
896
- 'managedBy', s.managed_by, 'entitlements', s.seats
589
+ 'managedBy', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
897
590
  )) as entry
898
591
  from (
899
592
  ${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
@@ -957,8 +650,8 @@ declare
957
650
  in_active boolean := false;
958
651
  budget integer := ${String(parts.budget)};
959
652
  used integer := 0;
960
- item record;${typedUsers(parts)}${plan === void 0 ? "" : "\n attrs jsonb;"}${parts.extra.length === 0 ? "" : "\n extra jsonb;"}${parts.version ? "\n ver bigint;" : ""}
961
- begin${suspended}
653
+ item record;${typedUsers(parts)}${plan === void 0 ? "" : "\n attrs jsonb;"}${parts.extra.length === 0 ? "" : "\n extra jsonb;"}${parts.version ? "\n ver bigint;" : ""}${parts.before.length === 0 ? "" : "\n checked jsonb;"}
654
+ begin${beforeSql(parts.before)}${suspended}
962
655
  claims := claims - 'attrs'${dropExtra};
963
656
  ${roles}
964
657
  claims := jsonb_set(claims, '{roles}', held);
@@ -990,85 +683,382 @@ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
990
683
  end if;
991
684
  if in_active then
992
685
  claims := jsonb_set(claims, ${quoteLiteral(`{${parts.tenantClaim}}`)}, to_jsonb(active));
993
- end if;${extra}${version}
686
+ end if;${extra}${version}${validateSql(parts)}
994
687
  return jsonb_set(event, '{claims}', claims);
995
688
  end;
996
689
  $$;`;
997
690
  }
691
+ /** Everything the hook grants `supabase_auth_admin`, and the execute revoke. */
692
+ function grantsSql(parts) {
693
+ const schema = quoteIdent(parts.schema);
694
+ const fn = `${schema}.custom_access_token_hook`;
695
+ return [
696
+ `-- supabase_auth_admin: the grants and read policies the hook needs
697
+ grant usage on schema ${schema} to supabase_auth_admin;
698
+ grant execute on function ${fn}(jsonb) to supabase_auth_admin;
699
+ revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${beforeGrantsSql(parts.before, parts.extra)}${memberForGrantsSql(parts)}${validateGrantsSql(parts)}`,
700
+ readsSql(parts),
701
+ parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
702
+ ].filter((chunk) => chunk !== "").join("\n");
703
+ }
704
+ function extraGrantsSql(extra) {
705
+ return [...[...new Set(extra.map((entry) => entry.fn.split(".")[0]))].map((name) => `\ngrant usage on schema ${quoteIdent(name ?? "")} to supabase_auth_admin;`), ...extra.map((entry) => `\ngrant execute on function ${quoteTable(entry.fn)}(uuid) to supabase_auth_admin;`)].join("");
706
+ }
707
+ function beforeGrantsSql(before, extra) {
708
+ const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
709
+ return [...[...new Set(before.map((fn) => fn.split(".")[0] ?? ""))].filter((name) => !granted.has(name)).map((name) => `\ngrant usage on schema ${quoteIdent(name)} to supabase_auth_admin;`), ...before.map((fn) => `\ngrant execute on function ${quoteTable(fn)}(jsonb) to supabase_auth_admin;`)].join("");
710
+ }
711
+ /**
712
+ * Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
713
+ * functions, which run as `supabase_auth_admin` and may call them. Only with
714
+ * `hook.claims`: the grant needs the helpers migration applied first, and a
715
+ * hook without extra claims does not.
716
+ */
717
+ function memberForGrantsSql(parts) {
718
+ const { schema, memberFor } = parts.helpers;
719
+ if (memberFor.length === 0 || parts.extra.length === 0) return "";
720
+ return [...schema === parts.schema ? [] : [`\ngrant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`], ...memberFor.map((scope) => `\ngrant execute on function ${quoteIdent(schema)}.${memberForHelper(scope)}(uuid) to supabase_auth_admin;`)].join("");
721
+ }
722
+ function readsSql(parts) {
723
+ const reads = /* @__PURE__ */ new Map();
724
+ for (const source of parts.sources) reads.set(source.sql.table, "memberships");
725
+ for (const source of parts.sources) {
726
+ for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
727
+ for (const through of source.sql.throughs) reads.set(through.table, reads.get(through.table) ?? "role_keys");
728
+ const users = source.sql.userThrough;
729
+ if (users !== void 0) reads.set(users.table, reads.get(users.table) ?? "member_users");
730
+ }
731
+ if (parts.roles !== void 0) {
732
+ const { table: rolesTable, through } = parts.roles;
733
+ reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
734
+ if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
735
+ }
736
+ if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
737
+ if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
738
+ return [...[...new Set([...reads.keys()].map((name) => name.includes(".") ? name.split(".")[0] : "public").filter((schema) => schema !== void 0 && schema !== parts.schema && schema !== "auth"))].map((schema) => `grant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`), ...[...reads].map(([name, label]) => authAdminRead(name, label))].join("\n");
739
+ }
740
+ function versionSql(parts) {
741
+ if (!parts.version) return "";
742
+ const schema = quoteIdent(parts.schema);
743
+ const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
744
+ const bump = `${schema}.permdock_bump_authz_version`;
745
+ const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
746
+ const tables = [...new Map(parts.sources.filter((source) => source.sql.userThrough === void 0).map((source) => [source.sql.table, source.sql.user]))];
747
+ if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
748
+ return `-- the authorization version: bumped on every membership change, written to authz_ver
749
+ create table if not exists ${versionTable} (
750
+ user_id uuid primary key references auth.users on delete cascade,
751
+ version bigint not null default 0
752
+ );
753
+ alter table ${versionTable} enable row level security;
754
+ revoke all on table ${versionTable} from anon, authenticated, public;
755
+
756
+ -- bumps each listed user once, skipping users already deleted from auth.users; no client role may call it
757
+ create or replace function ${bumpFor}(p_users uuid[])
758
+ returns void
759
+ language sql
760
+ security definer
761
+ set search_path = ''
762
+ as $$
763
+ insert into ${versionTable} as v (user_id, version)
764
+ select distinct u, 1 from unnest(p_users) u
765
+ where u is not null and exists (select 1 from auth.users au where au.id = u)
766
+ on conflict (user_id) do update set version = v.version + 1
767
+ $$;
768
+ revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
769
+
770
+ create or replace function ${bump}()
771
+ returns trigger
772
+ language plpgsql
773
+ security definer
774
+ set search_path = ''
775
+ as $$
776
+ declare
777
+ column_name text := tg_argv[0];
778
+ begin
779
+ perform ${bumpFor}(array[
780
+ case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
781
+ case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
782
+ ]::uuid[]);
783
+ return null;
784
+ end;
785
+ $$;
786
+ revoke execute on function ${bump}() from public, anon, authenticated;
787
+ ${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
788
+ create trigger ${quoteIdent(VERSION_TRIGGER)}
789
+ after insert or update or delete on ${table(name)}
790
+ for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
791
+ }
792
+ const SUBJECT_FOR = "subject_for";
793
+ /**
794
+ * `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
795
+ * token for `p_user`, from the same membership, role, suspension and version
796
+ * reads, plus the custom roles the user holds when `database` mode keeps them
797
+ * in tables. A backend that only has PostgREST (`postgrestSources`) builds a
798
+ * subject for an acting user or a job from it. Naming the user is why no
799
+ * client role may execute it.
800
+ */
801
+ function subjectForSql(parts, config) {
802
+ const schema = quoteIdent(parts.schema);
803
+ const fn = `${schema}.${SUBJECT_FOR}`;
804
+ const rows = parts.roles;
805
+ const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
806
+ into held
807
+ from ${rows.from}
808
+ where ${rows.userSql} = v_roles_user;`;
809
+ const suspended = parts.users === void 0 ? "" : `
810
+ if not ${activeRowSql(parts.users, "uid")} then
811
+ return jsonb_build_object('id', uid::text, 'active', false);
812
+ end if;`;
813
+ const version = parts.version ? `
814
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
815
+ ver := coalesce(ver, 0);` : "";
816
+ const custom = config.rls?.customRoles === true && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
817
+ return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
818
+ create or replace function ${fn}(p_user uuid)
819
+ returns jsonb
820
+ language plpgsql
821
+ stable
822
+ security definer
823
+ set search_path = ''
824
+ as $$
825
+ declare
826
+ uid uuid := p_user;
827
+ held jsonb;
828
+ memberships jsonb;
829
+ custom jsonb := '[]'::jsonb;
830
+ ver bigint;${typedUsers(parts)}
831
+ begin
832
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
833
+ return null;
834
+ end if;${suspended}
835
+ ${roles}
836
+ select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
837
+ into memberships
838
+ from (
839
+ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
840
+ ) x;${custom}${version}
841
+ return jsonb_strip_nulls(jsonb_build_object(
842
+ 'id', uid::text,
843
+ 'active', true,
844
+ 'roles', held,
845
+ 'memberships', memberships,
846
+ 'customRoles', custom,
847
+ 'authzVersion', ver
848
+ ));
849
+ end;
850
+ $$;
851
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
852
+ }
853
+ const AUTHZ_VERSION_FOR = "authz_version_for";
854
+ /**
855
+ * `authz_version_for(p_user uuid) returns bigint`: the authorization version
856
+ * `subject_for` reports, without reading roles or memberships, so a
857
+ * `claimsFirst` reread can check a token's freshness on every request and read
858
+ * the full subject only when it is behind. `null` for an unknown or suspended
859
+ * user, and when the version is off.
860
+ */
861
+ function authzVersionForSql(parts) {
862
+ const schema = quoteIdent(parts.schema);
863
+ const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
864
+ return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
865
+ create or replace function ${fn}(p_user uuid)
866
+ returns bigint
867
+ language plpgsql
868
+ stable
869
+ security definer
870
+ set search_path = ''
871
+ as $$
872
+ declare
873
+ uid uuid := p_user;
874
+ ver bigint;
875
+ begin
876
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
877
+ return null;
878
+ end if;${parts.users === void 0 ? "" : `
879
+ if not ${activeRowSql(parts.users, "uid")} then
880
+ return null;
881
+ end if;`}${parts.version ? `
882
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
883
+ ver := coalesce(ver, 0);` : ""}
884
+ return ver;
885
+ end;
886
+ $$;
887
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
888
+ }
889
+ const MEMBERS_OF = "members_of";
890
+ function membersOfSql(parts) {
891
+ const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
892
+ const keep = parts.sources.some((source) => source.sql.keeps);
893
+ const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
894
+ return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
895
+ create or replace function ${fn}(p_scope text, p_id text)
896
+ returns jsonb
897
+ language sql
898
+ stable
899
+ security definer
900
+ set search_path = ''
901
+ as $$
902
+ select coalesce(jsonb_agg(jsonb_build_object(
903
+ 'principal', jsonb_build_object('id', s.user_id),
904
+ 'membership', jsonb_strip_nulls(jsonb_build_object(
905
+ 'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
906
+ 'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
907
+ 'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
908
+ 'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
909
+ ))
910
+ ) order by s.user_id, s.roles::text), '[]'::jsonb)
911
+ from (
912
+ ${rows}
913
+ ) s
914
+ $$;
915
+ revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
916
+ }
998
917
  /**
999
- * Everything the hook grants `supabase_auth_admin`, and the execute revoke:
1000
- * the statements `supabase db diff` drops, so `--grants-out` moves them to
1001
- * their own file.
918
+ * The custom roles `p_user` holds: tenant roles named on a membership of the
919
+ * scope (and instance) they live at, and platform roles among the global
920
+ * roles, each with its grants and includes in `CustomRole` form.
1002
921
  */
1003
- function grantsSql(parts) {
1004
- const schema = quoteIdent(parts.schema);
1005
- const fn = `${schema}.custom_access_token_hook`;
1006
- return [
1007
- `-- supabase_auth_admin: the grants and read policies the hook needs
1008
- grant usage on schema ${schema} to supabase_auth_admin;
1009
- grant execute on function ${fn}(jsonb) to supabase_auth_admin;
1010
- revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${memberForGrantsSql(parts)}`,
1011
- readsSql(parts),
1012
- parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
1013
- ].filter((chunk) => chunk !== "").join("\n");
1014
- }
1015
- function extraGrantsSql(extra) {
1016
- return [...[...new Set(extra.map((entry) => entry.fn.split(".")[0]))].map((name) => `\ngrant usage on schema ${quoteIdent(name ?? "")} to supabase_auth_admin;`), ...extra.map((entry) => `\ngrant execute on function ${quoteTable(entry.fn)}(uuid) to supabase_auth_admin;`)].join("");
922
+ function customRolesOfSql(parts) {
923
+ const helpers = quoteIdent(parts.helpers.schema);
924
+ const perms = `${helpers}.custom_role_permissions`;
925
+ const includes = `${helpers}.custom_role_includes`;
926
+ const root = quoteLiteral(parts.root);
927
+ const match = `p.tenant_id is not distinct from c.tenant_id and p.scope = c.scope and p.scope_id is not distinct from c.scope_id and p.role = c.role`;
928
+ return `
929
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
930
+ 'name', c.role,
931
+ 'tenant', c.tenant_id::text,
932
+ 'scope', c.scope,
933
+ 'id', c.scope_id,
934
+ 'grants', (
935
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
936
+ 'permission', p.permission,
937
+ 'effect', p.effect,
938
+ 'level', to_jsonb(p) ->> 'level'
939
+ )) order by p.permission, p.effect), '[]'::jsonb)
940
+ from ${perms} p
941
+ where ${match}
942
+ ),
943
+ 'includes', (
944
+ select jsonb_agg(p.include_role order by p.include_role)
945
+ from ${includes} p
946
+ where ${match}
947
+ )
948
+ )) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
949
+ into custom
950
+ from (
951
+ select tenant_id, scope, scope_id, role from ${perms}
952
+ union
953
+ select tenant_id, scope, scope_id, role from ${includes}
954
+ ) c
955
+ where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
956
+ or exists (
957
+ select 1
958
+ from jsonb_array_elements(memberships) m
959
+ where m -> 'roles' ? c.role
960
+ and m ->> 'scope' = c.scope
961
+ and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
962
+ and (c.scope_id is null or c.scope_id = m ->> 'id')
963
+ );`;
964
+ }
965
+ function signedUp(user) {
966
+ return `exists (select 1 from auth.users a where a.id = ${user})`;
1017
967
  }
1018
968
  /**
1019
- * Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
1020
- * functions, which run as `supabase_auth_admin` and may call them. Only with
1021
- * `hook.claims`: the grant needs the helpers migration applied first, and a
1022
- * hook without extra claims does not.
969
+ * Every membership source whose user id lives in another table: a row of
970
+ * the source bumps the users its old and new references hold, and a change
971
+ * of that table's user id (a contact re-linked to another login) bumps the
972
+ * old and the new user.
1023
973
  */
1024
- function memberForGrantsSql(parts) {
1025
- const { schema, memberFor } = parts.helpers;
1026
- if (memberFor.length === 0 || parts.extra.length === 0) return "";
1027
- return [...schema === parts.schema ? [] : [`\ngrant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`], ...memberFor.map((scope) => `\ngrant execute on function ${quoteIdent(schema)}.${memberForHelper(scope)}(uuid) to supabase_auth_admin;`)].join("");
1028
- }
1029
- function readsSql(parts) {
1030
- const reads = /* @__PURE__ */ new Map();
1031
- for (const source of parts.sources) reads.set(source.sql.table, "memberships");
1032
- for (const source of parts.sources) for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
1033
- if (parts.roles !== void 0) {
1034
- const { table: rolesTable, through } = parts.roles;
1035
- reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
1036
- if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
974
+ function memberUsersVersionSql(parts, bumpFor) {
975
+ const holders = /* @__PURE__ */ new Map();
976
+ for (const source of parts.sources) {
977
+ const users = source.sql.userThrough;
978
+ if (users !== void 0) holders.set(JSON.stringify([
979
+ tableKey(source.sql.table),
980
+ tableKey(users.table),
981
+ users.id,
982
+ users.key,
983
+ users.ref
984
+ ]), {
985
+ table: source.sql.table,
986
+ users
987
+ });
1037
988
  }
1038
- if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
1039
- if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
1040
- return [...[...new Set([...reads.keys()].map((name) => name.includes(".") ? name.split(".")[0] : "public").filter((schema) => schema !== void 0 && schema !== parts.schema && schema !== "auth"))].map((schema) => `grant usage on schema ${quoteIdent(schema)} to supabase_auth_admin;`), ...[...reads].map(([name, label]) => authAdminRead(name, label))].join("\n");
1041
- }
1042
- function versionSql(parts) {
1043
- if (!parts.version) return "";
1044
- const schema = quoteIdent(parts.schema);
1045
- const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
1046
- const bump = `${schema}.permdock_bump_authz_version`;
1047
- const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
1048
- const tables = [...new Map(parts.sources.map((source) => [source.sql.table, source.sql.user]))];
1049
- if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
1050
- return `-- the authorization version: bumped on every membership change, written to authz_ver
1051
- create table if not exists ${versionTable} (
1052
- user_id uuid primary key references auth.users on delete cascade,
1053
- version bigint not null default 0
1054
- );
1055
- alter table ${versionTable} enable row level security;
1056
- revoke all on table ${versionTable} from anon, authenticated, public;
989
+ if (holders.size === 0) return "";
990
+ const byTable = /* @__PURE__ */ new Map();
991
+ for (const holder of holders.values()) {
992
+ const key = tableKey(holder.table);
993
+ const group = byTable.get(key);
994
+ if (group === void 0) byTable.set(key, {
995
+ table: holder.table,
996
+ users: [holder.users]
997
+ });
998
+ else group.users.push(holder.users);
999
+ }
1000
+ const memberBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_member_users`;
1001
+ const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
1002
+ const groups = [...byTable];
1003
+ const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
1004
+ ${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
1005
+ ${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
1006
+ ${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
1007
+ ${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
1008
+ ${indent}));`;
1009
+ const body = groups.map(([key, group]) => {
1010
+ if (groups.length === 1) return group.users.map((users) => lookup(users, " ")).join("\n");
1011
+ const dot = key.indexOf(".");
1012
+ return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
1013
+ ${group.users.map((users) => lookup(users, " ")).join("\n")}
1014
+ end if;`;
1015
+ }).join("\n");
1016
+ const memberTriggers = groups.map(([, group]) => {
1017
+ const target = table(group.table);
1018
+ return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1019
+ create trigger ${quoteIdent(VERSION_TRIGGER)}
1020
+ after insert or update or delete on ${target}
1021
+ for each row execute function ${memberBump}();`;
1022
+ }).join("\n");
1023
+ const userTables = /* @__PURE__ */ new Map();
1024
+ for (const { users } of holders.values()) {
1025
+ const key = JSON.stringify([tableKey(users.table), users.key]);
1026
+ const entry = userTables.get(key) ?? {
1027
+ table: users.table,
1028
+ key: users.key,
1029
+ ids: /* @__PURE__ */ new Set()
1030
+ };
1031
+ entry.ids.add(users.id);
1032
+ userTables.set(key, entry);
1033
+ }
1034
+ const userTriggers = [...userTables.values()].map((entry) => {
1035
+ const target = table(entry.table);
1036
+ const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
1037
+ return `drop trigger if exists ${name} on ${target};
1038
+ create trigger ${name}
1039
+ after update of ${[entry.key, ...entry.ids].map(quoteIdent).join(", ")} or delete on ${target}
1040
+ for each row execute function ${linkedBump}(${quoteLiteral(entry.key)});`;
1041
+ }).join("\n");
1042
+ return `
1057
1043
 
1058
- -- bumps each listed user once, for a membership source outside the hook; no client role may call it
1059
- create or replace function ${bumpFor}(p_users uuid[])
1060
- returns void
1061
- language sql
1044
+ -- a membership whose user id lives in another table bumps the users its old and new rows reference
1045
+ create or replace function ${memberBump}()
1046
+ returns trigger
1047
+ language plpgsql
1062
1048
  security definer
1063
1049
  set search_path = ''
1064
1050
  as $$
1065
- insert into ${versionTable} as v (user_id, version)
1066
- select distinct u, 1 from unnest(p_users) u where u is not null
1067
- on conflict (user_id) do update set version = v.version + 1
1051
+ begin
1052
+ ${body}
1053
+ return null;
1054
+ end;
1068
1055
  $$;
1069
- revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
1056
+ revoke execute on function ${memberBump}() from public, anon, authenticated;
1057
+ ${memberTriggers}
1070
1058
 
1071
- create or replace function ${bump}()
1059
+ -- re-linking that table's user id moves every membership that references the row: bump the old and the new user,
1060
+ -- skipping a login being deleted (its version row goes with it)
1061
+ create or replace function ${linkedBump}()
1072
1062
  returns trigger
1073
1063
  language plpgsql
1074
1064
  security definer
@@ -1077,29 +1067,92 @@ as $$
1077
1067
  declare
1078
1068
  column_name text := tg_argv[0];
1079
1069
  begin
1080
- perform ${bumpFor}(array[
1081
- case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
1082
- case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
1083
- ]::uuid[]);
1070
+ perform ${bumpFor}(array(
1071
+ select u
1072
+ from unnest(array[
1073
+ case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
1074
+ case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
1075
+ ]::uuid[]) u
1076
+ where ${signedUp("u")}
1077
+ ));
1084
1078
  return null;
1085
1079
  end;
1086
1080
  $$;
1087
- revoke execute on function ${bump}() from public, anon, authenticated;
1088
- ${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
1089
- create trigger ${quoteIdent(VERSION_TRIGGER)}
1090
- after insert or update or delete on ${table(name)}
1091
- for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${roleKeysVersionSql(parts, versionTable)}`;
1081
+ revoke execute on function ${linkedBump}() from public, anon, authenticated;
1082
+ ${userTriggers}`;
1083
+ }
1084
+ /** Every table whose role column references a roles table: the global roles and the membership sources. */
1085
+ function roleKeyHolders(parts) {
1086
+ const holders = [];
1087
+ if (parts.roles?.through !== void 0) holders.push({
1088
+ table: parts.roles.table,
1089
+ user: parts.roles.user,
1090
+ through: parts.roles.through
1091
+ });
1092
+ for (const source of parts.sources) for (const through of source.sql.throughs) holders.push({
1093
+ table: source.sql.table,
1094
+ user: source.sql.user,
1095
+ ...source.sql.userThrough === void 0 ? {} : { userThrough: source.sql.userThrough },
1096
+ through
1097
+ });
1098
+ const seen = /* @__PURE__ */ new Set();
1099
+ return holders.filter((holder) => {
1100
+ const key = JSON.stringify([
1101
+ tableKey(holder.table),
1102
+ holder.user,
1103
+ holder.userThrough === void 0 ? null : [
1104
+ tableKey(holder.userThrough.table),
1105
+ holder.userThrough.id,
1106
+ holder.userThrough.key
1107
+ ],
1108
+ tableKey(holder.through.table),
1109
+ holder.through.id,
1110
+ holder.through.key,
1111
+ holder.through.ref
1112
+ ]);
1113
+ if (seen.has(key)) return false;
1114
+ seen.add(key);
1115
+ return true;
1116
+ });
1117
+ }
1118
+ /** The bump for one roles table: unchanged keys and ids return early, else every holder of `old` is bumped. */
1119
+ function roleKeysBumpSql(holders, versionTable, indent) {
1120
+ return `${indent}if tg_op = 'UPDATE'
1121
+ ${indent} and ${[...new Set(holders.flatMap((holder) => [holder.through.key, holder.through.id]))].map((column) => `new.${quoteIdent(column)} is not distinct from old.${quoteIdent(column)}`).join(`\n${indent} and `)} then
1122
+ ${indent} return null;
1123
+ ${indent}end if;
1124
+ ${indent}insert into ${versionTable} as v (user_id, version)
1125
+ ${indent}select distinct h.user_id, 1
1126
+ ${indent}from (
1127
+ ${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${table(holder.table)} h where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}` : `select u.${quoteIdent(holder.userThrough.key)}::uuid as user_id from ${table(holder.table)} h join ${table(holder.userThrough.table)} u on u.${quoteIdent(holder.userThrough.id)} = h.${quoteIdent(holder.user)} where h.${quoteIdent(holder.through.ref)} = old.${quoteIdent(holder.through.id)}`).join(`\n${indent} union\n${indent} `)}
1128
+ ${indent}) h
1129
+ ${indent}where h.user_id is not null
1130
+ ${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
1131
+ ${indent}on conflict (user_id) do update set version = v.version + 1;`;
1092
1132
  }
1093
- /** A renamed role key changes every holder's `roles` claim, so updates to the roles table bump each holder. */
1133
+ /**
1134
+ * A renamed role key changes the `roles` claim of everyone who holds it and
1135
+ * the `roles` of every membership that references it, so updates to a roles
1136
+ * table bump each holder.
1137
+ */
1094
1138
  function roleKeysVersionSql(parts, versionTable) {
1095
- const through = parts.roles?.through;
1096
- if (parts.roles === void 0 || through === void 0) return "";
1139
+ const holders = roleKeyHolders(parts);
1140
+ if (holders.length === 0) return "";
1141
+ const byTable = /* @__PURE__ */ new Map();
1142
+ for (const holder of holders) {
1143
+ const key = tableKey(holder.through.table);
1144
+ const group = byTable.get(key);
1145
+ if (group === void 0) byTable.set(key, {
1146
+ table: holder.through.table,
1147
+ holders: [holder]
1148
+ });
1149
+ else group.holders.push(holder);
1150
+ }
1097
1151
  const bump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_role_keys`;
1098
- const id = quoteIdent(through.id);
1099
- const key = quoteIdent(through.key);
1152
+ const groups = [...byTable];
1100
1153
  return `
1101
1154
 
1102
- -- a renamed role key changes the roles claim of everyone who holds it
1155
+ -- a renamed role key changes the roles of everyone who holds it, globally or through a membership
1103
1156
  create or replace function ${bump}()
1104
1157
  returns trigger
1105
1158
  language plpgsql
@@ -1107,24 +1160,24 @@ security definer
1107
1160
  set search_path = ''
1108
1161
  as $$
1109
1162
  begin
1110
- if tg_op = 'UPDATE'
1111
- and new.${key} is not distinct from old.${key}
1112
- and new.${id} is not distinct from old.${id} then
1113
- return null;
1114
- end if;
1115
- insert into ${versionTable} as v (user_id, version)
1116
- select distinct h.${quoteIdent(parts.roles.user)}::uuid, 1
1117
- from ${table(parts.roles.table)} h
1118
- where h.${quoteIdent(through.ref)} = old.${id}
1119
- on conflict (user_id) do update set version = v.version + 1;
1163
+ ${groups.map(([key, group]) => {
1164
+ if (groups.length === 1) return roleKeysBumpSql(group.holders, versionTable, " ");
1165
+ const dot = key.indexOf(".");
1166
+ return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
1167
+ ${roleKeysBumpSql(group.holders, versionTable, " ")}
1168
+ end if;`;
1169
+ }).join("\n")}
1120
1170
  return null;
1121
1171
  end;
1122
1172
  $$;
1123
1173
  revoke execute on function ${bump}() from public, anon, authenticated;
1124
- drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(through.table)};
1174
+ ${groups.map(([, group]) => {
1175
+ const target = table(group.table);
1176
+ return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1125
1177
  create trigger ${quoteIdent(VERSION_TRIGGER)}
1126
- after update or delete on ${table(through.table)}
1178
+ after update or delete on ${target}
1127
1179
  for each row execute function ${bump}();`;
1180
+ }).join("\n")}`;
1128
1181
  }
1129
1182
  /**
1130
1183
  * Fails the migration when a client role can write an `attrs` column: a
@@ -1216,6 +1269,7 @@ function hookParts(scopes, config, overrides) {
1216
1269
  active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
1217
1270
  attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
1218
1271
  extra: extraPlan.claims,
1272
+ before: beforePlan(hook.before),
1219
1273
  helpers: {
1220
1274
  schema: config.rls?.schema ?? "permdock",
1221
1275
  ...memberForPlan({
@@ -1253,38 +1307,121 @@ function hookClaims(parts) {
1253
1307
  }))
1254
1308
  ];
1255
1309
  }
1310
+ //#endregion
1311
+ //#region src/cli/supabase-hook-manifest.ts
1256
1312
  function helperList(parts, types) {
1257
1313
  const client = ["authenticated"];
1258
- return [{
1259
- name: "permdock_has",
1260
- args: "p_grant text",
1261
- returns: "boolean",
1262
- execute: client
1263
- }, ...parts.scopes.flatMap((scope) => {
1314
+ return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
1264
1315
  const returns = `setof ${types.get(scope.name) ?? "uuid"}`;
1265
1316
  return [
1266
- {
1267
- name: `permitted_${scope.name}_ids`,
1268
- args: "p_grant text",
1269
- returns,
1270
- execute: client
1271
- },
1272
- {
1273
- name: memberIdsHelper(scope.name),
1274
- args: "",
1275
- returns,
1276
- execute: client
1277
- },
1278
- ...parts.helpers.memberFor.includes(scope.name) ? [{
1279
- name: memberForHelper(scope.name),
1280
- args: "p_user uuid",
1281
- returns,
1282
- execute: parts.extra.length === 0 ? [] : ["supabase_auth_admin"]
1283
- }] : []
1317
+ helperEntry(permittedIdsHelper(scope.name), "p_grant text", returns, client),
1318
+ helperEntry(memberIdsHelper(scope.name), "", returns, client),
1319
+ ...parts.helpers.memberFor.includes(scope.name) ? [helperEntry(memberForHelper(scope.name), "p_user uuid", returns, parts.extra.length === 0 ? [] : ["supabase_auth_admin"])] : []
1284
1320
  ];
1285
1321
  })];
1286
1322
  }
1287
- function manifestOf(parts, out, config) {
1323
+ function helperEntry(name, args, returns, execute) {
1324
+ return {
1325
+ name,
1326
+ args,
1327
+ returns,
1328
+ execute
1329
+ };
1330
+ }
1331
+ /**
1332
+ * The helpers `rls generate` writes beyond the ones the hook's claims feed:
1333
+ * the `_for` forms in `database` mode, and the assignment checks when a role
1334
+ * declares `assigns` (known only with the policy).
1335
+ */
1336
+ function trustedHelpers(parts, config, types, tenantType, policy) {
1337
+ const database = resolveAuthorize(config) === "database";
1338
+ const custom = database && config.rls?.customRoles === true;
1339
+ const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
1340
+ const client = ["authenticated"];
1341
+ const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
1342
+ const forUser = database ? [
1343
+ helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
1344
+ ...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
1345
+ ...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
1346
+ ] : [];
1347
+ const keyed = [
1348
+ helperEntry(USER_ID_HELPER, "", "uuid", client),
1349
+ ...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
1350
+ ...forUser
1351
+ ];
1352
+ if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
1353
+ const customArgs = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
1354
+ const anyArgs = `p_role text, p_tenant ${tenantType}, p_scope text, p_scope_id text`;
1355
+ return [
1356
+ ...keyed,
1357
+ helperEntry(OWNERSHIP.canAssign, "p_role text, p_scope_id text", "boolean", client),
1358
+ ...database ? [helperEntry(OWNERSHIP.canAssignFor, "p_user uuid, p_role text, p_scope_id text", "boolean", [])] : [],
1359
+ ...custom ? [helperEntry(ASSIGNMENTS.custom, customArgs, "boolean", client)] : [],
1360
+ ...custom && everyFor ? [helperEntry(ASSIGNMENTS.customFor, `p_user uuid, ${customArgs}`, "boolean", [])] : [],
1361
+ helperEntry(OWNERSHIP.canAssignAny, anyArgs, "boolean", client),
1362
+ ...database && (!custom || everyFor) ? [helperEntry(OWNERSHIP.canAssignAnyFor, `p_user uuid, ${anyArgs}`, "boolean", [])] : []
1363
+ ];
1364
+ }
1365
+ function activeRowManifest(row) {
1366
+ const keep = "keep" in row ? keptKeys(row) : [];
1367
+ return {
1368
+ table: qualifiedTable(row.table),
1369
+ id: row.id,
1370
+ ...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
1371
+ ...row.status === void 0 ? {} : { status: row.status },
1372
+ ...row.active === void 0 ? {} : { active: row.active },
1373
+ ...keep.length === 0 ? {} : { keep }
1374
+ };
1375
+ }
1376
+ /** The `rls` settings a package writing SQL next to the helpers reads: custom roles, global roles, suspension and assignment triggers. */
1377
+ function rlsSettings(parts, config) {
1378
+ const rls = config.rls;
1379
+ const suspension = checkSuspension(rls?.suspension, parts.scopes);
1380
+ const scopes = Object.entries(suspension?.scopes ?? {});
1381
+ const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
1382
+ return {
1383
+ customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
1384
+ ...parts.roles === void 0 ? {} : { roles: {
1385
+ table: qualifiedTable(parts.roles.table),
1386
+ user: { column: parts.roles.user },
1387
+ role: parts.roles.role
1388
+ } },
1389
+ ...suspension === void 0 ? {} : { suspension: {
1390
+ ...suspension.users === void 0 ? {} : { users: activeRowManifest(suspension.users) },
1391
+ ...scopes.length === 0 ? {} : { scopes: Object.fromEntries(scopes.map(([name, row]) => [name, activeRowManifest(row)])) },
1392
+ ...suspension.memberships === void 0 ? {} : { memberships: { keep: keptKeys(suspension.memberships) } }
1393
+ } },
1394
+ ...assigned === void 0 ? {} : { assignments: { tables: assigned } },
1395
+ ...rls?.apiKeys === void 0 ? {} : { apiKeys: apiKeyFields(rls.apiKeys) }
1396
+ };
1397
+ }
1398
+ /** The `supabase/sdk` release `requires.capabilities` names features of. */
1399
+ const CAPABILITY_MATRIX = "capability-matrix-v1.12.0";
1400
+ function usesLiveSession(policy) {
1401
+ return [...policy?.grants ?? [], ...policy?.roles.flatMap((binding) => binding.grants) ?? []].some((grant) => hasConditionOp(grant.where, "liveSession") || hasConditionOp(grant.check, "liveSession"));
1402
+ }
1403
+ function requiredCapabilities(config, policy) {
1404
+ const ids = /* @__PURE__ */ new Set(["auth.session.get_claims"]);
1405
+ if (usesLiveSession(policy)) ids.add("auth.session.get_user");
1406
+ if (config.rls?.realtime !== void 0) ids.add("realtime.subscriptions.private_channel");
1407
+ for (const bucket of Object.values(config.rls?.storage?.buckets ?? {})) {
1408
+ if (bucket.read !== void 0) {
1409
+ ids.add("storage.file_buckets.download");
1410
+ ids.add("storage.file_buckets.list_files");
1411
+ }
1412
+ if (bucket.write !== void 0) {
1413
+ ids.add("storage.file_buckets.upload");
1414
+ ids.add("storage.file_buckets.update_file");
1415
+ ids.add("storage.file_buckets.move");
1416
+ }
1417
+ if (bucket.delete !== void 0) ids.add("storage.file_buckets.remove");
1418
+ }
1419
+ return {
1420
+ matrix: CAPABILITY_MATRIX,
1421
+ capabilities: [...ids].toSorted()
1422
+ };
1423
+ }
1424
+ function manifestOf(parts, out, config, policy) {
1288
1425
  const rls = config.rls;
1289
1426
  const ctx = {
1290
1427
  dialect: "supabase",
@@ -1303,11 +1440,12 @@ function manifestOf(parts, out, config) {
1303
1440
  hook: {
1304
1441
  schema: parts.schema,
1305
1442
  function: "custom_access_token_hook",
1306
- out
1443
+ out,
1444
+ ...parts.before.length === 0 ? {} : { before: parts.before }
1307
1445
  },
1308
1446
  helpers: {
1309
1447
  schema: parts.helpers.schema,
1310
- functions: helpers.map((helper) => helper.name)
1448
+ functions: helpers.map((entry) => entry.name)
1311
1449
  },
1312
1450
  tenantClaim: parts.tenantClaim,
1313
1451
  budget: {
@@ -1331,8 +1469,9 @@ function manifestOf(parts, out, config) {
1331
1469
  type: types.get(scope.name) ?? "uuid",
1332
1470
  ...scope.within === void 0 ? {} : { within: scope.within }
1333
1471
  })),
1334
- helpers,
1335
- memberships: parts.helpers.memberships
1472
+ helpers: [...helpers, ...trustedHelpers(parts, config, types, tenantTypeOf(ctx), policy)],
1473
+ memberships: parts.helpers.memberships,
1474
+ ...rlsSettings(parts, config)
1336
1475
  },
1337
1476
  decidingColumns: decidingColumns(config, parts.attrs === void 0 ? void 0 : {
1338
1477
  ...parts.attrs.table === void 0 ? {} : { table: parts.attrs.table },
@@ -1341,7 +1480,8 @@ function manifestOf(parts, out, config) {
1341
1480
  markers: {
1342
1481
  hook: "v1",
1343
1482
  grants: "v1"
1344
- }
1483
+ },
1484
+ requires: requiredCapabilities(config, policy)
1345
1485
  };
1346
1486
  }
1347
1487
  /** The first line of the generated hook: fields `--check` compares before the full text. */
@@ -1351,17 +1491,155 @@ function hookMarker(manifest) {
1351
1491
  function defaultOut(config) {
1352
1492
  return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
1353
1493
  }
1494
+ //#endregion
1495
+ //#region src/cli/supabase-setup.ts
1496
+ const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
1497
+ function escape(text) {
1498
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
1499
+ }
1500
+ /** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
1501
+ function helpersDefined(sql, schema) {
1502
+ const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
1503
+ const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
1504
+ const names = /* @__PURE__ */ new Set();
1505
+ for (const match of sql.matchAll(create)) {
1506
+ const name = (match[1] ?? "").toLowerCase();
1507
+ if (HELPER_NAME.test(name)) names.add(name);
1508
+ }
1509
+ return names;
1510
+ }
1511
+ /**
1512
+ * SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
1513
+ * its `helpers` part when it has `{part}`), the SQL files next to the hook,
1514
+ * and the migration folders, not the hook itself.
1515
+ */
1516
+ function helperSources(cwd, config, hookOut) {
1517
+ const hook = resolve(cwd, hookOut);
1518
+ const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
1519
+ const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
1520
+ if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
1521
+ const beside = dirname(hook);
1522
+ if (existsSync(beside)) {
1523
+ for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
1524
+ }
1525
+ files.delete(hook);
1526
+ return [...files];
1527
+ }
1528
+ function missingHelpersInFiles(cwd, config, manifest) {
1529
+ const found = /* @__PURE__ */ new Set();
1530
+ for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
1531
+ return manifest.helpers.functions.filter((name) => !found.has(name));
1532
+ }
1533
+ async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
1534
+ const client = await connect(db);
1535
+ try {
1536
+ const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
1537
+ where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
1538
+ const found = new Set(result.rows.map((row) => row["proname"]));
1539
+ return manifest.helpers.functions.filter((name) => !found.has(name));
1540
+ } finally {
1541
+ await client.end();
1542
+ }
1543
+ }
1544
+ function missingHelpersMessage(manifest, missing) {
1545
+ return `PD039 schema ${manifest.helpers.schema} has no ${missing.join(", ")}: the hook's claims are read by these helpers; run permdock rls generate and apply its migration`;
1546
+ }
1547
+ /** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
1548
+ function oversizedClaims(samples, manifest) {
1549
+ const encoder = new TextEncoder();
1550
+ const oversized = [];
1551
+ for (const claim of manifest.claims) {
1552
+ if (claim.source === "permdock") continue;
1553
+ let bytes = 0;
1554
+ for (const sample of samples) {
1555
+ if (sample === null || typeof sample !== "object") continue;
1556
+ const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
1557
+ if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
1558
+ }
1559
+ if (bytes > manifest.budget.bytes) oversized.push({
1560
+ name: claim.name,
1561
+ bytes
1562
+ });
1563
+ }
1564
+ return oversized;
1565
+ }
1566
+ /** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
1567
+ function droppedMemberships(samples) {
1568
+ const out = [];
1569
+ for (const [index, sample] of samples.entries()) {
1570
+ if (sample === null || typeof sample !== "object") continue;
1571
+ const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
1572
+ const { dropped } = readMemberships(claim);
1573
+ if (dropped.length > 0) out.push({
1574
+ sample: index,
1575
+ entries: dropped
1576
+ });
1577
+ }
1578
+ return out;
1579
+ }
1580
+ function pd039(input) {
1581
+ const findings = [];
1582
+ const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
1583
+ if (missing.length > 0) findings.push({
1584
+ code: "PD039",
1585
+ severity: "warning",
1586
+ message: missingHelpersMessage(input.manifest, missing).slice(6),
1587
+ fix: "run permdock rls generate, or set rls.out to the file that holds the helpers (with {part} for --split output) or write them next to the hook"
1588
+ });
1589
+ const fixture = input.config.doctor?.claims;
1590
+ if (fixture !== void 0) {
1591
+ const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
1592
+ const samples = Array.isArray(parsed) ? parsed : [];
1593
+ for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
1594
+ code: "PD039",
1595
+ severity: "warning",
1596
+ message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
1597
+ fix: "return less from the claim function, or read the data per request instead of from the token"
1598
+ });
1599
+ for (const { sample, entries } of droppedMemberships(samples)) findings.push({
1600
+ code: "PD039",
1601
+ severity: "warning",
1602
+ message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
1603
+ fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
1604
+ });
1605
+ }
1606
+ return findings;
1607
+ }
1608
+ //#endregion
1609
+ //#region src/cli/supabase-hook.ts
1610
+ const SUPABASE_HELP = `permdock supabase hook generate | inspect
1611
+
1612
+ hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
1613
+ [--active-from app_metadata.active_<scope>|<table>.<column>]
1614
+ [--budget 1024] [--schema public] [--grants-out <file>|-]
1615
+ inspect [--json] [--out [permdock.manifest.json]] [--check]
1616
+
1617
+ Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
1618
+ passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
1619
+ needs (the ones db diff drops in --grants-out, for declarative schemas), the permdock_authz_version table
1620
+ and its triggers. Never grants anything to
1621
+ service_role. inspect prints the manifest: helper schema, names and signatures, tenant claim,
1622
+ budget, the claims the hook writes, the membership sources and the columns that decide them.
1623
+ --out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
1624
+ file differs.
1625
+ `;
1626
+ const MANIFEST_FILE = "permdock.manifest.json";
1354
1627
  /** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
1355
1628
  function hookOut(cwd, config, schema) {
1356
1629
  const pgDelta = supabaseConfig(cwd).pgDelta;
1357
1630
  return config.supabase?.hook?.out !== void 0 || pgDelta === void 0 ? defaultOut(config) : pgDeltaPath(pgDelta.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
1358
1631
  }
1359
- function supabaseHookManifest(scopes, config, overrides = {}) {
1360
- return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config);
1632
+ /**
1633
+ * The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
1634
+ * lists the assignment checks `rls generate` writes for roles that declare
1635
+ * `assigns`.
1636
+ */
1637
+ function supabaseHookManifest(scopes, config, overrides = {}, policy) {
1638
+ return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
1361
1639
  }
1362
1640
  /**
1363
- * The hook migration. With `grantsOut`, the `supabase_auth_admin` grants are
1364
- * left out of `sql` and returned in `grants` for that file.
1641
+ * The hook migration. With `grantsOut`, the statements `supabase db diff`
1642
+ * drops are left out of `sql` and returned in `grants` for that file.
1365
1643
  */
1366
1644
  function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
1367
1645
  const parts = hookParts(scopes, config, overrides);
@@ -1371,18 +1649,23 @@ function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
1371
1649
  `${hookMarker(manifest)}
1372
1650
  -- custom_access_token_hook(jsonb): memberships go active ${parts.root} first and stop at the budget
1373
1651
  -- supabase/config.toml:
1374
- ${toml}${grantsOut === void 0 ? "" : `\n-- the supabase_auth_admin grants are in ${grantsOut}`}`,
1652
+ ${toml}${grantsOut === void 0 ? "" : `\n-- what supabase db diff drops from this part is in ${grantsOut}`}`,
1375
1653
  attrsGuardSql(parts.attrs),
1376
1654
  hookSql(parts),
1377
1655
  versionSql(parts),
1378
- grantsOut === void 0 ? grantsSql(parts) : "",
1656
+ subjectForSql(parts, config),
1657
+ authzVersionForSql(parts),
1658
+ membersOfSql(parts),
1659
+ grantsSql(parts),
1379
1660
  managedSql(parts)
1380
1661
  ].filter((chunk) => chunk !== "").join("\n\n");
1662
+ const { kept, moved } = splitUndiffed(`${sql}\n`);
1381
1663
  const grants = `${GRANTS_MARKER} schema=${parts.schema}
1382
- ${grantsSql(parts)}
1664
+ -- the privileges and view options supabase db diff drops from the hook part
1665
+ ${moved.join("\n")}
1383
1666
  `;
1384
1667
  return {
1385
- sql: `${sql}\n`,
1668
+ sql: grantsOut === void 0 ? `${sql}\n` : kept,
1386
1669
  grants,
1387
1670
  manifest
1388
1671
  };
@@ -1447,9 +1730,11 @@ function manifestFile(cwd, rel, manifest, check) {
1447
1730
  };
1448
1731
  }
1449
1732
  async function loadScopes(cwd, config) {
1733
+ return scopeList((await loadHookPolicy(cwd, config)).scopes);
1734
+ }
1735
+ async function loadHookPolicy(cwd, config) {
1450
1736
  if (config.policy === void 0) throw new Error("PermDock CLI: supabase hook generate needs policy in permdock.config.ts");
1451
- const policy = asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
1452
- return scopeList(policy.scopes);
1737
+ return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
1453
1738
  }
1454
1739
  async function runSupabase(input) {
1455
1740
  const [area, action] = input.rest;
@@ -1459,10 +1744,11 @@ async function runSupabase(input) {
1459
1744
  schema: input.schema
1460
1745
  }).filter(([, value]) => value !== void 0));
1461
1746
  if (area === "inspect" && action === void 0) {
1462
- const manifest = supabaseHookManifest(await loadScopes(input.cwd, input.config), input.config, {
1747
+ const policy = await loadHookPolicy(input.cwd, input.config);
1748
+ const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
1463
1749
  ...overrides,
1464
1750
  out: hookOut(input.cwd, input.config, input.schema)
1465
- });
1751
+ }, policy);
1466
1752
  if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
1467
1753
  return {
1468
1754
  code: 0,
@@ -1525,4 +1811,4 @@ async function runSupabase(input) {
1525
1811
  };
1526
1812
  }
1527
1813
  //#endregion
1528
- export { tableKey as A, pd009 as C, pd040 as D, pd028 as E, group as O, pd006 as S, pd022 as T, resolveAuthorize as _, loadScopes as a, MIGRATION_DIRS as b, supabaseHookSql as c, parseSplit as d, partPath as f, rbacScaffold as g, parseRbacAuthorize as h, hookOut as i, sqlStatements as k, pd039 as l, writeSqlFiles as m, attrsPlan as n, runSupabase as o, pgDeltaPath as p, grantsLabel as r, supabaseHookManifest as s, activeFromSql as t, driftOf as u, pd045 as v, pd012 as w, pd005 as x, supabaseConfig as y };
1814
+ export { seedsMigration as _, supabaseHookManifest as a, attrsPlan as c, resolveAuthorize as d, driftOf as f, pgDeltaPath as g, partPath as h, runSupabase as i, parseRbacAuthorize as l, parseSplit as m, hookOut as n, supabaseHookSql as o, isSeedsDirectory as p, loadScopes as r, pd039 as s, grantsLabel as t, rbacScaffold as u, writeSqlFiles as v };