permdock 0.3.1 → 0.4.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 (323) hide show
  1. package/dist/a2a/index.d.ts +7 -5
  2. package/dist/a2a/index.js +14 -21
  3. package/dist/ai-sdk/index.d.ts +18 -6
  4. package/dist/ai-sdk/index.js +8 -5
  5. package/dist/app-data-CV8pqqMi.js +59 -0
  6. package/dist/approvals/index.d.ts +2 -2
  7. package/dist/approvals/index.js +3 -3
  8. package/dist/{approvals-DbYAuNkG.js → approvals-DZL76lKW.js} +35 -19
  9. package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
  10. package/dist/{arazzo-BAgw9ap6.js → arazzo-C4BcskbX.js} +6 -5
  11. package/dist/{arazzo-C9chp3ST.js → arazzo-CD8d0WLb.js} +2 -2
  12. package/dist/authzen/index.d.ts +3 -3
  13. package/dist/authzen/index.js +161 -23
  14. package/dist/better-auth/index.d.ts +2 -2
  15. package/dist/better-auth/index.js +6 -19
  16. package/dist/better-supabase/index.d.ts +112 -87
  17. package/dist/better-supabase/index.js +257 -156
  18. package/dist/{bind-C_ob3LKa.d.ts → bind-BzqG8SMb.d.ts} +16 -5
  19. package/dist/{bind-CuzGytRf.js → bind-kk-uyCwH.js} +14 -6
  20. package/dist/build-instance-DIIV9DbC.js +51 -0
  21. package/dist/{canonical-json-DemiK4Vp.js → canonical-json-CFKyFOke.js} +1 -3
  22. package/dist/{capability-2kYTMWDv.d.ts → capability-CksyCWDH.d.ts} +2 -2
  23. package/dist/{capability-4qPdO9gk.js → capability-txATtKV4.js} +3 -2
  24. package/dist/catalog/index.d.ts +1 -1
  25. package/dist/catalog/index.js +1 -1
  26. package/dist/catalog-6yWAX6Ps.js +2 -0
  27. package/dist/{catalog-CYj-mX2O.js → catalog-WHG4kBq7.js} +2 -2
  28. package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-DWtg0gGR.js} +17 -10
  29. package/dist/{catalog-DWF5cYqB.js → catalog-tZUyOfOv.js} +4 -4
  30. package/dist/claude-agent/index.d.ts +4 -4
  31. package/dist/claude-agent/index.js +4 -5
  32. package/dist/clerk/index.d.ts +3 -3
  33. package/dist/clerk/index.js +8 -33
  34. package/dist/cli/bin.js +1 -1
  35. package/dist/cli/index.d.ts +3 -3
  36. package/dist/cli/index.js +3 -3
  37. package/dist/cloud/index.d.ts +4 -4
  38. package/dist/cloud/index.js +9 -15
  39. package/dist/{cloud-CfwZ39Rl.js → cloud-C7bSDgX4.js} +2 -2
  40. package/dist/{cloud-DWLXO2af.js → cloud-PeBN3IEn.js} +5 -5
  41. package/dist/{collect-DSTE3ml9.js → collect-2SrB7lfk.js} +6 -8
  42. package/dist/collect-Bw2Jdq1q.js +2 -0
  43. package/dist/{collect-CHB22Sx4.js → collect-CQx4hX4N.js} +3 -3
  44. package/dist/{commands-BeaGNj_o.js → commands-Cpip4ULx.js} +13 -13
  45. package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
  46. package/dist/compile/index.d.ts +1 -1
  47. package/dist/compile/index.js +1 -1
  48. package/dist/{compile-Ba4blROR.js → compile-CcEu2ZQ1.js} +6 -5
  49. package/dist/{compile-BkRyBDBw.d.ts → compile-CtTP7eur.d.ts} +2 -2
  50. package/dist/{config-BJiM-xAm.js → config-BIjREpyE.js} +4 -4
  51. package/dist/{config-ir40y8m0.js → config-DLt6BvV7.js} +2 -2
  52. package/dist/{config-report-CdCgbRnV.js → config-report--Xb9APJS.js} +5 -6
  53. package/dist/{connection-CU8MQxRi.js → connection-DUsczpB5.js} +3 -2
  54. package/dist/constants-DcJABbLW.js +20 -0
  55. package/dist/{context-Bcy85rGi.js → context-DieZVJXu.js} +16 -5
  56. package/dist/convex/index.d.ts +4 -4
  57. package/dist/convex/index.js +14 -16
  58. package/dist/create-BBO24vj6.js +324 -0
  59. package/dist/{credential-8P81ff4u.js → credential-kue0qpbm.js} +4 -4
  60. package/dist/deciding-columns-Sq12mEA6.js +123 -0
  61. package/dist/delegation-input-FTxMK3ji.js +19 -0
  62. package/dist/denied-text-DfRk15uw.d.ts +19 -0
  63. package/dist/{describe-DMYiCjJV.d.ts → describe-C3ldA9os.d.ts} +11 -2
  64. package/dist/{describe-B_gVZEVi.js → describe-Cgkp7eP0.js} +5 -4
  65. package/dist/{diff-C9RDjjhs.js → diff-Cnghh_Ip.js} +2 -2
  66. package/dist/{diff-2VaeChzX.js → diff-rpqoe7Ag.js} +9 -9
  67. package/dist/{doctor-Djh6JDEk.js → doctor-CuVJvhV_.js} +2 -2
  68. package/dist/doctor-owlv6ddh.js +2166 -0
  69. package/dist/drizzle/index.d.ts +2 -2
  70. package/dist/drizzle/index.js +6 -14
  71. package/dist/elysia/index.d.ts +4 -4
  72. package/dist/elysia/index.js +7 -7
  73. package/dist/{errors-DPdqI50v.d.ts → errors-OD_4c__H.d.ts} +2 -2
  74. package/dist/{errors-Cz5zn0mW.js → errors-drnfxYtm.js} +3 -3
  75. package/dist/{evaluations-tz6D9A2G.js → evaluations-C9YJsv3H.js} +45 -7
  76. package/dist/eve/index.d.ts +5 -5
  77. package/dist/eve/index.js +5 -5
  78. package/dist/express/index.d.ts +4 -4
  79. package/dist/express/index.js +8 -7
  80. package/dist/fastify/index.d.ts +15 -5
  81. package/dist/fastify/index.js +9 -29
  82. package/dist/fields-fwfXN01n.js +1016 -0
  83. package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
  84. package/dist/{fixtures-ht28Cplr.js → fixtures-h2eG2OZV.js} +5 -6
  85. package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-7REQZe0H.js} +32 -22
  86. package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
  87. package/dist/grantee-CXY-0C87.js +254 -0
  88. package/dist/grantee-conditions-MQs5NIuX.js +270 -0
  89. package/dist/grantee-match-fxYp9Nmt.js +141 -0
  90. package/dist/{helpers-H_KARDVC.js → helpers-C9YmyBIa.js} +7 -6
  91. package/dist/hono/index.d.ts +4 -4
  92. package/dist/hono/index.js +8 -8
  93. package/dist/hosted-CBHgkzDl.js +655 -0
  94. package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
  95. package/dist/{http-BIhmLfCc.js → http-BEbdMZ05.js} +2 -24
  96. package/dist/{rls-import-4lLW_pbb.js → import-BFIlhetD.js} +86 -80
  97. package/dist/{openapi-import-cmwetbzw.js → import-UHk1N7vH.js} +6 -7
  98. package/dist/{index-C8z4RjQV.d.ts → index-BwMe94Xq.d.ts} +1 -1
  99. package/dist/{index-0nNqHspG.d.ts → index-DgM7w96K.d.ts} +2 -2
  100. package/dist/{index-C7Tw7MaW.d.ts → index-Dz7didpC.d.ts} +3 -3
  101. package/dist/{index-BNc7Wer_.d.ts → index-UdVQgq48.d.ts} +105 -38
  102. package/dist/index.d.ts +15 -13
  103. package/dist/index.js +22 -22
  104. package/dist/{instance-options-jARdwfPN.d.ts → instance-options-plkrsAog.d.ts} +1 -1
  105. package/dist/{json-schema-BsAflUGW.js → json-schema-DBkdUuCO.js} +1 -3
  106. package/dist/jwt/index.d.ts +4 -4
  107. package/dist/jwt/index.js +13 -13
  108. package/dist/{kernel-_RRHmnMY.js → kernel-CB3LrIJM.js} +81 -32
  109. package/dist/kysely/index.d.ts +2 -2
  110. package/dist/kysely/index.js +6 -14
  111. package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
  112. package/dist/load-B_shHrmd.js +2 -0
  113. package/dist/{load-DhWEB9P1.js → load-C3_oTHyO.js} +11 -3
  114. package/dist/{manifest-iaoBXHKc.js → manifest-BngWKryL.js} +5 -18
  115. package/dist/{map-claims-Z4QZOiFF.js → map-claims-AVlBaJoa.js} +7 -16
  116. package/dist/{map-error-pgUWNNtG.js → map-error-BE27PYkE.js} +5 -5
  117. package/dist/{markers-B0hYLvMD.js → markers-CiwAOZtO.js} +6 -2
  118. package/dist/{may-use-DndQ5tQ-.js → may-use-BOn_dR_w.js} +4 -3
  119. package/dist/mcp/index.d.ts +26 -9
  120. package/dist/mcp/index.js +456 -446
  121. package/dist/{rls-migrate-CN4TlJTO.js → migrate-BwlB5-Wk.js} +75 -15
  122. package/dist/nest/index.d.ts +4 -4
  123. package/dist/nest/index.js +11 -12
  124. package/dist/next/index.d.ts +31 -8
  125. package/dist/next/index.js +29 -33
  126. package/dist/next/plugin.d.ts +1 -1
  127. package/dist/next/plugin.js +1 -1
  128. package/dist/node/index.d.ts +4 -4
  129. package/dist/node/index.js +4 -4
  130. package/dist/ocsf-uYFXGVw2.js +115 -0
  131. package/dist/openai/index.d.ts +5 -5
  132. package/dist/openai/index.js +4 -4
  133. package/dist/openapi/index.d.ts +3 -3
  134. package/dist/openapi/index.js +1 -1
  135. package/dist/{openapi-CqqEbHsL.js → openapi-BJdBmYDG.js} +19 -23
  136. package/dist/{openapi-BqRBvAng.js → openapi-DBzHQbov.js} +2 -5
  137. package/dist/{openapi-CQTsgKoB.js → openapi-DcQ1Kg40.js} +3 -3
  138. package/dist/orpc/index.d.ts +5 -5
  139. package/dist/orpc/index.js +9 -11
  140. package/dist/otel/index.d.ts +2 -2
  141. package/dist/otel/index.js +26 -23
  142. package/dist/output-files-CgIsvdSa.js +140 -0
  143. package/dist/{rls-ownership-DcYlZ09B.js → ownership-BgnbOJFT.js} +387 -379
  144. package/dist/{package-root-zGkxO3oN.js → package-root-DtNnpmUd.js} +3 -2
  145. package/dist/{parse-BuyjxhZV.js → parse-CVFQHk6c.js} +3 -3
  146. package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-C-q02Pal.js} +2 -4
  147. package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DvYUR0QL.d.ts} +1 -1
  148. package/dist/pdp/index.d.ts +2 -2
  149. package/dist/pdp/index.js +8 -9
  150. package/dist/{permdock-CbgvLnuo.js → permdock-L26udHSk.js} +1387 -1220
  151. package/dist/{rls-fields-CZOMlwHl.js → permission-keys-B26jeyUv.js} +1268 -2053
  152. package/dist/permission-registry-BsblpB-z.js +385 -0
  153. package/dist/{permissions-DttnqqxC.d.ts → permission-types-Dg39z9Kv.d.ts} +59 -31
  154. package/dist/{permissions-RJrovw98.js → permissions-CCJ6lmxB.js} +3 -3
  155. package/dist/{permissions-CDgvKG5L.d.ts → permissions-COH-KRgT.d.ts} +2 -2
  156. package/dist/{policy-IB6yxpC3.d.ts → policy-types-sp9P1iIV.d.ts} +661 -582
  157. package/dist/{policy-D2W7uv19.d.ts → policy-ur0BBK_e.d.ts} +2 -2
  158. package/dist/{powersync-DE19b9dT.js → powersync-BFSbSElQ.js} +2 -2
  159. package/dist/powersync-DCDgZgfZ.js +2 -0
  160. package/dist/{powersync-Dv1_aWF7.js → powersync-cZe4kzhL.js} +26 -27
  161. package/dist/prisma/index.d.ts +1 -1
  162. package/dist/prisma/index.js +6 -12
  163. package/dist/{problem-B43ztKIm.js → problem-CnWraVhC.js} +8 -7
  164. package/dist/{problem-details-_AMOYz-w.js → problem-details-DECUnXAn.js} +2 -2
  165. package/dist/{problem-details-DX075l9V.d.ts → problem-details-rIIo8B2C.d.ts} +1 -1
  166. package/dist/{protected-BdnIEFkY.d.ts → protected-B3_P7xuw.d.ts} +9 -6
  167. package/dist/{protected-utU-SLuy.js → protected-BN7BUdY1.js} +20 -8
  168. package/dist/{provider-CSQd7m92.js → provider-Bi-0HNL3.js} +23 -10
  169. package/dist/{provider-DZY281jO.d.ts → provider-kwHC9CZD.d.ts} +1 -1
  170. package/dist/{provider-store-Cfbvo4DE.js → provider-store-Ctt-81Ni.js} +6 -3
  171. package/dist/react/index.d.ts +7 -7
  172. package/dist/react/index.js +5 -5
  173. package/dist/react/provider-client.d.ts +1 -1
  174. package/dist/react/provider-client.js +1 -1
  175. package/dist/react-native/index.d.ts +20 -11
  176. package/dist/react-native/index.js +33 -17
  177. package/dist/{refs-KYb4ntaz.js → refs-f3imMjNW.js} +1 -1
  178. package/dist/relation-walk-BXnlLtg2.js +186 -0
  179. package/dist/relations-CvmSpG67.js +163 -0
  180. package/dist/{replay-CYyiLiwO.d.ts → replay-COpmZUfx.d.ts} +1 -1
  181. package/dist/request-context-BVM7aWNs.js +71 -0
  182. package/dist/request-context-C2_1lchh.d.ts +5 -0
  183. package/dist/resource-relations-D1Un3pcz.js +243 -0
  184. package/dist/retire-BFcxL6un.js +240 -0
  185. package/dist/{rls-P-BeBx0n.js → rls-55DQTuf5.js} +14 -2
  186. package/dist/{rls-DiinlRML.js → rls-BEktUaqg.js} +884 -484
  187. package/dist/{policy-xyWg7QdR.js → role-builder-B4GbKM-i.js} +235 -541
  188. package/dist/{rpc-DL9Sqvb1.js → rpc-nYC3cR_u.js} +15 -5
  189. package/dist/{run-BWc9qqvE.js → run-DMcNW_8T.js} +3 -3
  190. package/dist/{runtime-CB8gQFvS.js → runtime-D_tMXnsq.js} +1 -1
  191. package/dist/{saas-DhpJjnBA.js → saas-DtaA4JuW.js} +9 -8
  192. package/dist/{openapi-schema--ARjyfQr.js → schema-Bol6gqXA.js} +3 -5
  193. package/dist/{schema-D3MnDMxa.js → schema-DjQBcAP9.js} +4 -2
  194. package/dist/scim/index.d.ts +1 -1
  195. package/dist/scim/index.js +1 -1
  196. package/dist/{scim-BFGBBAqR.js → scim-vFR1O5oG.js} +6 -12
  197. package/dist/{scopes-CVdPrWN-.js → scopes-tvpa34ep.js} +10 -2
  198. package/dist/server/index.d.ts +18 -8
  199. package/dist/server/index.js +8 -8
  200. package/dist/{server-DlZ28Oz5.js → server-ZYEHtNHS.js} +4 -4
  201. package/dist/{create-B1d519XK.d.ts → server-types-DdqBwZN_.d.ts} +69 -33
  202. package/dist/shared-1uhI-e-b.js +294 -0
  203. package/dist/{shared-DRu8VFVM.js → shared-JhMeOsDF.js} +1 -1
  204. package/dist/{sink-Bd9PSyfq.js → sink-BTtmfPnE.js} +1 -1
  205. package/dist/{sink-B1fZzIMf.d.ts → sink-CPN4zLfs.d.ts} +1 -1
  206. package/dist/{skills-Biq5uup3.js → skills-BZdRQkxs.js} +4 -3
  207. package/dist/{skills-C5CbhhpG.js → skills-Dle4ZoLF.js} +2 -2
  208. package/dist/{snapshot-Enxd0n-s.js → snapshot-Dc9CHPME.js} +116 -13
  209. package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-Cod40D-X.js} +1 -1
  210. package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-J5T7t_vD.d.ts} +1 -1
  211. package/dist/solid/index.d.ts +22 -22
  212. package/dist/solid/index.js +40 -10
  213. package/dist/{sources-4p0-4xQm.js → sources-B1-b-R0t.js} +25 -26
  214. package/dist/{sources-BdOYLw9j.d.ts → sources-Dljy06zL.d.ts} +6 -2
  215. package/dist/{sql-xdg5f4Fi.js → sql-t8teWOfo.js} +19 -3
  216. package/dist/{src-BDwEZWrZ.js → src-Bb7no-hS.js} +194 -123
  217. package/dist/ssf/index.d.ts +3 -3
  218. package/dist/ssf/index.js +58 -61
  219. package/dist/store-9KO4BlkB.d.ts +147 -0
  220. package/dist/{store-DMAWwAJV.js → store-ILkLUj_o.js} +4 -4
  221. package/dist/{store-options-ByhZq1jV.js → store-options-CbUHDoSe.js} +16 -7
  222. package/dist/{stores-BT2pSCub.js → stores-BHXdSbw9.js} +7 -3
  223. package/dist/stores-Cxz_80g-.d.ts +31 -0
  224. package/dist/{stream-DDpvynSB.js → stream-BMui98KK.js} +2 -1
  225. package/dist/{stream-D1RXubw2.d.ts → stream-Dwsg6WwE.d.ts} +2 -2
  226. package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
  227. package/dist/supabase/index.d.ts +4 -4
  228. package/dist/supabase/index.js +3 -3
  229. package/dist/supabase/middleware.d.ts +11 -5
  230. package/dist/supabase/middleware.js +10 -7
  231. package/dist/{supabase-C0qESOa-.js → supabase-C1TWlnYt.js} +36 -52
  232. package/dist/{supabase-s4o9s1O1.js → supabase-Dbqr141s.js} +2 -2
  233. package/dist/supabase-hook-BULg7Ut_.js +2 -0
  234. package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-Cva3ikXz.js} +730 -781
  235. package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
  236. package/dist/svelte/Protected.svelte +9 -2
  237. package/dist/svelte/index.d.ts +6 -6
  238. package/dist/svelte/index.js +25 -11
  239. package/dist/svelte/runtime.d.ts +10 -3
  240. package/dist/svelte/runtime.js +4 -3
  241. package/dist/svelte/source.d.ts +6 -6
  242. package/dist/svelte/source.js +4 -4
  243. package/dist/{validity-uOp8UpYn.js → tenancy-DshjqY0n.js} +3 -39
  244. package/dist/terminal/index.d.ts +5 -5
  245. package/dist/terminal/index.js +18 -34
  246. package/dist/testing/index.d.ts +89 -77
  247. package/dist/testing/index.js +1276 -1262
  248. package/dist/testing/saas/index.d.ts +4 -4
  249. package/dist/testing/saas/index.js +2 -2
  250. package/dist/testing/saas/permissions.d.ts +1 -1
  251. package/dist/testing/saas/permissions.js +1 -1
  252. package/dist/thenable-QSmvt-vZ.js +65 -0
  253. package/dist/{token-CIWxJOCh.js → token-Bx9qy30p.js} +1 -1
  254. package/dist/tool-refusal-CQ7OEia9.js +41 -0
  255. package/dist/trpc/index.d.ts +5 -5
  256. package/dist/trpc/index.js +7 -8
  257. package/dist/{types-DbjRN55U.d.ts → types-7rJVLWe5.d.ts} +82 -4
  258. package/dist/{types-aqOHTHcp.d.ts → types-Br9IXUBG.d.ts} +1 -1
  259. package/dist/types-ByUo56un.d.ts +88 -0
  260. package/dist/{types-Cq_H8N_X.d.ts → types-C0dee0Qf.d.ts} +2 -2
  261. package/dist/{types-DxiCGmmd.d.ts → types-CIlTojhq.d.ts} +2 -2
  262. package/dist/{types-QCuYJh4q.d.ts → types-CUcFZfpU.d.ts} +1 -1
  263. package/dist/{types-C97IskBw.d.ts → types-CqrjB9o6.d.ts} +1 -1
  264. package/dist/{types-BnIEz1sU.d.ts → types-DHrmBJLH.d.ts} +24 -6
  265. package/dist/{types-Dnemwd77.d.ts → types-DpWfQdSh.d.ts} +5 -1
  266. package/dist/{types-Bvc4oWT6.d.ts → types-DvupZE0w.d.ts} +1 -1
  267. package/dist/unplugin/index.d.ts +1 -1
  268. package/dist/unplugin/index.js +1 -1
  269. package/dist/{usage-B0YuTQRO.js → usage-4G6t643F.js} +2 -2
  270. package/dist/{usage-ItS7nLcl.js → usage-CG9nOYZw.js} +7 -7
  271. package/dist/usage-iFLJ0-rr.js +2 -0
  272. package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-BSSw0l_8.d.ts} +1 -1
  273. package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
  274. package/dist/validity-dVrUEPJt.js +62 -0
  275. package/dist/{verifier-BxBo6I44.js → verifier-DMr_Zmb_.js} +51 -15
  276. package/dist/{rls-verify-CMv9H3wV.js → verify-BztUB3GX.js} +668 -239
  277. package/dist/{version-BvacVTZR.js → version-BEogC-rB.js} +1 -1
  278. package/dist/{views-Soe7bGSC.js → views-CJsrBS1f.js} +23 -1
  279. package/dist/{vocabulary-DA8a8zif.d.ts → vocabulary-hhEFLFoL.d.ts} +15 -6
  280. package/dist/vue/index.d.ts +24 -19
  281. package/dist/vue/index.js +24 -8
  282. package/dist/watch-C8TpsAl4.js +2 -0
  283. package/dist/{watch-DVBJ9cBq.js → watch-zeucB7eF.js} +6 -6
  284. package/dist/webmcp/index.d.ts +2 -2
  285. package/dist/webmcp/index.js +13 -37
  286. package/dist/wrap-Cr93qqdP.d.ts +14 -0
  287. package/dist/wrap-DWsU_5Bd.js +26 -0
  288. package/package.json +8 -8
  289. package/schemas/catalog-v1.json +6 -0
  290. package/skills/permdock-credentials/SKILL.md +1 -1
  291. package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
  292. package/skills/permdock-data/references/queries-and-rls.md +4 -0
  293. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +1 -0
  294. package/skills/permdock-wire/SKILL.md +2 -2
  295. package/skills/permdock-wire/references/adapters.md +9 -1
  296. package/skills/permdock-wire/references/policy-options.md +10 -1
  297. package/dist/catalog-CRW5Mx_B.js +0 -2
  298. package/dist/collect-DSWOk19R.js +0 -2
  299. package/dist/create-CMwIl-w2.js +0 -253
  300. package/dist/delegation-D8pUEuce.js +0 -103
  301. package/dist/doctor-DvH0IQff.js +0 -1194
  302. package/dist/doctor-collect-CjdO0wQX.js +0 -665
  303. package/dist/evaluation-items-CfCCejQd.js +0 -40
  304. package/dist/fields-BMl1RxoX.js +0 -26
  305. package/dist/grantee-Cv5Sogfv.js +0 -602
  306. package/dist/hosted-DJQw46hn.js +0 -266
  307. package/dist/instance-options-Dhhcnm3y.js +0 -22
  308. package/dist/load-DwJUafYN.js +0 -2
  309. package/dist/map-AcxK9o_D.js +0 -147
  310. package/dist/permissions-DVoF6dMt.js +0 -662
  311. package/dist/powersync-CrBX8hY6.js +0 -2
  312. package/dist/protected-xAr24FEC.d.ts +0 -40
  313. package/dist/relations-cjOL01hZ.js +0 -505
  314. package/dist/rls-graph-CHwx0V8q.js +0 -435
  315. package/dist/rls-shared-Cewd_-zI.js +0 -495
  316. package/dist/store-jOBYpiXC.d.ts +0 -36
  317. package/dist/stores-C0IMQrGI.d.ts +0 -52
  318. package/dist/supabase-config-Bj3iDH2I.js +0 -472
  319. package/dist/supabase-hook-gd3s8bxB.js +0 -2
  320. package/dist/thenable-okc2xDsj.js +0 -10
  321. package/dist/types-CKNSyIf_.d.ts +0 -52
  322. package/dist/usage-DorAJiU_.js +0 -2
  323. package/dist/watch-BwxLTYMZ.js +0 -2
@@ -1,129 +1,79 @@
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";
6
- import { t as shortDiff } from "./text-diff-BdkkYSbi.js";
7
- import { r as scopeColumn } from "./compile-Ba4blROR.js";
8
- import { t as authorizeSql } from "./supabase-C0qESOa-.js";
1
+ import { r as isRecord } from "./compact-CAf557ZB.js";
2
+ import { a as hasConditionOp } from "./refs-f3imMjNW.js";
3
+ import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-tvpa34ep.js";
4
+ import { i as describeError } from "./errors-CWfoNg9N.js";
5
+ import { i as readJsonFile, o as sqlFiles, s as writeFileEnsuringDir } from "./files-D_JIzUrP.js";
6
+ import { i as SUPABASE_MANIFEST_SCHEMA } from "./version-BEogC-rB.js";
7
+ import { c as requirePolicy } from "./load-C3_oTHyO.js";
8
+ import { r as scopeColumn } from "./compile-CcEu2ZQ1.js";
9
+ import { t as authorizeSql } from "./supabase-C1TWlnYt.js";
9
10
  import "./budget-NbEtbJC_.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";
12
- import { a as hookMarkerFields, n as HOOK_MARKER, t as GRANTS_MARKER } from "./markers-B0hYLvMD.js";
13
- import { f as splitUndiffed, h as tableKey, m as decidingColumns, n as supabaseConfig, r as MIGRATION_DIRS } from "./supabase-config-Bj3iDH2I.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-CZOMlwHl.js";
16
- import { a as ownershipRules, i as guardedTables, n as OWNERSHIP, t as ASSIGNMENTS } from "./rls-ownership-DcYlZ09B.js";
17
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
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
- }
30
- }
31
- return drift;
32
- }
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());
40
- continue;
41
- }
42
- const path = resolve(cwd, file.rel);
43
- mkdirSync(dirname(path), { recursive: true });
44
- writeFileSync(path, file.text);
45
- wrote.push(file.rel);
11
+ import { r as qualifySqlTable } from "./sql-t8teWOfo.js";
12
+ import { c as hookApiNames, d as keptKeys, t as AUTHZ_VERSION_TABLE, u as roleManifest } from "./sources-B1-b-R0t.js";
13
+ import { i as readMemberships } from "./manifest-BngWKryL.js";
14
+ import { n as GRANTS_MARKER, r as HOOK_MARKER, s as hookMarkerFields } from "./markers-CiwAOZtO.js";
15
+ import { n as MIGRATION_DIRS } from "./constants-DcJABbLW.js";
16
+ import { E as scopeTypeOf, F as quoteQualified, I as quoteTable, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, a as customRolesOn, l as USER_ID_HELPER, r as HELPERS } from "./shared-1uhI-e-b.js";
17
+ import { l as writeOutputFiles, n as driftOf, o as partPath, s as pgDeltaPath } from "./output-files-CgIsvdSa.js";
18
+ import { a as splitUndiffed, r as tableKey, t as decidingColumns } from "./deciding-columns-Sq12mEA6.js";
19
+ import { B as memberIdsHelper, D as roleNames, F as hasMemberFor, H as permittedForHelper, L as memberForHelper, R as memberForSources, S as apiKeyFields, U as permittedIdsHelper, V as memberRoleOf, Y as checkSuspension, Z as pgConnector, _ as globalRoleSource, b as API_KEY_ALLOWS, n as permissionKeysForHelper, q as activeRowSql, z as memberForTable } from "./permission-keys-B26jeyUv.js";
20
+ import { a as ASSIGNMENTS, i as guardedTables, o as OWNERSHIP, t as ownershipRules } from "./ownership-BgnbOJFT.js";
21
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
22
+ import { dirname, join, posix, resolve } from "node:path";
23
+ import { parse } from "smol-toml";
24
+ //#region src/cli/supabase-config.ts
25
+ const SUPABASE_CONFIG = "supabase/config.toml";
26
+ function table(value) {
27
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
28
+ return value;
29
+ }
30
+ function strings(value) {
31
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
32
+ }
33
+ /** `supabase/config.toml`, parsed; `undefined` when the project has none. */
34
+ function readSupabaseConfig(cwd) {
35
+ const file = join(cwd, SUPABASE_CONFIG);
36
+ if (!existsSync(file)) return;
37
+ let root;
38
+ try {
39
+ root = parse(readFileSync(file, "utf8"));
40
+ } catch (error) {
41
+ return {
42
+ ok: false,
43
+ error: describeError(error)
44
+ };
46
45
  }
46
+ const expiry = table(root["auth"])?.["jwt_expiry"];
47
+ const paths = table(table(root["db"])?.["migrations"])?.["schema_paths"];
48
+ const schemaPaths = strings(paths);
49
+ const apiSchemas = strings(table(root["api"])?.["schemas"]);
50
+ const pgDelta = table(table(root["experimental"])?.["pgdelta"]);
51
+ const schemaPath = pgDelta?.["declarative_schema_path"];
47
52
  return {
48
- wrote,
49
- printed: printed.join("\n")
53
+ ok: true,
54
+ config: {
55
+ ...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
56
+ ...schemaPaths === void 0 ? {} : { schemaPaths },
57
+ ...apiSchemas === void 0 ? {} : { apiSchemas },
58
+ ...pgDelta?.["enabled"] === true ? { pgDelta: { schemaDir: posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "") } } : {}
59
+ }
50
60
  };
51
61
  }
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));
65
- }
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);
69
- }
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;
82
- }
83
- }
84
- /** Whether `--seeds-out` names a migrations directory: it ends with \`/\` or is an existing directory. */
85
- function isSeedsDirectory(cwd, out) {
86
- if (out === "-") return false;
87
- if (out.endsWith("/")) return true;
88
- const path = resolve(cwd, out);
89
- return existsSync(path) && statSync(path).isDirectory();
90
- }
91
- /** `YYYYMMDDHHMMSS` in UTC, the Supabase migration version. */
92
- function migrationVersion(now) {
93
- return now.toISOString().replaceAll(/[^0-9]/gu, "").slice(0, 14);
94
- }
95
- const VERSION_PREFIX = /^\d{14}(?=_)/u;
96
- /**
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`.
100
- */
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));
107
- }
108
- /**
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.
114
- */
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
- };
62
+ /** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
63
+ function supabaseConfig(cwd) {
64
+ const read = readSupabaseConfig(cwd);
65
+ return read?.ok === true ? read.config : {};
66
+ }
67
+ /** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
68
+ function pd045(cwd) {
69
+ const read = readSupabaseConfig(cwd);
70
+ if (read === void 0 || read.ok) return [];
71
+ return [{
72
+ code: "PD045",
73
+ severity: "warning",
74
+ message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
75
+ fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then doctor assumes Supabase's defaults for jwt_expiry and schema_paths`
76
+ }];
127
77
  }
128
78
  //#endregion
129
79
  //#region src/cli/claims-schema.ts
@@ -249,92 +199,7 @@ const PERMDOCK_CLAIMS_SCHEMA = {
249
199
  }
250
200
  };
251
201
  //#endregion
252
- //#region src/cli/rls-rbac.ts
253
- /**
254
- * Where the helpers read roles and memberships: the flag, `rls.authorize`,
255
- * `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
256
- * `--rbac` or with a mapped memberships table, else `jwt`.
257
- */
258
- function resolveAuthorize(config, flags = {}) {
259
- const rls = config.rls;
260
- const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
261
- if (explicit !== void 0) return explicit;
262
- if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
263
- const memberships = flags.memberships ?? rls?.memberships;
264
- return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
265
- }
266
- function parseRbacAuthorize(raw) {
267
- if (raw === void 0) return;
268
- if (raw === "database" || raw === "jwt") return raw;
269
- throw new Error(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
270
- }
271
- function hookUri(schema) {
272
- return `pg-functions://postgres/${schema}/custom_access_token_hook`;
273
- }
274
- function createEnum(schema, name, values) {
275
- const list = values.map((value) => quoteLiteral(value)).join(", ");
276
- return `do $$ begin
277
- create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
278
- exception when duplicate_object then null;
279
- end $$;`;
280
- }
281
- function hookTable(name) {
282
- return quoteTable(name.includes(".") ? name : `public.${name}`);
283
- }
284
- /** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
285
- function authAdminRead(table, label) {
286
- const t = hookTable(table);
287
- const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
288
- return `grant select on table ${t} to supabase_auth_admin;
289
- drop policy if exists ${policy} on ${t};
290
- create policy ${policy} on ${t}
291
- as permissive for select
292
- to supabase_auth_admin
293
- using (true);`;
294
- }
295
- /**
296
- * Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
297
- * enums, `user_roles` and `authorize()` over the shared `role_permissions`.
298
- * The token hook comes only from `permdock supabase hook generate`, so one
299
- * function writes the claims. Policies never call `authorize()` per row; they
300
- * call the helpers. Never grants anything to `service_role`.
301
- */
302
- function rbacScaffold(policy, options) {
303
- const schema = options.schema;
304
- const q = (name) => quoteTable(`${schema}.${name}`);
305
- const s = quoteIdent(schema);
306
- const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
307
- const authorizeFn = authorizeSql({
308
- schema,
309
- authorize: options.authorize,
310
- ...options.scope === void 0 ? {} : { scope: options.scope },
311
- ...options.memberships === void 0 ? {} : { tenant: options.memberships },
312
- ...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
313
- ...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
314
- });
315
- return {
316
- head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
317
- -- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
318
- -- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
319
- ${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
320
- ${createEnum(schema, "app_permission", permissions)}
321
-
322
- create table if not exists ${q("user_roles")} (
323
- user_id uuid not null references auth.users on delete cascade,
324
- role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
325
- primary key (user_id, role)
326
- );
327
-
328
- alter table ${q("user_roles")} enable row level security;
329
- revoke all on table ${q("user_roles")} from authenticated, anon, public;
330
- `,
331
- tail: authorizeFn,
332
- warnings: []
333
- };
334
- }
335
- //#endregion
336
- //#region src/cli/supabase-hook-sql.ts
337
- const MANAGED_TRIGGER = "permdock_protect_managed";
202
+ //#region src/cli/supabase-hook/claims.ts
338
203
  const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
339
204
  const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
340
205
  "__proto__",
@@ -485,103 +350,341 @@ create extension if not exists pg_jsonschema with schema extensions;
485
350
  grant usage on schema extensions to supabase_auth_admin;
486
351
  grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
487
352
  }
488
- const VERSION_TRIGGER = "permdock_authz_version";
489
- const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
490
- function memberForPlan(input) {
491
- const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
492
- dialect: "supabase",
493
- ...input
494
- }, name));
495
- const entries = /* @__PURE__ */ new Map();
496
- for (const name of memberFor) {
497
- const mapped = memberForTable(input, name);
498
- const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
499
- for (const entry of found) entries.set(JSON.stringify(entry), entry);
500
- }
501
- return {
502
- memberFor,
503
- memberships: [...entries.values()]
504
- };
353
+ /**
354
+ * Fails the migration when a client role can write an `attrs` column: a
355
+ * claim the user can set is not a server-owned attribute.
356
+ */
357
+ function attrsGuardSql(plan) {
358
+ if (plan?.table === void 0 || plan.columns.length === 0) return "";
359
+ const target = quoteLiteral(quoteQualified(plan.table));
360
+ return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
361
+ do $$
362
+ begin
363
+ if exists (
364
+ select 1
365
+ from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
366
+ cross join (values ('anon'), ('authenticated')) r(role)
367
+ where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
368
+ or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
369
+ ) then
370
+ raise exception 'PermDock: an attrs column of % is writable by anon or authenticated; revoke insert and update on it before it becomes a claim', ${target}
371
+ using errcode = '42501';
372
+ end if;
373
+ end
374
+ $$;`;
505
375
  }
506
- /** An `rls.memberships` table in the manifest's `fromTable` / `fromJunction` shape, for scope `name`. */
507
- function mappedMembership(mapped, scopes, name) {
508
- const id = scopeColumn(mapped, scopes, name) ?? "";
509
- const within = {};
510
- for (const ancestor of scopeChain(scopes, name).slice(1)) {
511
- const column = scopeColumn(mapped, scopes, ancestor);
512
- if (column !== void 0) within[ancestor] = column;
513
- }
514
- const role = memberRoleOf(mapped);
515
- const columns = [
516
- mapped.user,
517
- id,
518
- ...role.columns,
519
- ...Object.values(within),
520
- ...typeof mapped.via === "string" ? [mapped.via] : [],
521
- ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
522
- ...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
523
- ];
376
+ function permdockClaim(name, budget = false) {
524
377
  return {
525
- table: mapped.table.includes(".") ? mapped.table : `public.${mapped.table}`,
526
- user: { column: mapped.user },
527
- scope: { value: name },
528
- id: { column: id },
529
- role: roleManifest(role),
530
- ...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
531
- ...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
532
- ...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
533
- ...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
534
- columns: [...new Set(columns)]
378
+ name,
379
+ source: "permdock",
380
+ budget
535
381
  };
536
382
  }
537
- function table(name) {
538
- return quoteTable(name.includes(".") ? name : `public.${name}`);
383
+ function hookClaims(parts) {
384
+ return [
385
+ permdockClaim("user_role"),
386
+ permdockClaim("roles"),
387
+ permdockClaim("memberships", true),
388
+ permdockClaim("memberships_truncated"),
389
+ permdockClaim(parts.tenantClaim),
390
+ ...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
391
+ ...parts.version ? [permdockClaim("authz_ver")] : [],
392
+ ...parts.extra.map((entry) => ({
393
+ name: entry.claim,
394
+ source: entry.fn,
395
+ budget: false
396
+ }))
397
+ ];
539
398
  }
540
- function positiveInteger(value, label) {
541
- const number = typeof value === "string" ? Number(value) : value;
542
- if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw new Error(`PermDock CLI: ${label} must be a positive integer`);
543
- return number;
399
+ //#endregion
400
+ //#region src/cli/supabase-setup.ts
401
+ const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
402
+ function escape(text) {
403
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
544
404
  }
545
- /**
546
- * SQL for the active first-scope id, as text, for user `uid`; a table source
547
- * compares its id column with `v_active_user`, declared as `userType`.
548
- */
549
- function activeFromSql(input, root) {
550
- const spec = input ?? `app_metadata.active_${root}`;
551
- if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
552
- const key = spec.slice(13);
553
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`PermDock CLI: unsafe app_metadata key '${key}'`);
554
- return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
405
+ /** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
406
+ function helpersDefined(sql, schema) {
407
+ const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
408
+ const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
409
+ const names = /* @__PURE__ */ new Set();
410
+ for (const match of sql.matchAll(create)) {
411
+ const name = (match[1] ?? "").toLowerCase();
412
+ if (HELPER_NAME.test(name)) names.add(name);
555
413
  }
556
- const parsed = typeof spec === "string" ? (() => {
557
- const dot = spec.lastIndexOf(".");
558
- if (dot <= 0) throw new Error(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
559
- return {
560
- table: spec.slice(0, dot),
561
- column: spec.slice(dot + 1)
562
- };
563
- })() : spec;
564
- const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
565
- return {
566
- sql: `(select a.${quoteIdent(parsed.column)}::text from ${table(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
567
- userType: `${table(parsed.table)}.${quoteIdent(id)}%type`
568
- };
414
+ return names;
569
415
  }
570
416
  /**
571
- * One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
572
- * Supabase Auth issues) as that column's type, so each lookup compares the
573
- * column uncast and its index applies.
417
+ * SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
418
+ * its `helpers` part when it has `{part}`), the SQL files next to the hook,
419
+ * and the migration folders, not the hook itself.
574
420
  */
575
- function typedUsers(parts) {
576
- return [
577
- ...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
578
- ...parts.roles === void 0 ? [] : [`v_roles_user ${table(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
579
- ...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${table(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
580
- ...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
581
- ].map((line) => `\n ${line}`).join("");
421
+ function helperSources(cwd, config, hookOut) {
422
+ const hook = resolve(cwd, hookOut);
423
+ const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
424
+ const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
425
+ if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
426
+ const beside = dirname(hook);
427
+ if (existsSync(beside)) {
428
+ for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
429
+ }
430
+ files.delete(hook);
431
+ return [...files];
582
432
  }
583
- function checkSources(parts) {
584
- if (parts.sources.length === 0) throw new Error("PermDock CLI: supabase.hook.memberships needs at least one fromTable or fromJunction source");
433
+ function missingHelpersInFiles(cwd, config, manifest) {
434
+ const found = /* @__PURE__ */ new Set();
435
+ for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
436
+ return manifest.helpers.functions.filter((name) => !found.has(name));
437
+ }
438
+ async function missingHelpersInDb(db, manifest, connect = pgConnector("permdock supabase hook generate --db")) {
439
+ const client = await connect(db);
440
+ try {
441
+ const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
442
+ where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
443
+ const found = new Set(result.rows.map((row) => row["proname"]));
444
+ return manifest.helpers.functions.filter((name) => !found.has(name));
445
+ } finally {
446
+ await client.end();
447
+ }
448
+ }
449
+ function missingHelpersMessage(manifest, missing) {
450
+ 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`;
451
+ }
452
+ /** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
453
+ function oversizedClaims(samples, manifest) {
454
+ const encoder = new TextEncoder();
455
+ const oversized = [];
456
+ for (const claim of manifest.claims) {
457
+ if (claim.source === "permdock") continue;
458
+ let bytes = 0;
459
+ for (const sample of samples) {
460
+ if (sample === null || typeof sample !== "object") continue;
461
+ const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
462
+ if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
463
+ }
464
+ if (bytes > manifest.budget.bytes) oversized.push({
465
+ name: claim.name,
466
+ bytes
467
+ });
468
+ }
469
+ return oversized;
470
+ }
471
+ /** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
472
+ function droppedMemberships(samples) {
473
+ const out = [];
474
+ for (const [index, sample] of samples.entries()) {
475
+ if (sample === null || typeof sample !== "object") continue;
476
+ const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
477
+ const { dropped } = readMemberships(claim);
478
+ if (dropped.length > 0) out.push({
479
+ sample: index,
480
+ entries: dropped
481
+ });
482
+ }
483
+ return out;
484
+ }
485
+ function pd039(input) {
486
+ const findings = [];
487
+ const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
488
+ if (missing.length > 0) findings.push({
489
+ code: "PD039",
490
+ severity: "warning",
491
+ message: missingHelpersMessage(input.manifest, missing).slice(6),
492
+ 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"
493
+ });
494
+ const fixture = input.config.doctor?.claims;
495
+ if (fixture !== void 0) {
496
+ const parsed = JSON.parse(readFileSync(resolve(input.cwd, fixture), "utf8"));
497
+ const samples = Array.isArray(parsed) ? parsed : [];
498
+ for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
499
+ code: "PD039",
500
+ severity: "warning",
501
+ message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
502
+ fix: "return less from the claim function, or read the data per request instead of from the token"
503
+ });
504
+ for (const { sample, entries } of droppedMemberships(samples)) findings.push({
505
+ code: "PD039",
506
+ severity: "warning",
507
+ message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
508
+ fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
509
+ });
510
+ }
511
+ return findings;
512
+ }
513
+ //#endregion
514
+ //#region src/cli/rls/rbac.ts
515
+ /**
516
+ * Where the helpers read roles and memberships: the flag, `rls.authorize`,
517
+ * `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
518
+ * `--rbac` or with a mapped memberships table, else `jwt`.
519
+ */
520
+ function resolveAuthorize(config, flags = {}) {
521
+ const rls = config.rls;
522
+ const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
523
+ if (explicit !== void 0) return explicit;
524
+ if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
525
+ const memberships = flags.memberships ?? rls?.memberships;
526
+ return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
527
+ }
528
+ function parseRbacAuthorize(raw) {
529
+ if (raw === void 0) return;
530
+ if (raw === "database" || raw === "jwt") return raw;
531
+ throw new Error(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
532
+ }
533
+ function hookUri(schema) {
534
+ return `pg-functions://postgres/${schema}/custom_access_token_hook`;
535
+ }
536
+ function createEnum(schema, name, values) {
537
+ const list = values.map((value) => quoteLiteral(value)).join(", ");
538
+ return `do $$ begin
539
+ create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
540
+ exception when duplicate_object then null;
541
+ end $$;`;
542
+ }
543
+ /** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
544
+ function authAdminRead(table, label) {
545
+ const t = quoteQualified(table);
546
+ const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
547
+ return `grant select on table ${t} to supabase_auth_admin;
548
+ drop policy if exists ${policy} on ${t};
549
+ create policy ${policy} on ${t}
550
+ as permissive for select
551
+ to supabase_auth_admin
552
+ using (true);`;
553
+ }
554
+ /**
555
+ * Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
556
+ * enums, `user_roles` and `authorize()` over the shared `role_permissions`.
557
+ * The token hook comes only from `permdock supabase hook generate`, so one
558
+ * function writes the claims. Policies never call `authorize()` per row; they
559
+ * call the helpers. Never grants anything to `service_role`.
560
+ */
561
+ function rbacScaffold(policy, options) {
562
+ const schema = options.schema;
563
+ const q = (name) => quoteTable(`${schema}.${name}`);
564
+ const s = quoteIdent(schema);
565
+ const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
566
+ const authorizeFn = authorizeSql({
567
+ schema,
568
+ authorize: options.authorize,
569
+ ...options.scope === void 0 ? {} : { scope: options.scope },
570
+ ...options.memberships === void 0 ? {} : { tenant: options.memberships },
571
+ ...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
572
+ ...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
573
+ });
574
+ return {
575
+ head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
576
+ -- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
577
+ -- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
578
+ ${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
579
+ ${createEnum(schema, "app_permission", permissions)}
580
+
581
+ create table if not exists ${q("user_roles")} (
582
+ user_id uuid not null references auth.users on delete cascade,
583
+ role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
584
+ primary key (user_id, role)
585
+ );
586
+
587
+ alter table ${q("user_roles")} enable row level security;
588
+ revoke all on table ${q("user_roles")} from authenticated, anon, public;
589
+ `,
590
+ tail: authorizeFn,
591
+ warnings: []
592
+ };
593
+ }
594
+ //#endregion
595
+ //#region src/cli/supabase-hook/memberships.ts
596
+ function memberForPlan(input) {
597
+ const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
598
+ dialect: "supabase",
599
+ ...input
600
+ }, name));
601
+ const entries = /* @__PURE__ */ new Map();
602
+ for (const name of memberFor) {
603
+ const mapped = memberForTable(input, name);
604
+ const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
605
+ for (const entry of found) entries.set(JSON.stringify(entry), entry);
606
+ }
607
+ return {
608
+ memberFor,
609
+ memberships: [...entries.values()]
610
+ };
611
+ }
612
+ /** An `rls.memberships` table in the manifest's `fromTable` / `fromJunction` shape, for scope `name`. */
613
+ function mappedMembership(mapped, scopes, name) {
614
+ const id = scopeColumn(mapped, scopes, name) ?? "";
615
+ const within = {};
616
+ for (const ancestor of scopeChain(scopes, name).slice(1)) {
617
+ const column = scopeColumn(mapped, scopes, ancestor);
618
+ if (column !== void 0) within[ancestor] = column;
619
+ }
620
+ const role = memberRoleOf(mapped);
621
+ const columns = [
622
+ mapped.user,
623
+ id,
624
+ ...role.columns,
625
+ ...Object.values(within),
626
+ ...typeof mapped.via === "string" ? [mapped.via] : [],
627
+ ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
628
+ ...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
629
+ ];
630
+ return {
631
+ table: qualifySqlTable(mapped.table),
632
+ user: { column: mapped.user },
633
+ scope: { value: name },
634
+ id: { column: id },
635
+ role: roleManifest(role),
636
+ ...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
637
+ ...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
638
+ ...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
639
+ ...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
640
+ columns: [...new Set(columns)]
641
+ };
642
+ }
643
+ function positiveInteger(value, label) {
644
+ const number = typeof value === "string" ? Number(value) : value;
645
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw new Error(`PermDock CLI: ${label} must be a positive integer`);
646
+ return number;
647
+ }
648
+ /**
649
+ * SQL for the active first-scope id, as text, for user `uid`; a table source
650
+ * compares its id column with `v_active_user`, declared as `userType`.
651
+ */
652
+ function activeFromSql(input, root) {
653
+ const spec = input ?? `app_metadata.active_${root}`;
654
+ if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
655
+ const key = spec.slice(13);
656
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`PermDock CLI: unsafe app_metadata key '${key}'`);
657
+ return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
658
+ }
659
+ const parsed = typeof spec === "string" ? (() => {
660
+ const dot = spec.lastIndexOf(".");
661
+ if (dot <= 0) throw new Error(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
662
+ return {
663
+ table: spec.slice(0, dot),
664
+ column: spec.slice(dot + 1)
665
+ };
666
+ })() : spec;
667
+ const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
668
+ return {
669
+ sql: `(select a.${quoteIdent(parsed.column)}::text from ${quoteQualified(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
670
+ userType: `${quoteQualified(parsed.table)}.${quoteIdent(id)}%type`
671
+ };
672
+ }
673
+ /**
674
+ * One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
675
+ * Supabase Auth issues) as that column's type, so each lookup compares the
676
+ * column uncast and its index applies.
677
+ */
678
+ function typedUsers(parts) {
679
+ return [
680
+ ...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
681
+ ...parts.roles === void 0 ? [] : [`v_roles_user ${quoteQualified(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
682
+ ...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${quoteQualified(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
683
+ ...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
684
+ ].map((line) => `\n ${line}`).join("");
685
+ }
686
+ function checkSources(parts) {
687
+ if (parts.sources.length === 0) throw new Error("PermDock CLI: supabase.hook.memberships needs at least one fromTable or fromJunction source");
585
688
  for (const source of parts.sources) {
586
689
  if (source.sql === void 0 || typeof source.sql.select !== "function") throw new Error("PermDock CLI: supabase.hook.memberships takes fromTable / fromJunction sources from permdock/supabase");
587
690
  const scope = source.sql.scope;
@@ -607,6 +710,214 @@ from (
607
710
  ${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
608
711
  ) s`).join("\nunion all\n");
609
712
  }
713
+ //#endregion
714
+ //#region src/cli/supabase-hook/api.ts
715
+ const SUBJECT_FOR = "subject_for";
716
+ /**
717
+ * `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
718
+ * token for `p_user`, from the same membership, role, suspension and version
719
+ * reads, plus the custom roles the user holds when `database` mode keeps them
720
+ * in tables. A backend that only has PostgREST (`postgrestSources`) builds a
721
+ * subject for an acting user or a job from it. Naming the user is why no
722
+ * client role may execute it.
723
+ */
724
+ function subjectForSql(parts, config) {
725
+ const schema = quoteIdent(parts.schema);
726
+ const fn = `${schema}.${SUBJECT_FOR}`;
727
+ const rows = parts.roles;
728
+ const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
729
+ into held
730
+ from ${rows.from}
731
+ where ${rows.userSql} = v_roles_user;`;
732
+ const suspended = parts.users === void 0 ? "" : `
733
+ if not ${activeRowSql(parts.users, "uid")} then
734
+ return jsonb_build_object('id', uid::text, 'active', false);
735
+ end if;`;
736
+ const version = parts.version ? `
737
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
738
+ ver := coalesce(ver, 0);` : "";
739
+ const custom = customRolesOn(config.rls) && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
740
+ return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
741
+ create or replace function ${fn}(p_user uuid)
742
+ returns jsonb
743
+ language plpgsql
744
+ stable
745
+ security definer
746
+ set search_path = ''
747
+ as $$
748
+ declare
749
+ uid uuid := p_user;
750
+ held jsonb;
751
+ memberships jsonb;
752
+ custom jsonb := '[]'::jsonb;
753
+ ver bigint;${typedUsers(parts)}
754
+ begin
755
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
756
+ return null;
757
+ end if;${suspended}
758
+ ${roles}
759
+ select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
760
+ into memberships
761
+ from (
762
+ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
763
+ ) x;${custom}${version}
764
+ return jsonb_strip_nulls(jsonb_build_object(
765
+ 'id', uid::text,
766
+ 'active', true,
767
+ 'roles', held,
768
+ 'memberships', memberships,
769
+ 'customRoles', custom,
770
+ 'authzVersion', ver
771
+ ));
772
+ end;
773
+ $$;
774
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
775
+ }
776
+ /**
777
+ * The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
778
+ * function per reader, in the API schema, executable by `service_role` only.
779
+ */
780
+ function apiWrappersSql(parts) {
781
+ const api = parts.hook.api;
782
+ if (api === void 0) return "";
783
+ const target = api.schema ?? "public";
784
+ const names = hookApiNames(api.prefix);
785
+ const source = quoteIdent(parts.schema);
786
+ const wrapper = (name, inner, params, args, types, returns) => {
787
+ const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
788
+ if (target === parts.schema && name === inner) throw new Error(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
789
+ return `create or replace function ${fn}(${params})
790
+ returns ${returns}
791
+ language sql
792
+ stable
793
+ security definer
794
+ set search_path = ''
795
+ as $$ select ${source}.${inner}(${args}) $$;
796
+ revoke execute on function ${fn}(${types}) from public, anon, authenticated;
797
+ grant execute on function ${fn}(${types}) to service_role;`;
798
+ };
799
+ 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
800
+ ${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
801
+
802
+ ${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
803
+
804
+ ${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
805
+ }
806
+ const AUTHZ_VERSION_FOR = "authz_version_for";
807
+ /**
808
+ * `authz_version_for(p_user uuid) returns bigint`: the authorization version
809
+ * `subject_for` reports, without reading roles or memberships, so a
810
+ * `claimsFirst` reread can check a token's freshness on every request and read
811
+ * the full subject only when it is behind. `null` for an unknown or suspended
812
+ * user, and when the version is off.
813
+ */
814
+ function authzVersionForSql(parts) {
815
+ const schema = quoteIdent(parts.schema);
816
+ const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
817
+ return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
818
+ create or replace function ${fn}(p_user uuid)
819
+ returns bigint
820
+ language plpgsql
821
+ stable
822
+ security definer
823
+ set search_path = ''
824
+ as $$
825
+ declare
826
+ uid uuid := p_user;
827
+ ver bigint;
828
+ begin
829
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
830
+ return null;
831
+ end if;${parts.users === void 0 ? "" : `
832
+ if not ${activeRowSql(parts.users, "uid")} then
833
+ return null;
834
+ end if;`}${parts.version ? `
835
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
836
+ ver := coalesce(ver, 0);` : ""}
837
+ return ver;
838
+ end;
839
+ $$;
840
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
841
+ }
842
+ const MEMBERS_OF = "members_of";
843
+ function membersOfSql(parts) {
844
+ const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
845
+ const keep = parts.sources.some((source) => source.sql.keeps);
846
+ const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
847
+ return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
848
+ create or replace function ${fn}(p_scope text, p_id text)
849
+ returns jsonb
850
+ language sql
851
+ stable
852
+ security definer
853
+ set search_path = ''
854
+ as $$
855
+ select coalesce(jsonb_agg(jsonb_build_object(
856
+ 'principal', jsonb_build_object('id', s.user_id),
857
+ 'membership', jsonb_strip_nulls(jsonb_build_object(
858
+ 'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
859
+ 'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
860
+ 'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
861
+ 'managedBy', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
862
+ ))
863
+ ) order by s.user_id, s.roles::text), '[]'::jsonb)
864
+ from (
865
+ ${rows}
866
+ ) s
867
+ $$;
868
+ revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
869
+ }
870
+ /**
871
+ * The custom roles `p_user` holds: tenant roles named on a membership of the
872
+ * scope (and instance) they live at, and platform roles among the global
873
+ * roles, each with its grants and includes in `CustomRole` form.
874
+ */
875
+ function customRolesOfSql(parts) {
876
+ const helpers = quoteIdent(parts.helpers.schema);
877
+ const perms = `${helpers}.custom_role_permissions`;
878
+ const includes = `${helpers}.custom_role_includes`;
879
+ const root = quoteLiteral(parts.root);
880
+ 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`;
881
+ return `
882
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
883
+ 'name', c.role,
884
+ 'tenant', c.tenant_id::text,
885
+ 'scope', c.scope,
886
+ 'id', c.scope_id,
887
+ 'grants', (
888
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
889
+ 'permission', p.permission,
890
+ 'effect', p.effect,
891
+ 'level', to_jsonb(p) ->> 'level'
892
+ )) order by p.permission, p.effect), '[]'::jsonb)
893
+ from ${perms} p
894
+ where ${match}
895
+ ),
896
+ 'includes', (
897
+ select jsonb_agg(p.include_role order by p.include_role)
898
+ from ${includes} p
899
+ where ${match}
900
+ )
901
+ )) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
902
+ into custom
903
+ from (
904
+ select tenant_id, scope, scope_id, role from ${perms}
905
+ union
906
+ select tenant_id, scope, scope_id, role from ${includes}
907
+ ) c
908
+ where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
909
+ or exists (
910
+ select 1
911
+ from jsonb_array_elements(memberships) m
912
+ where m -> 'roles' ? c.role
913
+ and m ->> 'scope' = c.scope
914
+ and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
915
+ and (c.scope_id is null or c.scope_id = m ->> 'id')
916
+ );`;
917
+ }
918
+ //#endregion
919
+ //#region src/cli/supabase-hook/sql.ts
920
+ const MANAGED_TRIGGER = "permdock_protect_managed";
610
921
  function hookSql(parts) {
611
922
  const schema = quoteIdent(parts.schema);
612
923
  const fn = `${schema}.custom_access_token_hook`;
@@ -619,7 +930,7 @@ function hookSql(parts) {
619
930
  const fromTable = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
620
931
  select jsonb_strip_nulls(jsonb_build_object(${plan.columns.map((column) => `${quoteLiteral(column)}, to_jsonb(p.${quoteIdent(column)})`).join(", ")}))
621
932
  into attrs
622
- from ${table(plan.table)} p
933
+ from ${quoteQualified(plan.table)} p
623
934
  where p.${quoteIdent(plan.id)} = v_attrs_user;`;
624
935
  const fromMeta = plan === void 0 || plan.meta.length === 0 ? "" : `
625
936
  attrs := coalesce(attrs, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(${plan.meta.map((key) => `${quoteLiteral(key)}, claims -> 'app_metadata' -> ${quoteLiteral(key)}`).join(", ")}));`;
@@ -752,6 +1063,92 @@ function readsSql(parts) {
752
1063
  if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
753
1064
  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
1065
  }
1066
+ function managedSql(parts) {
1067
+ const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
1068
+ if (managed.length === 0) return "";
1069
+ const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
1070
+ return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
1071
+ create or replace function ${guard}()
1072
+ returns trigger
1073
+ language plpgsql
1074
+ set search_path = ''
1075
+ as $$
1076
+ declare
1077
+ column_name text := tg_argv[0];
1078
+ begin
1079
+ if current_user in ('anon', 'authenticated') and (
1080
+ column_name = ''
1081
+ or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
1082
+ or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
1083
+ ) then
1084
+ raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
1085
+ using errcode = '42501';
1086
+ end if;
1087
+ return coalesce(new, old);
1088
+ end;
1089
+ $$;
1090
+ ${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${quoteQualified(source.sql.table)};
1091
+ create trigger ${quoteIdent(MANAGED_TRIGGER)}
1092
+ before insert or update or delete on ${quoteQualified(source.sql.table)}
1093
+ for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
1094
+ }
1095
+ /** The `config.toml` block that enables the hook and bounds token staleness. */
1096
+ function configToml(schema, jwtExpiry) {
1097
+ return `[auth]
1098
+ jwt_expiry = ${String(jwtExpiry)}
1099
+
1100
+ [auth.hook.custom_access_token]
1101
+ enabled = true
1102
+ uri = "${hookUri(schema)}"`;
1103
+ }
1104
+ const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
1105
+ function hookParts(scopes, config, overrides) {
1106
+ const hook = config.supabase?.hook;
1107
+ if (hook === void 0) throw new Error("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
1108
+ const root = rootScope(scopes) ?? "tenant";
1109
+ const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
1110
+ const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
1111
+ quoteIdent(schema);
1112
+ const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
1113
+ const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
1114
+ checkSources({
1115
+ sources: hook.memberships,
1116
+ scopes
1117
+ });
1118
+ const parts = {
1119
+ schema,
1120
+ scopes,
1121
+ root,
1122
+ sources: hook.memberships,
1123
+ budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
1124
+ version: hook.version !== false,
1125
+ jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
1126
+ tenantClaim,
1127
+ users: suspension?.users,
1128
+ hook,
1129
+ roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r", config.rls?.customRoleWrites?.roles),
1130
+ active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
1131
+ attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
1132
+ extra: extraPlan.claims,
1133
+ before: beforePlan(hook.before),
1134
+ helpers: {
1135
+ schema: config.rls?.schema ?? "permdock",
1136
+ ...memberForPlan({
1137
+ scopes,
1138
+ ...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
1139
+ memberSources: config.rls?.membershipSources ?? hook.memberships
1140
+ })
1141
+ }
1142
+ };
1143
+ if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw new Error(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
1144
+ if (extraPlan.errors.length > 0) throw new Error(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
1145
+ quoteIdent(parts.tenantClaim);
1146
+ return parts;
1147
+ }
1148
+ //#endregion
1149
+ //#region src/cli/supabase-hook/version.ts
1150
+ const VERSION_TRIGGER = "permdock_authz_version";
1151
+ const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
755
1152
  function versionSql(parts) {
756
1153
  if (!parts.version) return "";
757
1154
  const schema = quoteIdent(parts.schema);
@@ -783,229 +1180,26 @@ $$;
783
1180
  revoke execute on function ${bumpFor}(uuid[]) from public, anon, authenticated;
784
1181
 
785
1182
  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
- }
962
- /**
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.
966
- */
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
- );`;
1183
+ returns trigger
1184
+ language plpgsql
1185
+ security definer
1186
+ set search_path = ''
1187
+ as $$
1188
+ declare
1189
+ column_name text := tg_argv[0];
1190
+ begin
1191
+ perform ${bumpFor}(array[
1192
+ case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
1193
+ case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
1194
+ ]::uuid[]);
1195
+ return null;
1196
+ end;
1197
+ $$;
1198
+ revoke execute on function ${bump}() from public, anon, authenticated;
1199
+ ${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${quoteQualified(name)};
1200
+ create trigger ${quoteIdent(VERSION_TRIGGER)}
1201
+ after insert or update or delete on ${quoteQualified(name)}
1202
+ for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
1009
1203
  }
1010
1204
  function signedUp(user) {
1011
1205
  return `exists (select 1 from auth.users a where a.id = ${user})`;
@@ -1046,7 +1240,7 @@ function memberUsersVersionSql(parts, bumpFor) {
1046
1240
  const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
1047
1241
  const groups = [...byTable];
1048
1242
  const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
1049
- ${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
1243
+ ${indent} select u.${quoteIdent(users.key)}::uuid from ${quoteQualified(users.table)} u
1050
1244
  ${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
1051
1245
  ${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
1052
1246
  ${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
@@ -1059,7 +1253,7 @@ ${group.users.map((users) => lookup(users, " ")).join("\n")}
1059
1253
  end if;`;
1060
1254
  }).join("\n");
1061
1255
  const memberTriggers = groups.map(([, group]) => {
1062
- const target = table(group.table);
1256
+ const target = quoteQualified(group.table);
1063
1257
  return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1064
1258
  create trigger ${quoteIdent(VERSION_TRIGGER)}
1065
1259
  after insert or update or delete on ${target}
@@ -1077,7 +1271,7 @@ create trigger ${quoteIdent(VERSION_TRIGGER)}
1077
1271
  userTables.set(key, entry);
1078
1272
  }
1079
1273
  const userTriggers = [...userTables.values()].map((entry) => {
1080
- const target = table(entry.table);
1274
+ const target = quoteQualified(entry.table);
1081
1275
  const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
1082
1276
  return `drop trigger if exists ${name} on ${target};
1083
1277
  create trigger ${name}
@@ -1169,7 +1363,7 @@ ${indent}end if;
1169
1363
  ${indent}insert into ${versionTable} as v (user_id, version)
1170
1364
  ${indent}select distinct h.user_id, 1
1171
1365
  ${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} `)}
1366
+ ${indent} ${holders.map((holder) => holder.userThrough === void 0 ? `select h.${quoteIdent(holder.user)}::uuid as user_id from ${quoteQualified(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 ${quoteQualified(holder.table)} h join ${quoteQualified(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
1367
  ${indent}) h
1174
1368
  ${indent}where h.user_id is not null
1175
1369
  ${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
@@ -1217,143 +1411,15 @@ end;
1217
1411
  $$;
1218
1412
  revoke execute on function ${bump}() from public, anon, authenticated;
1219
1413
  ${groups.map(([, group]) => {
1220
- const target = table(group.table);
1414
+ const target = quoteQualified(group.table);
1221
1415
  return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1222
1416
  create trigger ${quoteIdent(VERSION_TRIGGER)}
1223
1417
  after update or delete on ${target}
1224
1418
  for each row execute function ${bump}();`;
1225
1419
  }).join("\n")}`;
1226
1420
  }
1227
- /**
1228
- * Fails the migration when a client role can write an `attrs` column: a
1229
- * claim the user can set is not a server-owned attribute.
1230
- */
1231
- function attrsGuardSql(plan) {
1232
- if (plan?.table === void 0 || plan.columns.length === 0) return "";
1233
- const target = quoteLiteral(table(plan.table));
1234
- return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
1235
- do $$
1236
- begin
1237
- if exists (
1238
- select 1
1239
- from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
1240
- cross join (values ('anon'), ('authenticated')) r(role)
1241
- where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
1242
- or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
1243
- ) then
1244
- raise exception 'PermDock: an attrs column of % is writable by anon or authenticated; revoke insert and update on it before it becomes a claim', ${target}
1245
- using errcode = '42501';
1246
- end if;
1247
- end
1248
- $$;`;
1249
- }
1250
- function managedSql(parts) {
1251
- const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
1252
- if (managed.length === 0) return "";
1253
- const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
1254
- return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
1255
- create or replace function ${guard}()
1256
- returns trigger
1257
- language plpgsql
1258
- set search_path = ''
1259
- as $$
1260
- declare
1261
- column_name text := tg_argv[0];
1262
- begin
1263
- if current_user in ('anon', 'authenticated') and (
1264
- column_name = ''
1265
- or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
1266
- or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
1267
- ) then
1268
- raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
1269
- using errcode = '42501';
1270
- end if;
1271
- return coalesce(new, old);
1272
- end;
1273
- $$;
1274
- ${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${table(source.sql.table)};
1275
- create trigger ${quoteIdent(MANAGED_TRIGGER)}
1276
- before insert or update or delete on ${table(source.sql.table)}
1277
- for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
1278
- }
1279
- /** The `config.toml` block that enables the hook and bounds token staleness. */
1280
- function configToml(schema, jwtExpiry) {
1281
- return `[auth]
1282
- jwt_expiry = ${String(jwtExpiry)}
1283
-
1284
- [auth.hook.custom_access_token]
1285
- enabled = true
1286
- uri = "${hookUri(schema)}"`;
1287
- }
1288
- const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
1289
- function hookParts(scopes, config, overrides) {
1290
- const hook = config.supabase?.hook;
1291
- if (hook === void 0) throw new Error("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
1292
- const root = rootScope(scopes) ?? "tenant";
1293
- const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
1294
- const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
1295
- quoteIdent(schema);
1296
- const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
1297
- const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
1298
- checkSources({
1299
- sources: hook.memberships,
1300
- scopes
1301
- });
1302
- const parts = {
1303
- schema,
1304
- scopes,
1305
- root,
1306
- sources: hook.memberships,
1307
- budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
1308
- version: hook.version !== false,
1309
- jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
1310
- tenantClaim,
1311
- users: suspension?.users,
1312
- hook,
1313
- roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r", config.rls?.customRoleWrites?.roles),
1314
- active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
1315
- attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
1316
- extra: extraPlan.claims,
1317
- before: beforePlan(hook.before),
1318
- helpers: {
1319
- schema: config.rls?.schema ?? "permdock",
1320
- ...memberForPlan({
1321
- scopes,
1322
- ...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
1323
- memberSources: config.rls?.membershipSources ?? hook.memberships
1324
- })
1325
- }
1326
- };
1327
- if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw new Error(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
1328
- if (extraPlan.errors.length > 0) throw new Error(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
1329
- quoteIdent(parts.tenantClaim);
1330
- return parts;
1331
- }
1332
- function permdockClaim(name, budget = false) {
1333
- return {
1334
- name,
1335
- source: "permdock",
1336
- budget
1337
- };
1338
- }
1339
- function hookClaims(parts) {
1340
- return [
1341
- permdockClaim("user_role"),
1342
- permdockClaim("roles"),
1343
- permdockClaim("memberships", true),
1344
- permdockClaim("memberships_truncated"),
1345
- permdockClaim(parts.tenantClaim),
1346
- ...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
1347
- ...parts.version ? [permdockClaim("authz_ver")] : [],
1348
- ...parts.extra.map((entry) => ({
1349
- name: entry.claim,
1350
- source: entry.fn,
1351
- budget: false
1352
- }))
1353
- ];
1354
- }
1355
1421
  //#endregion
1356
- //#region src/cli/supabase-hook-manifest.ts
1422
+ //#region src/cli/supabase-hook/manifest.ts
1357
1423
  function helperList(parts, types) {
1358
1424
  const client = ["authenticated"];
1359
1425
  return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
@@ -1380,7 +1446,7 @@ function helperEntry(name, args, returns, execute) {
1380
1446
  */
1381
1447
  function trustedHelpers(parts, config, types, tenantType, policy) {
1382
1448
  const database = resolveAuthorize(config) === "database";
1383
- const custom = database && config.rls?.customRoles === true;
1449
+ const custom = database && customRolesOn(config.rls);
1384
1450
  const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
1385
1451
  const client = ["authenticated"];
1386
1452
  const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
@@ -1410,7 +1476,7 @@ function trustedHelpers(parts, config, types, tenantType, policy) {
1410
1476
  function activeRowManifest(row) {
1411
1477
  const keep = "keep" in row ? keptKeys(row) : [];
1412
1478
  return {
1413
- table: qualifiedTable(row.table),
1479
+ table: qualifySqlTable(row.table),
1414
1480
  id: row.id,
1415
1481
  ...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
1416
1482
  ...row.status === void 0 ? {} : { status: row.status },
@@ -1425,9 +1491,9 @@ function rlsSettings(parts, config) {
1425
1491
  const scopes = Object.entries(suspension?.scopes ?? {});
1426
1492
  const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
1427
1493
  return {
1428
- customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
1494
+ customRoles: resolveAuthorize(config) === "database" && customRolesOn(rls),
1429
1495
  ...parts.roles === void 0 ? {} : { roles: {
1430
- table: qualifiedTable(parts.roles.table),
1496
+ table: qualifySqlTable(parts.roles.table),
1431
1497
  user: { column: parts.roles.user },
1432
1498
  role: parts.roles.role
1433
1499
  } },
@@ -1537,121 +1603,7 @@ function defaultOut(config) {
1537
1603
  return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
1538
1604
  }
1539
1605
  //#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
1606
+ //#region src/cli/supabase-hook/supabase-hook.ts
1655
1607
  const SUPABASE_HELP = `permdock supabase hook generate | inspect
1656
1608
 
1657
1609
  hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
@@ -1745,8 +1697,7 @@ function inspectText(manifest) {
1745
1697
  function manifestFile(cwd, rel, manifest, check) {
1746
1698
  const path = resolve(cwd, rel);
1747
1699
  if (!check) {
1748
- mkdirSync(dirname(path), { recursive: true });
1749
- writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
1700
+ writeFileEnsuringDir(path, `${JSON.stringify(manifest, null, 2)}\n`);
1750
1701
  return {
1751
1702
  code: 0,
1752
1703
  output: `wrote ${rel}`
@@ -1758,14 +1709,14 @@ function manifestFile(cwd, rel, manifest, check) {
1758
1709
  };
1759
1710
  let onDisk;
1760
1711
  try {
1761
- onDisk = JSON.parse(readFileSync(path, "utf8"));
1712
+ onDisk = readJsonFile(path);
1762
1713
  } catch {
1763
1714
  return {
1764
1715
  code: 1,
1765
1716
  output: `supabase manifest drift: ${rel} is not JSON`
1766
1717
  };
1767
1718
  }
1768
- const found = typeof onDisk === "object" && onDisk !== null && !Array.isArray(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
1719
+ const found = isRecord(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
1769
1720
  const changed = [.../* @__PURE__ */ new Set([...Object.keys(manifest), ...Object.keys(found)])].filter((key) => JSON.stringify(Reflect.get(manifest, key)) !== JSON.stringify(found[key]));
1770
1721
  return changed.length === 0 ? {
1771
1722
  code: 0,
@@ -1778,9 +1729,8 @@ function manifestFile(cwd, rel, manifest, check) {
1778
1729
  async function loadScopes(cwd, config) {
1779
1730
  return scopeList((await loadHookPolicy(cwd, config)).scopes);
1780
1731
  }
1781
- async function loadHookPolicy(cwd, config) {
1782
- if (config.policy === void 0) throw new Error("PermDock CLI: supabase hook generate needs policy in permdock.config.ts");
1783
- return asPolicy(pickNamed(await loadModule(resolve(cwd, config.policy)), ["policy"]));
1732
+ function loadHookPolicy(cwd, config) {
1733
+ return requirePolicy(cwd, config.policy, "supabase hook generate needs policy in permdock.config.ts");
1784
1734
  }
1785
1735
  async function runSupabase(input) {
1786
1736
  const [area, action] = input.rest;
@@ -1834,9 +1784,8 @@ async function runSupabase(input) {
1834
1784
  output: `supabase hook drift: ${drift.join("; ")}`
1835
1785
  };
1836
1786
  }
1837
- mkdirSync(dirname(outPath), { recursive: true });
1838
- writeFileSync(outPath, sql);
1839
- const grantsWritten = writeSqlFiles(input.cwd, grantsFile);
1787
+ writeFileEnsuringDir(outPath, sql);
1788
+ const grantsWritten = writeOutputFiles(input.cwd, grantsFile);
1840
1789
  const placed = {
1841
1790
  ...manifest,
1842
1791
  hook: {
@@ -1857,4 +1806,4 @@ async function runSupabase(input) {
1857
1806
  };
1858
1807
  }
1859
1808
  //#endregion
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 };
1809
+ export { supabaseHookManifest as a, rbacScaffold as c, attrsPlan as d, pd045 as f, runSupabase as i, resolveAuthorize as l, hookOut as n, supabaseHookSql as o, supabaseConfig as p, loadScopes as r, parseRbacAuthorize as s, grantsLabel as t, pd039 as u };