permdock 0.1.0 → 0.3.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 (350) 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-CKwwqFWz.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 +223 -0
  18. package/dist/better-supabase/index.js +648 -0
  19. package/dist/bind-C_ob3LKa.d.ts +25 -0
  20. package/dist/bind-CuzGytRf.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-BDG_nkxt.d.ts → capability-2kYTMWDv.d.ts} +2 -2
  25. package/dist/{capability-B68OFnZx.js → capability-4qPdO9gk.js} +7 -5
  26. package/dist/catalog/index.d.ts +1 -1
  27. package/dist/catalog/index.js +1 -1
  28. package/dist/catalog-CRW5Mx_B.js +2 -0
  29. package/dist/{catalog-H-vFGwWK.js → catalog-DWF5cYqB.js} +4 -4
  30. package/dist/{catalog-B0E6bggc.js → catalog-dFZdWCGN.js} +2 -2
  31. package/dist/{catalog-doc-BSo_Q_-6.js → catalog-doc-DdGm82XT.js} +20 -32
  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-CB_ELYuX.js} +2 -2
  44. package/dist/{cloud-Cuxr6Czw.js → cloud-DWLXO2af.js} +7 -6
  45. package/dist/collect-DClJsheJ.js +83 -0
  46. package/dist/{collect-O-tOtbnk.js → collect-DSTE3ml9.js} +194 -42
  47. package/dist/collect-DSWOk19R.js +2 -0
  48. package/dist/{commands-BMh_Etm2.js → commands-DSxCqG9A.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-BkRyBDBw.d.ts +94 -0
  53. package/dist/config-BJiM-xAm.js +157 -0
  54. package/dist/config-Dnszbv-6.js +36 -0
  55. package/dist/config-report-DMvWKUxL.js +44 -0
  56. package/dist/{connection-JvqwHODC.js → connection-CU8MQxRi.js} +1 -1
  57. package/dist/{context-CKEAabd7.js → context-Bcy85rGi.js} +36 -27
  58. package/dist/convex/index.d.ts +9 -4
  59. package/dist/convex/index.js +14 -4
  60. package/dist/create-B1d519XK.d.ts +245 -0
  61. package/dist/create-CMwIl-w2.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-DMYiCjJV.d.ts} +9 -10
  66. package/dist/{diff-x5Il8LVa.js → diff-2VaeChzX.js} +44 -11
  67. package/dist/{diff-BUzf5Poi.js → diff-DBIhz_f2.js} +2 -2
  68. package/dist/{doctor-Bqo6FwUa.js → doctor-BwCAMLx_.js} +2 -2
  69. package/dist/doctor-collect-CHk6XWOA.js +665 -0
  70. package/dist/doctor-igIiM_Hl.js +1194 -0
  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-DPdqI50v.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-CTLijvdm.js} +66 -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-0nNqHspG.d.ts +6 -0
  105. package/dist/{index-xIxJLwOQ.d.ts → index-BNc7Wer_.d.ts} +75 -7
  106. package/dist/{index-CltcQ4Bf.d.ts → index-C7Tw7MaW.d.ts} +68 -5
  107. package/dist/{index-LF_hS-yn.d.ts → index-C8z4RjQV.d.ts} +1 -1
  108. package/dist/index.d.ts +14 -9
  109. package/dist/index.js +22 -17
  110. package/dist/instance-options-Dhhcnm3y.js +22 -0
  111. package/dist/instance-options-jARdwfPN.d.ts +11 -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-_RRHmnMY.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-DcqM8nU7.js → openapi-BqRBvAng.js} +237 -5
  144. package/dist/{openapi-DWD0Uc_4.js → openapi-CqqEbHsL.js} +5 -5
  145. package/dist/{openapi-import-BWRUNNeW.js → openapi-import-cmwetbzw.js} +1 -1
  146. package/dist/{openapi-CwePaF7C.js → openapi-l0xYHkcv.js} +3 -3
  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-BrMozJCo.js +36 -0
  153. package/dist/parse-local-manifest-JLnaBH0S.d.ts +10 -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-CbgvLnuo.js} +672 -149
  157. package/dist/{permissions-B8vCg_bI.d.ts → permissions-CDgvKG5L.d.ts} +2 -2
  158. package/dist/{permissions-DC7GROPA.js → permissions-DVoF6dMt.js} +77 -15
  159. package/dist/{permissions-B4apnahQ.d.ts → permissions-DttnqqxC.d.ts} +34 -5
  160. package/dist/{permissions-CofRqesP.js → permissions-RJrovw98.js} +3 -3
  161. package/dist/{pins-CqE1jZuF.js → pins-8ILTftC9.js} +2 -2
  162. package/dist/{policy-Cq2fB8Q5.d.ts → policy-D2W7uv19.d.ts} +2 -2
  163. package/dist/{policy-CInmNGkM.d.ts → policy-IB6yxpC3.d.ts} +351 -42
  164. package/dist/{policy-ANp36PnI.js → policy-xyWg7QdR.js} +173 -22
  165. package/dist/powersync-BylawD_U.js +540 -0
  166. package/dist/powersync-DliZK_-y.js +62 -0
  167. package/dist/powersync-e_AZrKbg.js +2 -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-DX075l9V.d.ts +7 -0
  172. package/dist/problem-details-_AMOYz-w.js +144 -0
  173. package/dist/{protected-BuPAycJT.d.ts → protected-BdnIEFkY.d.ts} +5 -4
  174. package/dist/protected-utU-SLuy.js +100 -0
  175. package/dist/protected-xAr24FEC.d.ts +40 -0
  176. package/dist/provider-CSQd7m92.js +52 -0
  177. package/dist/{provider-wTAUda2v.d.ts → provider-DZY281jO.d.ts} +1 -1
  178. package/dist/provider-store-Cfbvo4DE.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-CYyiLiwO.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-BOFU3chG.js → rls-B4ur8nKQ.js} +8 -3
  192. package/dist/rls-DQtdgTHB.js +2521 -0
  193. package/dist/rls-fields-74oZPXWL.js +3582 -0
  194. package/dist/{rls-graph-DxQ39PJj.js → rls-graph-CHwx0V8q.js} +100 -36
  195. package/dist/{rls-import-CXV6LBlS.js → rls-import-BTFVk3jH.js} +7 -6
  196. package/dist/{rls-migrate-DrFPbf8p.js → rls-migrate-CN4TlJTO.js} +6 -5
  197. package/dist/rls-ownership-RuFqaqGz.js +843 -0
  198. package/dist/rls-shared-Cewd_-zI.js +495 -0
  199. package/dist/rls-verify-CVQTHTh5.js +746 -0
  200. package/dist/{row-check-BajTgQZ-.d.ts → row-check-Dt35czLy.d.ts} +12 -15
  201. package/dist/row-conditions-h2HythXQ.js +27 -0
  202. package/dist/rpc-DL9Sqvb1.js +89 -0
  203. package/dist/{run-CJ6UvS3a.js → run-DKDbM4Du.js} +31 -12
  204. package/dist/runtime-CB8gQFvS.js +11 -0
  205. package/dist/{saas-BRrZzJkM.js → saas-DhpJjnBA.js} +17 -7
  206. package/dist/{schema-P8yoma__.js → schema-D3MnDMxa.js} +8 -4
  207. package/dist/scim/index.d.ts +1 -1
  208. package/dist/scim/index.js +1 -1
  209. package/dist/{scim-9NFa0XVZ.js → scim-BFGBBAqR.js} +1 -1
  210. package/dist/{scopes-cKVp_WTw.js → scopes-CVdPrWN-.js} +35 -8
  211. package/dist/server/index.d.ts +22 -11
  212. package/dist/server/index.js +11 -8
  213. package/dist/{credentials-BgRX3WEg.js → server-DlZ28Oz5.js} +1 -1
  214. package/dist/shared-DRu8VFVM.js +123 -0
  215. package/dist/{sink-Cfr9-7YC.d.ts → sink-B1fZzIMf.d.ts} +1 -1
  216. package/dist/skills-Biq5uup3.js +192 -0
  217. package/dist/{skills-C_lpV4Jx.js → skills-Bx-U0irG.js} +10 -4
  218. package/dist/{snapshot-DE8dE_hz.js → snapshot-Enxd0n-s.js} +138 -214
  219. package/dist/snapshot-cache-B-LxUahj.d.ts +45 -0
  220. package/dist/snapshot-cache-D6jLelln.js +44 -0
  221. package/dist/solid/index.d.ts +39 -9
  222. package/dist/solid/index.js +76 -85
  223. package/dist/sources-4p0-4xQm.js +545 -0
  224. package/dist/sources-BdOYLw9j.d.ts +192 -0
  225. package/dist/{sql-Bll1d7y0.js → sql-xdg5f4Fi.js} +6 -1
  226. package/dist/{src-CTNT-PBW.js → src-BDwEZWrZ.js} +149 -11
  227. package/dist/ssf/index.d.ts +3 -2
  228. package/dist/ssf/index.js +3 -29
  229. package/dist/store-DMAWwAJV.js +371 -0
  230. package/dist/{store-Bq90395i.d.ts → store-jOBYpiXC.d.ts} +13 -4
  231. package/dist/store-options-ByhZq1jV.js +716 -0
  232. package/dist/{stores-CkG2HrSg.js → stores-BT2pSCub.js} +10 -63
  233. package/dist/{stores-CvOnu_2T.d.ts → stores-C0IMQrGI.d.ts} +20 -9
  234. package/dist/{stream-BhMfZOwr.d.ts → stream-D1RXubw2.d.ts} +2 -2
  235. package/dist/{stream-Q_xlB6Ko.js → stream-DDpvynSB.js} +16 -2
  236. package/dist/subject-settings-BwBmCdTR.js +128 -0
  237. package/dist/supabase/index.d.ts +133 -6
  238. package/dist/supabase/index.js +4 -3
  239. package/dist/supabase/middleware.d.ts +32 -27
  240. package/dist/supabase/middleware.js +56 -22
  241. package/dist/{supabase-D8UlBA7Z.js → supabase-Boqb9a_Y.js} +3 -3
  242. package/dist/{supabase-DTxGtYdi.js → supabase-C0qESOa-.js} +347 -367
  243. package/dist/supabase-config-BpPXt3x1.js +472 -0
  244. package/dist/supabase-hook-6sYLXg_u.js +2 -0
  245. package/dist/{supabase-hook-D_OqGb_V.js → supabase-hook-CSLQ4zJ3.js} +1037 -705
  246. package/dist/supautils-Cn4R4cJ4.js +91 -0
  247. package/dist/svelte/PermissionBoundary.svelte +37 -0
  248. package/dist/svelte/Protected.svelte +8 -2
  249. package/dist/svelte/index.d.ts +9 -6
  250. package/dist/svelte/index.js +61 -7
  251. package/dist/svelte/runtime.d.ts +4 -3
  252. package/dist/svelte/runtime.js +4 -3
  253. package/dist/svelte/source.d.ts +9 -6
  254. package/dist/svelte/source.js +7 -4
  255. package/dist/terminal/index.d.ts +6 -14
  256. package/dist/terminal/index.js +44 -32
  257. package/dist/testing/index.d.ts +105 -16
  258. package/dist/testing/index.js +504 -40
  259. package/dist/testing/saas/index.d.ts +4 -4
  260. package/dist/testing/saas/index.js +2 -2
  261. package/dist/testing/saas/permissions.d.ts +1 -1
  262. package/dist/testing/saas/permissions.js +1 -1
  263. package/dist/token-CIWxJOCh.js +50 -0
  264. package/dist/trpc/index.d.ts +7 -26
  265. package/dist/trpc/index.js +17 -100
  266. package/dist/types-BnIEz1sU.d.ts +48 -0
  267. package/dist/{types-CxquUByI.d.ts → types-Bvc4oWT6.d.ts} +1 -1
  268. package/dist/{sources-DOpW5Ila.d.ts → types-C97IskBw.d.ts} +86 -127
  269. package/dist/types-CKNSyIf_.d.ts +52 -0
  270. package/dist/types-Cq_H8N_X.d.ts +197 -0
  271. package/dist/{types-D6Z6-rKJ.d.ts → types-DbjRN55U.d.ts} +273 -22
  272. package/dist/{types-BBK-exvJ.d.ts → types-Dnemwd77.d.ts} +7 -1
  273. package/dist/{types-BvBk1wgn.d.ts → types-DxiCGmmd.d.ts} +5 -3
  274. package/dist/{types-B9dmiiab.d.ts → types-QCuYJh4q.d.ts} +1 -1
  275. package/dist/{types-B8w5PFtS.d.ts → types-aqOHTHcp.d.ts} +17 -1
  276. package/dist/unplugin/index.d.ts +1 -1
  277. package/dist/unplugin/index.js +10 -9
  278. package/dist/usage-DorAJiU_.js +2 -0
  279. package/dist/{usage-1ozMubLY.js → usage-GTixvDDU.js} +2 -2
  280. package/dist/{usage-CUnK6Ou9.js → usage-ItS7nLcl.js} +225 -62
  281. package/dist/validation-error-Ba264OaJ.d.ts +26 -0
  282. package/dist/validation-error-CnjZ4t92.js +33 -0
  283. package/dist/{validity-D0uRmJKJ.js → validity-uOp8UpYn.js} +57 -36
  284. package/dist/{verifier-B90agnhl.js → verifier-BxBo6I44.js} +10 -5
  285. package/dist/version-BvacVTZR.js +17 -0
  286. package/dist/views-Soe7bGSC.js +95 -0
  287. package/dist/{vocabulary-Dk0fs-Jk.d.ts → vocabulary-DA8a8zif.d.ts} +1 -1
  288. package/dist/vue/index.d.ts +35 -13
  289. package/dist/vue/index.js +86 -93
  290. package/dist/watch-BwxLTYMZ.js +2 -0
  291. package/dist/watch-DVBJ9cBq.js +128 -0
  292. package/dist/webmcp/index.d.ts +2 -2
  293. package/dist/webmcp/index.js +7 -13
  294. package/package.json +62 -36
  295. package/schemas/approval-request-v1.json +175 -0
  296. package/schemas/catalog-v1.json +29 -1
  297. package/schemas/supabase-claims-v1.json +6 -1
  298. package/schemas/supabase-manifest-v1.json +150 -6
  299. package/skills/permdock/SKILL.md +7 -7
  300. package/skills/permdock-agents/SKILL.md +5 -5
  301. package/skills/permdock-agents/references/runtimes.md +12 -10
  302. package/skills/permdock-approvals/SKILL.md +4 -4
  303. package/skills/permdock-approvals/references/stores-and-resume.md +15 -4
  304. package/skills/permdock-audit/SKILL.md +3 -3
  305. package/skills/permdock-credentials/SKILL.md +4 -4
  306. package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
  307. package/skills/permdock-data/SKILL.md +4 -4
  308. package/skills/permdock-data/references/queries-and-rls.md +14 -6
  309. package/skills/permdock-data/references/relationships.md +5 -3
  310. package/skills/permdock-tenancy/SKILL.md +9 -6
  311. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +14 -8
  312. package/skills/permdock-wire/SKILL.md +4 -4
  313. package/skills/permdock-wire/references/adapters.md +42 -30
  314. package/skills/permdock-wire/references/policy-options.md +5 -3
  315. package/dist/approvers-aH0PJkmf.js +0 -20
  316. package/dist/catalog-idsHc0vo.js +0 -2
  317. package/dist/collect-B7_TIfmg.js +0 -49
  318. package/dist/collect-fRPAQIWb.js +0 -2
  319. package/dist/compile-CQbvyIBr.d.ts +0 -30
  320. package/dist/config-BPYmjTOp.js +0 -26
  321. package/dist/context-gtAwAILx.js +0 -6
  322. package/dist/create-BKF3dgFk.d.ts +0 -162
  323. package/dist/create-CSkhDU9T.js +0 -222
  324. package/dist/doctor-BGle8Gqi.js +0 -1314
  325. package/dist/helpers-DnSOrhB9.js +0 -682
  326. package/dist/index-fG6NSXlc.d.ts +0 -9
  327. package/dist/kernel-YOGCWNLf.js +0 -211
  328. package/dist/load-QOOm1z70.js +0 -2
  329. package/dist/manifest-DDQbC6K0.d.ts +0 -118
  330. package/dist/map-error-RJ3ZsUyA.js +0 -16
  331. package/dist/may-use-CnFEjVEp.js +0 -37
  332. package/dist/pg-4GJWDUxG.js +0 -953
  333. package/dist/plugin-4gr7IxFJ.js +0 -65
  334. package/dist/protected-B4K3BKoX.js +0 -138
  335. package/dist/protected-wY7nXhY9.d.ts +0 -23
  336. package/dist/provider-_wx46HpB.js +0 -48
  337. package/dist/rls-CJVja9Ww.js +0 -2119
  338. package/dist/rls-helpers-BxRuif0l.js +0 -1346
  339. package/dist/runtime-DP9ttFh1.js +0 -27
  340. package/dist/skills-Dx3QLnMY.js +0 -110
  341. package/dist/source-DoBBYJi2.js +0 -6
  342. package/dist/sources-DVHM3Mwq.js +0 -284
  343. package/dist/store-C6hFW6ta.js +0 -508
  344. package/dist/subject-settings-xt7V845g.js +0 -82
  345. package/dist/supabase-hook-DW-xP4hU.js +0 -2
  346. package/dist/types-DbWdEfcm.d.ts +0 -87
  347. package/dist/types-bva3i4Gf.d.ts +0 -98
  348. package/dist/types-i4BAOvdb.d.ts +0 -97
  349. package/dist/usage-PyEor6GA.js +0 -2
  350. package/dist/version-CnSh33Fj.js +0 -16
@@ -1,404 +1,253 @@
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-C0qESOa-.js";
8
9
  import "./budget-NbEtbJC_.js";
9
- import { t as AUTHZ_VERSION_TABLE } from "./sources-DVHM3Mwq.js";
10
+ import { c as hookApiNames, d as keptKeys, t as AUTHZ_VERSION_TABLE, u as roleManifest } from "./sources-4p0-4xQm.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-BpPXt3x1.js";
14
+ import { D as hasMemberFor, F as memberForHelper, G as quoteIdent, H as permittedForHelper, I as memberForSources, K as quoteLiteral, L as memberForTable, Q as scopeTypeOf, R as memberIdsHelper, U as permittedIdsHelper, W as qualifiedTable, at as tenantTypeOf, g as activeRowSql, p as USER_ID_HELPER, q as quoteTable, r as HELPERS, y as checkSuspension, z as memberRoleOf } from "./rls-shared-Cewd_-zI.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-74oZPXWL.js";
16
+ import { a as ownershipRules, i as guardedTables, n as OWNERSHIP, t as ASSIGNMENTS } from "./rls-ownership-RuFqaqGz.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;
42
+ const path = resolve(cwd, file.rel);
43
+ mkdirSync(dirname(path), { recursive: true });
44
+ writeFileSync(path, file.text);
45
+ wrote.push(file.rel);
92
46
  }
93
- push(start, text.length);
94
- return statements;
47
+ return {
48
+ wrote,
49
+ printed: printed.join("\n")
50
+ };
95
51
  }
96
- /** Capture group `index` of a match, or '' when an optional group took no part. */
97
- function group(match, index) {
98
- return match[index] ?? "";
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));
99
65
  }
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 [];
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);
121
69
  }
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
- }];
70
+ /**
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.
73
+ */
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;
146
82
  }
147
83
  }
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 [];
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();
171
90
  }
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;
91
+ /** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
92
+ function migrationVersion(now) {
93
+ return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
187
94
  }
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");
95
+ const VERSION_PREFIX = /^\d{14}(?=_)/u;
201
96
  /**
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.
97
+ * The version of a new migration that sorts after every migration in
98
+ * `names`: one second past the newest, so the same directory always gives the
99
+ * same name. Only a directory with no versioned migration falls back to `now`.
206
100
  */
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));
223
- }
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
- }));
101
+ function nextMigrationVersion(names, now) {
102
+ const times = names.flatMap((name) => {
103
+ const stamp = VERSION_PREFIX.exec(name)?.[0];
104
+ return stamp === void 0 ? [] : [Date.UTC(Number(stamp.slice(0, 4)), Number(stamp.slice(4, 6)) - 1, Number(stamp.slice(6, 8)), Number(stamp.slice(8, 10)), Number(stamp.slice(10, 12)), Number(stamp.slice(12, 14)))];
105
+ });
106
+ return times.length === 0 ? migrationVersion(now) : migrationVersion(new Date(Math.max(...times) + 1e3));
230
107
  }
231
- const AUTH_ROLE = /\bauth\.role\s*\(\s*\)/giu;
232
108
  /**
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.
109
+ * The seeds part in a migrations directory: the newest migration there that
110
+ * starts with \`marker\` when its text is \`text\` already, otherwise a new
111
+ * \`<version>_permdock_seeds.sql\` one second after the newest migration
112
+ * (\`nextMigrationVersion\`), so a regeneration of the same directory names it
113
+ * the same. \`current\` is false when a new one is due.
236
114
  */
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;
115
+ function seedsMigration(cwd, dir, marker, text, now) {
116
+ const base = dir.endsWith("/") ? dir.slice(0, -1) : dir;
117
+ const path = resolve(cwd, base);
118
+ const latest = existsSync(path) ? readdirSync(path).filter((name) => name.endsWith(".sql")).toSorted().toReversed().find((name) => readFileSync(resolve(path, name), "utf8").startsWith(marker)) : void 0;
119
+ if (latest !== void 0 && readFileSync(resolve(path, latest), "utf8") === text) return {
120
+ rel: `${base}/${latest}`,
121
+ current: true
122
+ };
123
+ return {
124
+ rel: `${base}/${nextMigrationVersion(existsSync(path) ? readdirSync(path) : [], now)}_permdock_seeds.sql`,
125
+ current: false
126
+ };
252
127
  }
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;
128
+ //#endregion
129
+ //#region src/cli/claims-schema.ts
271
130
  /**
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.
131
+ * The PermDock claims of `schemas/supabase-claims-v1.json` (its
132
+ * `permdockClaims` and `membership` definitions), self-contained so
133
+ * `supabase.hook.validate` can inline it into the hook for pg_jsonschema.
276
134
  */
277
- function clientWritable(cwd, config, target) {
278
- const writable = {
279
- insert: /* @__PURE__ */ new Set(),
280
- update: /* @__PURE__ */ new Set()
281
- };
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("*");
135
+ const PERMDOCK_CLAIMS_SCHEMA = {
136
+ $schema: "https://json-schema.org/draft/2020-12/schema",
137
+ $ref: "#/$defs/permdockClaims",
138
+ $defs: {
139
+ permdockClaims: {
140
+ type: "object",
141
+ properties: {
142
+ user_role: {
143
+ description: "Global roles: one name, a list, or null for none.",
144
+ anyOf: [
145
+ { type: "string" },
146
+ {
147
+ type: "array",
148
+ items: { type: "string" }
149
+ },
150
+ { type: "null" }
151
+ ]
152
+ },
153
+ roles: {
154
+ type: "array",
155
+ items: { type: "string" }
156
+ },
157
+ memberships: {
158
+ type: "array",
159
+ items: { $ref: "#/$defs/membership" }
160
+ },
161
+ memberships_truncated: {
162
+ description: "true when the size budget cut memberships or attrs.",
163
+ type: "boolean"
164
+ },
165
+ tenant_id: {
166
+ description: "The active tenant (the default rls.tenantClaim): the canonical text of one instance of the first scope.",
167
+ type: "string"
168
+ },
169
+ attrs: {
170
+ description: "Allow-listed server-owned attributes, read as principal.claims.attrs.<key>.",
171
+ type: "object"
172
+ },
173
+ authz_ver: {
174
+ description: "The principal's authorization version.",
175
+ type: "integer"
176
+ }
293
177
  }
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);
178
+ },
179
+ membership: {
180
+ type: "object",
181
+ required: ["roles"],
182
+ properties: {
183
+ scope: { type: "string" },
184
+ id: {
185
+ description: "Canonical text of the scope instance id (uuid::text for a uuid column); compared exactly, never lower-cased.",
186
+ type: "string"
187
+ },
188
+ within: {
189
+ description: "The id of every ancestor scope instance, by scope name.",
190
+ type: "object",
191
+ additionalProperties: { type: "string" }
192
+ },
193
+ on: {
194
+ type: "object",
195
+ required: ["resource", "id"],
196
+ properties: {
197
+ resource: { type: "string" },
198
+ id: { type: "string" }
199
+ }
200
+ },
201
+ tenant: { type: "string" },
202
+ team: { type: "string" },
203
+ roles: {
204
+ type: "array",
205
+ minItems: 1,
206
+ items: { type: "string" }
207
+ },
208
+ via: { type: "string" },
209
+ expiresAt: {
210
+ description: "Seconds since the epoch.",
211
+ type: "number"
212
+ },
213
+ grantedBy: { type: "string" },
214
+ reason: { type: "string" },
215
+ member: {
216
+ description: "The subgroup a membership source's group fills.",
217
+ type: "object",
218
+ required: ["group"],
219
+ properties: { group: {
220
+ type: "string",
221
+ minLength: 1
222
+ } }
223
+ },
224
+ managedBy: { const: "idp" },
225
+ entitlements: {
226
+ type: "array",
227
+ items: { type: "string" }
228
+ },
229
+ keep: {
230
+ description: "Set when the instance or an ancestor is suspended: the permission keys the membership still grants.",
231
+ type: "array",
232
+ items: {
233
+ type: "string",
234
+ minLength: 1
235
+ }
236
+ },
237
+ grants: {
238
+ description: "Custom roles in compact form, read by the helpers in jwt mode.",
239
+ type: "object"
240
+ }
241
+ },
242
+ anyOf: [
243
+ { required: ["scope", "id"] },
244
+ { required: ["tenant"] },
245
+ { required: ["team"] },
246
+ { required: ["on"] }
247
+ ]
300
248
  }
301
249
  }
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
- }
319
- /**
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.
323
- */
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, "") } } : {}
383
- }
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
- }
250
+ };
402
251
  //#endregion
403
252
  //#region src/cli/rls-rbac.ts
404
253
  /**
@@ -483,196 +332,8 @@ revoke all on table ${q("user_roles")} from authenticated, anon, public;
483
332
  warnings: []
484
333
  };
485
334
  }
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
335
  //#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";
336
+ //#region src/cli/supabase-hook-sql.ts
676
337
  const MANAGED_TRIGGER = "permdock_protect_managed";
677
338
  const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
678
339
  const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
@@ -767,7 +428,7 @@ function extraClaimsPlan(claims, tenantClaim) {
767
428
  continue;
768
429
  }
769
430
  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`);
431
+ errors.push(`supabase.hook.claims.${claim} must be a schema-qualified function name such as public.feature_claims`);
771
432
  continue;
772
433
  }
773
434
  planned.push({
@@ -780,6 +441,50 @@ function extraClaimsPlan(claims, tenantClaim) {
780
441
  errors
781
442
  };
782
443
  }
444
+ function beforePlan(before) {
445
+ const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
446
+ const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
447
+ 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(", ")})`);
448
+ return [...new Set(list)];
449
+ }
450
+ function beforeSql(before) {
451
+ return before.map((fn) => `
452
+ checked := ${quoteTable(fn)}(event);
453
+ if checked is null or jsonb_typeof(checked) is distinct from 'object' then
454
+ return jsonb_build_object('error', jsonb_build_object('http_code', 500, 'message', ${quoteLiteral(`${fn} returned no event`)}));
455
+ end if;
456
+ if checked ? 'error' then
457
+ return checked;
458
+ end if;
459
+ event := checked;`).join("").concat(before.length === 0 ? "" : "\n claims := event -> 'claims';");
460
+ }
461
+ /**
462
+ * `hook.validate`: the claims the hook writes, dropped together when they do
463
+ * not match `supabase-claims-v1.json`, so a bad claim denies instead of
464
+ * failing sign-in. `hook.claims` entries belong to other packages and stay.
465
+ */
466
+ function validateSql(parts) {
467
+ if (parts.hook.validate !== true) return "";
468
+ const keys = /* @__PURE__ */ new Set([
469
+ "user_role",
470
+ "roles",
471
+ "memberships",
472
+ "memberships_truncated",
473
+ "attrs",
474
+ "authz_ver",
475
+ parts.tenantClaim
476
+ ]);
477
+ return `
478
+ if not extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(PERMDOCK_CLAIMS_SCHEMA))}::json, claims) then
479
+ claims := claims${[...keys].map((key) => ` - ${quoteLiteral(key)}`).join("")};
480
+ end if;`;
481
+ }
482
+ function validateGrantsSql(parts) {
483
+ return parts.hook.validate === true ? `
484
+ create extension if not exists pg_jsonschema with schema extensions;
485
+ grant usage on schema extensions to supabase_auth_admin;
486
+ grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
487
+ }
783
488
  const VERSION_TRIGGER = "permdock_authz_version";
784
489
  const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
785
490
  function memberForPlan(input) {
@@ -806,23 +511,26 @@ function mappedMembership(mapped, scopes, name) {
806
511
  const column = scopeColumn(mapped, scopes, ancestor);
807
512
  if (column !== void 0) within[ancestor] = column;
808
513
  }
514
+ const role = memberRoleOf(mapped);
809
515
  const columns = [
810
516
  mapped.user,
811
517
  id,
812
- mapped.role,
518
+ ...role.columns,
813
519
  ...Object.values(within),
814
- ...mapped.via === void 0 ? [] : [mapped.via],
815
- ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt]
520
+ ...typeof mapped.via === "string" ? [mapped.via] : [],
521
+ ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
522
+ ...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
816
523
  ];
817
524
  return {
818
525
  table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
819
526
  user: { column: mapped.user },
820
527
  scope: { value: name },
821
528
  id: { column: id },
822
- role: { column: mapped.role },
529
+ role: roleManifest(role),
823
530
  ...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
824
- ...mapped.via === void 0 ? {} : { via: { column: mapped.via } },
531
+ ...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
825
532
  ...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
533
+ ...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
826
534
  columns: [...new Set(columns)]
827
535
  };
828
536
  }
@@ -893,7 +601,7 @@ function entriesSql(parts) {
893
601
  'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
894
602
  'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
895
603
  '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
604
+ 'managedBy', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
897
605
  )) as entry
898
606
  from (
899
607
  ${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
@@ -957,8 +665,8 @@ declare
957
665
  in_active boolean := false;
958
666
  budget integer := ${String(parts.budget)};
959
667
  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}
668
+ 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;"}
669
+ begin${beforeSql(parts.before)}${suspended}
962
670
  claims := claims - 'attrs'${dropExtra};
963
671
  ${roles}
964
672
  claims := jsonb_set(claims, '{roles}', held);
@@ -990,85 +698,412 @@ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
990
698
  end if;
991
699
  if in_active then
992
700
  claims := jsonb_set(claims, ${quoteLiteral(`{${parts.tenantClaim}}`)}, to_jsonb(active));
993
- end if;${extra}${version}
701
+ end if;${extra}${version}${validateSql(parts)}
994
702
  return jsonb_set(event, '{claims}', claims);
995
703
  end;
996
704
  $$;`;
997
705
  }
706
+ /** Everything the hook grants `supabase_auth_admin`, and the execute revoke. */
707
+ function grantsSql(parts) {
708
+ const schema = quoteIdent(parts.schema);
709
+ const fn = `${schema}.custom_access_token_hook`;
710
+ return [
711
+ `-- supabase_auth_admin: the grants and read policies the hook needs
712
+ grant usage on schema ${schema} to supabase_auth_admin;
713
+ grant execute on function ${fn}(jsonb) to supabase_auth_admin;
714
+ revoke execute on function ${fn}(jsonb) from authenticated, anon, public;${extraGrantsSql(parts.extra)}${beforeGrantsSql(parts.before, parts.extra)}${memberForGrantsSql(parts)}${validateGrantsSql(parts)}`,
715
+ readsSql(parts),
716
+ parts.version ? authAdminRead(`${parts.schema}.${AUTHZ_VERSION_TABLE}`, "version") : ""
717
+ ].filter((chunk) => chunk !== "").join("\n");
718
+ }
719
+ function extraGrantsSql(extra) {
720
+ 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("");
721
+ }
722
+ function beforeGrantsSql(before, extra) {
723
+ const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
724
+ 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("");
725
+ }
726
+ /**
727
+ * Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
728
+ * functions, which run as `supabase_auth_admin` and may call them. Only with
729
+ * `hook.claims`: the grant needs the helpers migration applied first, and a
730
+ * hook without extra claims does not.
731
+ */
732
+ function memberForGrantsSql(parts) {
733
+ const { schema, memberFor } = parts.helpers;
734
+ if (memberFor.length === 0 || parts.extra.length === 0) return "";
735
+ 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("");
736
+ }
737
+ function readsSql(parts) {
738
+ const reads = /* @__PURE__ */ new Map();
739
+ for (const source of parts.sources) reads.set(source.sql.table, "memberships");
740
+ for (const source of parts.sources) {
741
+ for (const name of source.sql.reads) reads.set(name, reads.get(name) ?? "status");
742
+ for (const through of source.sql.throughs) reads.set(through.table, reads.get(through.table) ?? "role_keys");
743
+ const users = source.sql.userThrough;
744
+ if (users !== void 0) reads.set(users.table, reads.get(users.table) ?? "member_users");
745
+ }
746
+ if (parts.roles !== void 0) {
747
+ const { table: rolesTable, through } = parts.roles;
748
+ reads.set(rolesTable, reads.get(rolesTable) ?? "roles");
749
+ if (through !== void 0) reads.set(through.table, reads.get(through.table) ?? "role_keys");
750
+ }
751
+ if (parts.attrs?.table !== void 0 && parts.attrs.columns.length > 0) reads.set(parts.attrs.table, reads.get(parts.attrs.table) ?? "attrs");
752
+ if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
753
+ 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");
754
+ }
755
+ function versionSql(parts) {
756
+ if (!parts.version) return "";
757
+ const schema = quoteIdent(parts.schema);
758
+ const versionTable = `${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)}`;
759
+ const bump = `${schema}.permdock_bump_authz_version`;
760
+ const bumpFor = `${schema}.${AUTHZ_VERSION_BUMP}`;
761
+ const tables = [...new Map(parts.sources.filter((source) => source.sql.userThrough === void 0).map((source) => [source.sql.table, source.sql.user]))];
762
+ if (parts.roles !== void 0) tables.push([parts.roles.table, parts.roles.user]);
763
+ return `-- the authorization version: bumped on every membership change, written to authz_ver
764
+ create table if not exists ${versionTable} (
765
+ user_id uuid primary key references auth.users on delete cascade,
766
+ version bigint not null default 0
767
+ );
768
+ alter table ${versionTable} enable row level security;
769
+ revoke all on table ${versionTable} from anon, authenticated, public;
770
+
771
+ -- bumps each listed user once, skipping users already deleted from auth.users; no client role may call it
772
+ create or replace function ${bumpFor}(p_users uuid[])
773
+ returns void
774
+ language sql
775
+ security definer
776
+ set search_path = ''
777
+ as $$
778
+ insert into ${versionTable} as v (user_id, version)
779
+ select distinct u, 1 from unnest(p_users) u
780
+ where u is not null and exists (select 1 from auth.users au where au.id = u)
781
+ on conflict (user_id) do update set version = v.version + 1
782
+ $$;
783
+ revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
784
+
785
+ create or replace function ${bump}()
786
+ returns trigger
787
+ language plpgsql
788
+ security definer
789
+ set search_path = ''
790
+ as $$
791
+ declare
792
+ column_name text := tg_argv[0];
793
+ begin
794
+ perform ${bumpFor}(array[
795
+ case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
796
+ case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
797
+ ]::uuid[]);
798
+ return null;
799
+ end;
800
+ $$;
801
+ revoke execute on function ${bump}() from public, anon, authenticated;
802
+ ${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(name)};
803
+ create trigger ${quoteIdent(VERSION_TRIGGER)}
804
+ after insert or update or delete on ${table(name)}
805
+ for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
806
+ }
807
+ const SUBJECT_FOR = "subject_for";
808
+ /**
809
+ * `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
810
+ * token for `p_user`, from the same membership, role, suspension and version
811
+ * reads, plus the custom roles the user holds when `database` mode keeps them
812
+ * in tables. A backend that only has PostgREST (`postgrestSources`) builds a
813
+ * subject for an acting user or a job from it. Naming the user is why no
814
+ * client role may execute it.
815
+ */
816
+ function subjectForSql(parts, config) {
817
+ const schema = quoteIdent(parts.schema);
818
+ const fn = `${schema}.${SUBJECT_FOR}`;
819
+ const rows = parts.roles;
820
+ const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
821
+ into held
822
+ from ${rows.from}
823
+ where ${rows.userSql} = v_roles_user;`;
824
+ const suspended = parts.users === void 0 ? "" : `
825
+ if not ${activeRowSql(parts.users, "uid")} then
826
+ return jsonb_build_object('id', uid::text, 'active', false);
827
+ end if;`;
828
+ const version = parts.version ? `
829
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
830
+ ver := coalesce(ver, 0);` : "";
831
+ const custom = config.rls?.customRoles === true && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
832
+ return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
833
+ create or replace function ${fn}(p_user uuid)
834
+ returns jsonb
835
+ language plpgsql
836
+ stable
837
+ security definer
838
+ set search_path = ''
839
+ as $$
840
+ declare
841
+ uid uuid := p_user;
842
+ held jsonb;
843
+ memberships jsonb;
844
+ custom jsonb := '[]'::jsonb;
845
+ ver bigint;${typedUsers(parts)}
846
+ begin
847
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
848
+ return null;
849
+ end if;${suspended}
850
+ ${roles}
851
+ select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
852
+ into memberships
853
+ from (
854
+ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
855
+ ) x;${custom}${version}
856
+ return jsonb_strip_nulls(jsonb_build_object(
857
+ 'id', uid::text,
858
+ 'active', true,
859
+ 'roles', held,
860
+ 'memberships', memberships,
861
+ 'customRoles', custom,
862
+ 'authzVersion', ver
863
+ ));
864
+ end;
865
+ $$;
866
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
867
+ }
868
+ /**
869
+ * The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
870
+ * function per reader, in the API schema, executable by `service_role` only.
871
+ */
872
+ function apiWrappersSql(parts) {
873
+ const api = parts.hook.api;
874
+ if (api === void 0) return "";
875
+ const target = api.schema ?? "public";
876
+ const names = hookApiNames(api.prefix);
877
+ const source = quoteIdent(parts.schema);
878
+ const wrapper = (name, inner, params, args, types, returns) => {
879
+ const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
880
+ if (target === parts.schema && name === inner) throw new Error(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
881
+ return `create or replace function ${fn}(${params})
882
+ returns ${returns}
883
+ language sql
884
+ stable
885
+ security definer
886
+ set search_path = ''
887
+ as $$ select ${source}.${inner}(${args}) $$;
888
+ revoke execute on function ${fn}(${types}) from public, anon, authenticated;
889
+ grant execute on function ${fn}(${types}) to service_role;`;
890
+ };
891
+ return `-- supabase.hook.api: wrappers in ${quoteIdent(target)} for a backend that reaches the hook schema through the Data API; only service_role may execute them
892
+ ${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
893
+
894
+ ${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
895
+
896
+ ${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
897
+ }
898
+ const AUTHZ_VERSION_FOR = "authz_version_for";
899
+ /**
900
+ * `authz_version_for(p_user uuid) returns bigint`: the authorization version
901
+ * `subject_for` reports, without reading roles or memberships, so a
902
+ * `claimsFirst` reread can check a token's freshness on every request and read
903
+ * the full subject only when it is behind. `null` for an unknown or suspended
904
+ * user, and when the version is off.
905
+ */
906
+ function authzVersionForSql(parts) {
907
+ const schema = quoteIdent(parts.schema);
908
+ const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
909
+ return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
910
+ create or replace function ${fn}(p_user uuid)
911
+ returns bigint
912
+ language plpgsql
913
+ stable
914
+ security definer
915
+ set search_path = ''
916
+ as $$
917
+ declare
918
+ uid uuid := p_user;
919
+ ver bigint;
920
+ begin
921
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
922
+ return null;
923
+ end if;${parts.users === void 0 ? "" : `
924
+ if not ${activeRowSql(parts.users, "uid")} then
925
+ return null;
926
+ end if;`}${parts.version ? `
927
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
928
+ ver := coalesce(ver, 0);` : ""}
929
+ return ver;
930
+ end;
931
+ $$;
932
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
933
+ }
934
+ const MEMBERS_OF = "members_of";
935
+ function membersOfSql(parts) {
936
+ const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
937
+ const keep = parts.sources.some((source) => source.sql.keeps);
938
+ const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
939
+ return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
940
+ create or replace function ${fn}(p_scope text, p_id text)
941
+ returns jsonb
942
+ language sql
943
+ stable
944
+ security definer
945
+ set search_path = ''
946
+ as $$
947
+ select coalesce(jsonb_agg(jsonb_build_object(
948
+ 'principal', jsonb_build_object('id', s.user_id),
949
+ 'membership', jsonb_strip_nulls(jsonb_build_object(
950
+ 'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
951
+ 'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
952
+ 'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
953
+ 'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
954
+ ))
955
+ ) order by s.user_id, s.roles::text), '[]'::jsonb)
956
+ from (
957
+ ${rows}
958
+ ) s
959
+ $$;
960
+ revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
961
+ }
998
962
  /**
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.
963
+ * The custom roles `p_user` holds: tenant roles named on a membership of the
964
+ * scope (and instance) they live at, and platform roles among the global
965
+ * roles, each with its grants and includes in `CustomRole` form.
1002
966
  */
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("");
967
+ function customRolesOfSql(parts) {
968
+ const helpers = quoteIdent(parts.helpers.schema);
969
+ const perms = `${helpers}.custom_role_permissions`;
970
+ const includes = `${helpers}.custom_role_includes`;
971
+ const root = quoteLiteral(parts.root);
972
+ 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`;
973
+ return `
974
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
975
+ 'name', c.role,
976
+ 'tenant', c.tenant_id::text,
977
+ 'scope', c.scope,
978
+ 'id', c.scope_id,
979
+ 'grants', (
980
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
981
+ 'permission', p.permission,
982
+ 'effect', p.effect,
983
+ 'level', to_jsonb(p) ->> 'level'
984
+ )) order by p.permission, p.effect), '[]'::jsonb)
985
+ from ${perms} p
986
+ where ${match}
987
+ ),
988
+ 'includes', (
989
+ select jsonb_agg(p.include_role order by p.include_role)
990
+ from ${includes} p
991
+ where ${match}
992
+ )
993
+ )) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
994
+ into custom
995
+ from (
996
+ select tenant_id, scope, scope_id, role from ${perms}
997
+ union
998
+ select tenant_id, scope, scope_id, role from ${includes}
999
+ ) c
1000
+ where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
1001
+ or exists (
1002
+ select 1
1003
+ from jsonb_array_elements(memberships) m
1004
+ where m -> 'roles' ? c.role
1005
+ and m ->> 'scope' = c.scope
1006
+ and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
1007
+ and (c.scope_id is null or c.scope_id = m ->> 'id')
1008
+ );`;
1009
+ }
1010
+ function signedUp(user) {
1011
+ return `exists (select 1 from auth.users a where a.id = ${user})`;
1017
1012
  }
1018
1013
  /**
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.
1014
+ * Every membership source whose user id lives in another table: a row of
1015
+ * the source bumps the users its old and new references hold, and a change
1016
+ * of that table's user id (a contact re-linked to another login) bumps the
1017
+ * old and the new user.
1023
1018
  */
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");
1019
+ function memberUsersVersionSql(parts, bumpFor) {
1020
+ const holders = /* @__PURE__ */ new Map();
1021
+ for (const source of parts.sources) {
1022
+ const users = source.sql.userThrough;
1023
+ if (users !== void 0) holders.set(JSON.stringify([
1024
+ tableKey(source.sql.table),
1025
+ tableKey(users.table),
1026
+ users.id,
1027
+ users.key,
1028
+ users.ref
1029
+ ]), {
1030
+ table: source.sql.table,
1031
+ users
1032
+ });
1037
1033
  }
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;
1034
+ if (holders.size === 0) return "";
1035
+ const byTable = /* @__PURE__ */ new Map();
1036
+ for (const holder of holders.values()) {
1037
+ const key = tableKey(holder.table);
1038
+ const group = byTable.get(key);
1039
+ if (group === void 0) byTable.set(key, {
1040
+ table: holder.table,
1041
+ users: [holder.users]
1042
+ });
1043
+ else group.users.push(holder.users);
1044
+ }
1045
+ const memberBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_member_users`;
1046
+ const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
1047
+ const groups = [...byTable];
1048
+ const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
1049
+ ${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
1050
+ ${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
1051
+ ${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
1052
+ ${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
1053
+ ${indent}));`;
1054
+ const body = groups.map(([key, group]) => {
1055
+ if (groups.length === 1) return group.users.map((users) => lookup(users, " ")).join("\n");
1056
+ const dot = key.indexOf(".");
1057
+ return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
1058
+ ${group.users.map((users) => lookup(users, " ")).join("\n")}
1059
+ end if;`;
1060
+ }).join("\n");
1061
+ const memberTriggers = groups.map(([, group]) => {
1062
+ const target = table(group.table);
1063
+ return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1064
+ create trigger ${quoteIdent(VERSION_TRIGGER)}
1065
+ after insert or update or delete on ${target}
1066
+ for each row execute function ${memberBump}();`;
1067
+ }).join("\n");
1068
+ const userTables = /* @__PURE__ */ new Map();
1069
+ for (const { users } of holders.values()) {
1070
+ const key = JSON.stringify([tableKey(users.table), users.key]);
1071
+ const entry = userTables.get(key) ?? {
1072
+ table: users.table,
1073
+ key: users.key,
1074
+ ids: /* @__PURE__ */ new Set()
1075
+ };
1076
+ entry.ids.add(users.id);
1077
+ userTables.set(key, entry);
1078
+ }
1079
+ const userTriggers = [...userTables.values()].map((entry) => {
1080
+ const target = table(entry.table);
1081
+ const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
1082
+ return `drop trigger if exists ${name} on ${target};
1083
+ create trigger ${name}
1084
+ after update of ${[entry.key, ...entry.ids].map(quoteIdent).join(", ")} or delete on ${target}
1085
+ for each row execute function ${linkedBump}(${quoteLiteral(entry.key)});`;
1086
+ }).join("\n");
1087
+ return `
1057
1088
 
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
1089
+ -- a membership whose user id lives in another table bumps the users its old and new rows reference
1090
+ create or replace function ${memberBump}()
1091
+ returns trigger
1092
+ language plpgsql
1062
1093
  security definer
1063
1094
  set search_path = ''
1064
1095
  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
1096
+ begin
1097
+ ${body}
1098
+ return null;
1099
+ end;
1068
1100
  $$;
1069
- revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
1101
+ revoke execute on function ${memberBump}() from public, anon, authenticated;
1102
+ ${memberTriggers}
1070
1103
 
1071
- create or replace function ${bump}()
1104
+ -- re-linking that table's user id moves every membership that references the row: bump the old and the new user,
1105
+ -- skipping a login being deleted (its version row goes with it)
1106
+ create or replace function ${linkedBump}()
1072
1107
  returns trigger
1073
1108
  language plpgsql
1074
1109
  security definer
@@ -1077,29 +1112,92 @@ as $$
1077
1112
  declare
1078
1113
  column_name text := tg_argv[0];
1079
1114
  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[]);
1115
+ perform ${bumpFor}(array(
1116
+ select u
1117
+ from unnest(array[
1118
+ case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
1119
+ case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
1120
+ ]::uuid[]) u
1121
+ where ${signedUp("u")}
1122
+ ));
1084
1123
  return null;
1085
1124
  end;
1086
1125
  $$;
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)}`;
1126
+ revoke execute on function ${linkedBump}() from public, anon, authenticated;
1127
+ ${userTriggers}`;
1128
+ }
1129
+ /** Every table whose role column references a roles table: the global roles and the membership sources. */
1130
+ function roleKeyHolders(parts) {
1131
+ const holders = [];
1132
+ if (parts.roles?.through !== void 0) holders.push({
1133
+ table: parts.roles.table,
1134
+ user: parts.roles.user,
1135
+ through: parts.roles.through
1136
+ });
1137
+ for (const source of parts.sources) for (const through of source.sql.throughs) holders.push({
1138
+ table: source.sql.table,
1139
+ user: source.sql.user,
1140
+ ...source.sql.userThrough === void 0 ? {} : { userThrough: source.sql.userThrough },
1141
+ through
1142
+ });
1143
+ const seen = /* @__PURE__ */ new Set();
1144
+ return holders.filter((holder) => {
1145
+ const key = JSON.stringify([
1146
+ tableKey(holder.table),
1147
+ holder.user,
1148
+ holder.userThrough === void 0 ? null : [
1149
+ tableKey(holder.userThrough.table),
1150
+ holder.userThrough.id,
1151
+ holder.userThrough.key
1152
+ ],
1153
+ tableKey(holder.through.table),
1154
+ holder.through.id,
1155
+ holder.through.key,
1156
+ holder.through.ref
1157
+ ]);
1158
+ if (seen.has(key)) return false;
1159
+ seen.add(key);
1160
+ return true;
1161
+ });
1092
1162
  }
1093
- /** A renamed role key changes every holder's `roles` claim, so updates to the roles table bump each holder. */
1163
+ /** The bump for one roles table: unchanged keys and ids return early, else every holder of `old` is bumped. */
1164
+ function roleKeysBumpSql(holders, versionTable, indent) {
1165
+ return `${indent}if tg_op = 'UPDATE'
1166
+ ${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
1167
+ ${indent} return null;
1168
+ ${indent}end if;
1169
+ ${indent}insert into ${versionTable} as v (user_id, version)
1170
+ ${indent}select distinct h.user_id, 1
1171
+ ${indent}from (
1172
+ ${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} `)}
1173
+ ${indent}) h
1174
+ ${indent}where h.user_id is not null
1175
+ ${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
1176
+ ${indent}on conflict (user_id) do update set version = v.version + 1;`;
1177
+ }
1178
+ /**
1179
+ * A renamed role key changes the `roles` claim of everyone who holds it and
1180
+ * the `roles` of every membership that references it, so updates to a roles
1181
+ * table bump each holder.
1182
+ */
1094
1183
  function roleKeysVersionSql(parts, versionTable) {
1095
- const through = parts.roles?.through;
1096
- if (parts.roles === void 0 || through === void 0) return "";
1184
+ const holders = roleKeyHolders(parts);
1185
+ if (holders.length === 0) return "";
1186
+ const byTable = /* @__PURE__ */ new Map();
1187
+ for (const holder of holders) {
1188
+ const key = tableKey(holder.through.table);
1189
+ const group = byTable.get(key);
1190
+ if (group === void 0) byTable.set(key, {
1191
+ table: holder.through.table,
1192
+ holders: [holder]
1193
+ });
1194
+ else group.holders.push(holder);
1195
+ }
1097
1196
  const bump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_role_keys`;
1098
- const id = quoteIdent(through.id);
1099
- const key = quoteIdent(through.key);
1197
+ const groups = [...byTable];
1100
1198
  return `
1101
1199
 
1102
- -- a renamed role key changes the roles claim of everyone who holds it
1200
+ -- a renamed role key changes the roles of everyone who holds it, globally or through a membership
1103
1201
  create or replace function ${bump}()
1104
1202
  returns trigger
1105
1203
  language plpgsql
@@ -1107,24 +1205,24 @@ security definer
1107
1205
  set search_path = ''
1108
1206
  as $$
1109
1207
  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;
1208
+ ${groups.map(([key, group]) => {
1209
+ if (groups.length === 1) return roleKeysBumpSql(group.holders, versionTable, " ");
1210
+ const dot = key.indexOf(".");
1211
+ return ` if tg_table_schema = ${quoteLiteral(key.slice(0, dot))} and tg_table_name = ${quoteLiteral(key.slice(dot + 1))} then
1212
+ ${roleKeysBumpSql(group.holders, versionTable, " ")}
1213
+ end if;`;
1214
+ }).join("\n")}
1120
1215
  return null;
1121
1216
  end;
1122
1217
  $$;
1123
1218
  revoke execute on function ${bump}() from public, anon, authenticated;
1124
- drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${table(through.table)};
1219
+ ${groups.map(([, group]) => {
1220
+ const target = table(group.table);
1221
+ return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1125
1222
  create trigger ${quoteIdent(VERSION_TRIGGER)}
1126
- after update or delete on ${table(through.table)}
1223
+ after update or delete on ${target}
1127
1224
  for each row execute function ${bump}();`;
1225
+ }).join("\n")}`;
1128
1226
  }
1129
1227
  /**
1130
1228
  * Fails the migration when a client role can write an `attrs` column: a
@@ -1216,6 +1314,7 @@ function hookParts(scopes, config, overrides) {
1216
1314
  active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
1217
1315
  attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
1218
1316
  extra: extraPlan.claims,
1317
+ before: beforePlan(hook.before),
1219
1318
  helpers: {
1220
1319
  schema: config.rls?.schema ?? "permdock",
1221
1320
  ...memberForPlan({
@@ -1253,38 +1352,121 @@ function hookClaims(parts) {
1253
1352
  }))
1254
1353
  ];
1255
1354
  }
1355
+ //#endregion
1356
+ //#region src/cli/supabase-hook-manifest.ts
1256
1357
  function helperList(parts, types) {
1257
1358
  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) => {
1359
+ return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
1264
1360
  const returns = `setof ${types.get(scope.name) ?? "uuid"}`;
1265
1361
  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
- }] : []
1362
+ helperEntry(permittedIdsHelper(scope.name), "p_grant text", returns, client),
1363
+ helperEntry(memberIdsHelper(scope.name), "", returns, client),
1364
+ ...parts.helpers.memberFor.includes(scope.name) ? [helperEntry(memberForHelper(scope.name), "p_user uuid", returns, parts.extra.length === 0 ? [] : ["supabase_auth_admin"])] : []
1284
1365
  ];
1285
1366
  })];
1286
1367
  }
1287
- function manifestOf(parts, out, config) {
1368
+ function helperEntry(name, args, returns, execute) {
1369
+ return {
1370
+ name,
1371
+ args,
1372
+ returns,
1373
+ execute
1374
+ };
1375
+ }
1376
+ /**
1377
+ * The helpers `rls generate` writes beyond the ones the hook's claims feed:
1378
+ * the `_for` forms in `database` mode, and the assignment checks when a role
1379
+ * declares `assigns` (known only with the policy).
1380
+ */
1381
+ function trustedHelpers(parts, config, types, tenantType, policy) {
1382
+ const database = resolveAuthorize(config) === "database";
1383
+ const custom = database && config.rls?.customRoles === true;
1384
+ const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
1385
+ const client = ["authenticated"];
1386
+ const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
1387
+ const forUser = database ? [
1388
+ helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
1389
+ ...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
1390
+ ...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
1391
+ ] : [];
1392
+ const keyed = [
1393
+ helperEntry(USER_ID_HELPER, "", "uuid", client),
1394
+ ...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
1395
+ ...forUser
1396
+ ];
1397
+ if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
1398
+ const customArgs = `p_tenant ${tenantType}, p_scope text, p_scope_id text, p_role text`;
1399
+ const anyArgs = `p_role text, p_tenant ${tenantType}, p_scope text, p_scope_id text`;
1400
+ return [
1401
+ ...keyed,
1402
+ helperEntry(OWNERSHIP.canAssign, "p_role text, p_scope_id text", "boolean", client),
1403
+ ...database ? [helperEntry(OWNERSHIP.canAssignFor, "p_user uuid, p_role text, p_scope_id text", "boolean", [])] : [],
1404
+ ...custom ? [helperEntry(ASSIGNMENTS.custom, customArgs, "boolean", client)] : [],
1405
+ ...custom && everyFor ? [helperEntry(ASSIGNMENTS.customFor, `p_user uuid, ${customArgs}`, "boolean", [])] : [],
1406
+ helperEntry(OWNERSHIP.canAssignAny, anyArgs, "boolean", client),
1407
+ ...database && (!custom || everyFor) ? [helperEntry(OWNERSHIP.canAssignAnyFor, `p_user uuid, ${anyArgs}`, "boolean", [])] : []
1408
+ ];
1409
+ }
1410
+ function activeRowManifest(row) {
1411
+ const keep = "keep" in row ? keptKeys(row) : [];
1412
+ return {
1413
+ table: qualifiedTable(row.table),
1414
+ id: row.id,
1415
+ ...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
1416
+ ...row.status === void 0 ? {} : { status: row.status },
1417
+ ...row.active === void 0 ? {} : { active: row.active },
1418
+ ...keep.length === 0 ? {} : { keep }
1419
+ };
1420
+ }
1421
+ /** The `rls` settings a package writing SQL next to the helpers reads: custom roles, global roles, suspension and assignment triggers. */
1422
+ function rlsSettings(parts, config) {
1423
+ const rls = config.rls;
1424
+ const suspension = checkSuspension(rls?.suspension, parts.scopes);
1425
+ const scopes = Object.entries(suspension?.scopes ?? {});
1426
+ const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
1427
+ return {
1428
+ customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
1429
+ ...parts.roles === void 0 ? {} : { roles: {
1430
+ table: qualifiedTable(parts.roles.table),
1431
+ user: { column: parts.roles.user },
1432
+ role: parts.roles.role
1433
+ } },
1434
+ ...suspension === void 0 ? {} : { suspension: {
1435
+ ...suspension.users === void 0 ? {} : { users: activeRowManifest(suspension.users) },
1436
+ ...scopes.length === 0 ? {} : { scopes: Object.fromEntries(scopes.map(([name, row]) => [name, activeRowManifest(row)])) },
1437
+ ...suspension.memberships === void 0 ? {} : { memberships: { keep: keptKeys(suspension.memberships) } }
1438
+ } },
1439
+ ...assigned === void 0 ? {} : { assignments: { tables: assigned } },
1440
+ ...rls?.apiKeys === void 0 ? {} : { apiKeys: apiKeyFields(rls.apiKeys) }
1441
+ };
1442
+ }
1443
+ /** The `supabase/sdk` release `requires.capabilities` names features of. */
1444
+ const CAPABILITY_MATRIX = "capability-matrix-v1.12.0";
1445
+ function usesLiveSession(policy) {
1446
+ return [...policy?.grants ?? [], ...policy?.roles.flatMap((binding) => binding.grants) ?? []].some((grant) => hasConditionOp(grant.where, "liveSession") || hasConditionOp(grant.check, "liveSession"));
1447
+ }
1448
+ function requiredCapabilities(config, policy) {
1449
+ const ids = /* @__PURE__ */ new Set(["auth.session.get_claims"]);
1450
+ if (usesLiveSession(policy)) ids.add("auth.session.get_user");
1451
+ if (config.rls?.realtime !== void 0) ids.add("realtime.subscriptions.private_channel");
1452
+ for (const bucket of Object.values(config.rls?.storage?.buckets ?? {})) {
1453
+ if (bucket.read !== void 0) {
1454
+ ids.add("storage.file_buckets.download");
1455
+ ids.add("storage.file_buckets.list_files");
1456
+ }
1457
+ if (bucket.write !== void 0) {
1458
+ ids.add("storage.file_buckets.upload");
1459
+ ids.add("storage.file_buckets.update_file");
1460
+ ids.add("storage.file_buckets.move");
1461
+ }
1462
+ if (bucket.delete !== void 0) ids.add("storage.file_buckets.remove");
1463
+ }
1464
+ return {
1465
+ matrix: CAPABILITY_MATRIX,
1466
+ capabilities: [...ids].toSorted()
1467
+ };
1468
+ }
1469
+ function manifestOf(parts, out, config, policy) {
1288
1470
  const rls = config.rls;
1289
1471
  const ctx = {
1290
1472
  dialect: "supabase",
@@ -1303,11 +1485,12 @@ function manifestOf(parts, out, config) {
1303
1485
  hook: {
1304
1486
  schema: parts.schema,
1305
1487
  function: "custom_access_token_hook",
1306
- out
1488
+ out,
1489
+ ...parts.before.length === 0 ? {} : { before: parts.before }
1307
1490
  },
1308
1491
  helpers: {
1309
1492
  schema: parts.helpers.schema,
1310
- functions: helpers.map((helper) => helper.name)
1493
+ functions: helpers.map((entry) => entry.name)
1311
1494
  },
1312
1495
  tenantClaim: parts.tenantClaim,
1313
1496
  budget: {
@@ -1331,8 +1514,9 @@ function manifestOf(parts, out, config) {
1331
1514
  type: types.get(scope.name) ?? "uuid",
1332
1515
  ...scope.within === void 0 ? {} : { within: scope.within }
1333
1516
  })),
1334
- helpers,
1335
- memberships: parts.helpers.memberships
1517
+ helpers: [...helpers, ...trustedHelpers(parts, config, types, tenantTypeOf(ctx), policy)],
1518
+ memberships: parts.helpers.memberships,
1519
+ ...rlsSettings(parts, config)
1336
1520
  },
1337
1521
  decidingColumns: decidingColumns(config, parts.attrs === void 0 ? void 0 : {
1338
1522
  ...parts.attrs.table === void 0 ? {} : { table: parts.attrs.table },
@@ -1341,7 +1525,8 @@ function manifestOf(parts, out, config) {
1341
1525
  markers: {
1342
1526
  hook: "v1",
1343
1527
  grants: "v1"
1344
- }
1528
+ },
1529
+ requires: requiredCapabilities(config, policy)
1345
1530
  };
1346
1531
  }
1347
1532
  /** The first line of the generated hook: fields `--check` compares before the full text. */
@@ -1351,17 +1536,155 @@ function hookMarker(manifest) {
1351
1536
  function defaultOut(config) {
1352
1537
  return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
1353
1538
  }
1539
+ //#endregion
1540
+ //#region src/cli/supabase-setup.ts
1541
+ const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
1542
+ function escape(text) {
1543
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
1544
+ }
1545
+ /** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
1546
+ function helpersDefined(sql, schema) {
1547
+ const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
1548
+ const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
1549
+ const names = /* @__PURE__ */ new Set();
1550
+ for (const match of sql.matchAll(create)) {
1551
+ const name = (match[1] ?? "").toLowerCase();
1552
+ if (HELPER_NAME.test(name)) names.add(name);
1553
+ }
1554
+ return names;
1555
+ }
1556
+ /**
1557
+ * SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
1558
+ * its `helpers` part when it has `{part}`), the SQL files next to the hook,
1559
+ * and the migration folders, not the hook itself.
1560
+ */
1561
+ function helperSources(cwd, config, hookOut) {
1562
+ const hook = resolve(cwd, hookOut);
1563
+ const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
1564
+ const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
1565
+ if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
1566
+ const beside = dirname(hook);
1567
+ if (existsSync(beside)) {
1568
+ for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
1569
+ }
1570
+ files.delete(hook);
1571
+ return [...files];
1572
+ }
1573
+ function missingHelpersInFiles(cwd, config, manifest) {
1574
+ const found = /* @__PURE__ */ new Set();
1575
+ for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
1576
+ return manifest.helpers.functions.filter((name) => !found.has(name));
1577
+ }
1578
+ async function missingHelpersInDb(db, manifest, connect = (url) => connectPg(url, "permdock supabase hook generate --db")) {
1579
+ const client = await connect(db);
1580
+ try {
1581
+ const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
1582
+ where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
1583
+ const found = new Set(result.rows.map((row) => row["proname"]));
1584
+ return manifest.helpers.functions.filter((name) => !found.has(name));
1585
+ } finally {
1586
+ await client.end();
1587
+ }
1588
+ }
1589
+ function missingHelpersMessage(manifest, missing) {
1590
+ 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`;
1591
+ }
1592
+ /** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
1593
+ function oversizedClaims(samples, manifest) {
1594
+ const encoder = new TextEncoder();
1595
+ const oversized = [];
1596
+ for (const claim of manifest.claims) {
1597
+ if (claim.source === "permdock") continue;
1598
+ let bytes = 0;
1599
+ for (const sample of samples) {
1600
+ if (sample === null || typeof sample !== "object") continue;
1601
+ const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
1602
+ if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
1603
+ }
1604
+ if (bytes > manifest.budget.bytes) oversized.push({
1605
+ name: claim.name,
1606
+ bytes
1607
+ });
1608
+ }
1609
+ return oversized;
1610
+ }
1611
+ /** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
1612
+ function droppedMemberships(samples) {
1613
+ const out = [];
1614
+ for (const [index, sample] of samples.entries()) {
1615
+ if (sample === null || typeof sample !== "object") continue;
1616
+ const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
1617
+ const { dropped } = readMemberships(claim);
1618
+ if (dropped.length > 0) out.push({
1619
+ sample: index,
1620
+ entries: dropped
1621
+ });
1622
+ }
1623
+ return out;
1624
+ }
1625
+ function pd039(input) {
1626
+ const findings = [];
1627
+ const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
1628
+ if (missing.length > 0) findings.push({
1629
+ code: "PD039",
1630
+ severity: "warning",
1631
+ message: missingHelpersMessage(input.manifest, missing).slice(6),
1632
+ 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"
1633
+ });
1634
+ const fixture = input.config.doctor?.claims;
1635
+ if (fixture !== void 0) {
1636
+ const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
1637
+ const samples = Array.isArray(parsed) ? parsed : [];
1638
+ for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
1639
+ code: "PD039",
1640
+ severity: "warning",
1641
+ message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
1642
+ fix: "return less from the claim function, or read the data per request instead of from the token"
1643
+ });
1644
+ for (const { sample, entries } of droppedMemberships(samples)) findings.push({
1645
+ code: "PD039",
1646
+ severity: "warning",
1647
+ message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
1648
+ fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
1649
+ });
1650
+ }
1651
+ return findings;
1652
+ }
1653
+ //#endregion
1654
+ //#region src/cli/supabase-hook.ts
1655
+ const SUPABASE_HELP = `permdock supabase hook generate | inspect
1656
+
1657
+ hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
1658
+ [--active-from app_metadata.active_<scope>|<table>.<column>]
1659
+ [--budget 1024] [--schema public] [--grants-out <file>|-]
1660
+ inspect [--json] [--out [permdock.manifest.json]] [--check]
1661
+
1662
+ Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
1663
+ passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
1664
+ needs (the ones db diff drops in --grants-out, for declarative schemas), the permdock_authz_version table
1665
+ and its triggers. Never grants anything to
1666
+ service_role unless supabase.hook.api is set, which grants its wrappers to it. inspect prints the manifest: helper schema, names and signatures, tenant claim,
1667
+ budget, the claims the hook writes, the membership sources and the columns that decide them.
1668
+ --out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
1669
+ file differs.
1670
+ `;
1671
+ const MANIFEST_FILE = "permdock.manifest.json";
1354
1672
  /** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
1355
1673
  function hookOut(cwd, config, schema) {
1356
1674
  const pgDelta = supabaseConfig(cwd).pgDelta;
1357
1675
  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
1676
  }
1359
- function supabaseHookManifest(scopes, config, overrides = {}) {
1360
- return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config);
1677
+ /**
1678
+ * The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
1679
+ * lists the assignment checks `rls generate` writes for roles that declare
1680
+ * `assigns`.
1681
+ */
1682
+ function supabaseHookManifest(scopes, config, overrides = {}, policy) {
1683
+ return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
1361
1684
  }
1362
1685
  /**
1363
- * The hook migration. With `grantsOut`, the `supabase_auth_admin` grants are
1364
- * left out of `sql` and returned in `grants` for that file.
1686
+ * The hook migration. With `grantsOut`, the statements `supabase db diff`
1687
+ * drops are left out of `sql` and returned in `grants` for that file.
1365
1688
  */
1366
1689
  function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
1367
1690
  const parts = hookParts(scopes, config, overrides);
@@ -1371,18 +1694,24 @@ function supabaseHookSql(scopes, config, overrides = {}, grantsOut) {
1371
1694
  `${hookMarker(manifest)}
1372
1695
  -- custom_access_token_hook(jsonb): memberships go active ${parts.root} first and stop at the budget
1373
1696
  -- supabase/config.toml:
1374
- ${toml}${grantsOut === void 0 ? "" : `\n-- the supabase_auth_admin grants are in ${grantsOut}`}`,
1697
+ ${toml}${grantsOut === void 0 ? "" : `\n-- what supabase db diff drops from this part is in ${grantsOut}`}`,
1375
1698
  attrsGuardSql(parts.attrs),
1376
1699
  hookSql(parts),
1377
1700
  versionSql(parts),
1378
- grantsOut === void 0 ? grantsSql(parts) : "",
1701
+ subjectForSql(parts, config),
1702
+ authzVersionForSql(parts),
1703
+ membersOfSql(parts),
1704
+ apiWrappersSql(parts),
1705
+ grantsSql(parts),
1379
1706
  managedSql(parts)
1380
1707
  ].filter((chunk) => chunk !== "").join("\n\n");
1708
+ const { kept, moved } = splitUndiffed(`${sql}\n`);
1381
1709
  const grants = `${GRANTS_MARKER} schema=${parts.schema}
1382
- ${grantsSql(parts)}
1710
+ -- the privileges and view options supabase db diff drops from the hook part
1711
+ ${moved.join("\n")}
1383
1712
  `;
1384
1713
  return {
1385
- sql: `${sql}\n`,
1714
+ sql: grantsOut === void 0 ? `${sql}\n` : kept,
1386
1715
  grants,
1387
1716
  manifest
1388
1717
  };
@@ -1447,9 +1776,11 @@ function manifestFile(cwd, rel, manifest, check) {
1447
1776
  };
1448
1777
  }
1449
1778
  async function loadScopes(cwd, config) {
1779
+ return scopeList((await loadHookPolicy(cwd, config)).scopes);
1780
+ }
1781
+ async function loadHookPolicy(cwd, config) {
1450
1782
  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);
1783
+ return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
1453
1784
  }
1454
1785
  async function runSupabase(input) {
1455
1786
  const [area, action] = input.rest;
@@ -1459,10 +1790,11 @@ async function runSupabase(input) {
1459
1790
  schema: input.schema
1460
1791
  }).filter(([, value]) => value !== void 0));
1461
1792
  if (area === "inspect" && action === void 0) {
1462
- const manifest = supabaseHookManifest(await loadScopes(input.cwd, input.config), input.config, {
1793
+ const policy = await loadHookPolicy(input.cwd, input.config);
1794
+ const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
1463
1795
  ...overrides,
1464
1796
  out: hookOut(input.cwd, input.config, input.schema)
1465
- });
1797
+ }, policy);
1466
1798
  if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
1467
1799
  return {
1468
1800
  code: 0,
@@ -1525,4 +1857,4 @@ async function runSupabase(input) {
1525
1857
  };
1526
1858
  }
1527
1859
  //#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 };
1860
+ 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 };