permdock 0.3.1 → 0.5.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 (552) hide show
  1. package/README.md +37 -35
  2. package/dist/{openapi-schema--ARjyfQr.js → 2020-C9VN-x8c.js} +5 -132
  3. package/dist/a2a/index.d.ts +8 -6
  4. package/dist/a2a/index.js +21 -31
  5. package/dist/{adapter-context-mlWsVGGg.js → adapter-context-CRhqyzCk.js} +1 -1
  6. package/dist/adoption-scan-B4R5fnMU.js +107 -0
  7. package/dist/agent/index.d.ts +3 -0
  8. package/dist/agent/index.js +1 -0
  9. package/dist/ai-sdk/index.d.ts +18 -23
  10. package/dist/ai-sdk/index.js +38 -21
  11. package/dist/app-data-MuyzVqX8.js +60 -0
  12. package/dist/approval-header-CJACtHkv.js +23 -0
  13. package/dist/approval-prompt-Dg5LeAcT.js +62 -0
  14. package/dist/approval-prompt-Djo_PyZc.d.ts +57 -0
  15. package/dist/approvals/index.d.ts +5 -3
  16. package/dist/approvals/index.js +8 -5
  17. package/dist/approvals-DJ6Yd5z1.js +995 -0
  18. package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
  19. package/dist/arazzo-B6lebmiQ.js +36 -0
  20. package/dist/{arazzo-BAgw9ap6.js → arazzo-BWhNF-k0.js} +24 -17
  21. package/dist/args-IIolxxKA.js +197 -0
  22. package/dist/astro/index.d.ts +43 -0
  23. package/dist/astro/index.js +27 -0
  24. package/dist/audience-Dg8XsRaO.js +55 -0
  25. package/dist/auth-DRLEizbL.js +178 -0
  26. package/dist/authzen/index.d.ts +26 -6
  27. package/dist/authzen/index.js +228 -75
  28. package/dist/automation-BMZPIVUM.d.ts +114 -0
  29. package/dist/automation-DVjOckjV.js +157 -0
  30. package/dist/better-auth/index.d.ts +8 -6
  31. package/dist/better-auth/index.js +11 -23
  32. package/dist/better-supabase/index.d.ts +438 -86
  33. package/dist/better-supabase/index.js +1027 -184
  34. package/dist/bind--9EFVYWF.d.ts +38 -0
  35. package/dist/{bind-CuzGytRf.js → bind-CZzhx-Vj.js} +22 -10
  36. package/dist/body-CH5of8Ne.js +10 -0
  37. package/dist/body-text-QcXok_SI.js +52 -0
  38. package/dist/build-instance-BMnAudHn.js +52 -0
  39. package/dist/{canonical-json-DemiK4Vp.js → canonical-json-BC8_nMTw.js} +6 -6
  40. package/dist/{capability-2kYTMWDv.d.ts → capability-BSslw1QV.d.ts} +44 -25
  41. package/dist/{capability-4qPdO9gk.js → capability-N1K9IpqS.js} +69 -42
  42. package/dist/casbin-Cr7RLtA3.js +30 -0
  43. package/dist/casbin-Dyu2gnUD.js +234 -0
  44. package/dist/casl-64gpT-8x.js +24 -0
  45. package/dist/casl-DjEDhTCP.js +299 -0
  46. package/dist/catalog/index.d.ts +74 -2
  47. package/dist/catalog/index.js +51 -2
  48. package/dist/catalog-BECMPrwc.js +100 -0
  49. package/dist/catalog-DjhMYYCq.js +1 -0
  50. package/dist/catalog-R_A__RsH.js +52 -0
  51. package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-C9wjsfNY.js} +54 -25
  52. package/dist/catalog-fingerprint-CCP0-yQS.js +153 -0
  53. package/dist/chat/index.d.ts +2 -0
  54. package/dist/chat/index.js +224 -0
  55. package/dist/chat-sdk/index.d.ts +87 -0
  56. package/dist/chat-sdk/index.js +197 -0
  57. package/dist/claims-map-7oQ3ue4X.js +13 -0
  58. package/dist/claims-map-Ca-QX8C6.d.ts +32 -0
  59. package/dist/claude-agent/index.d.ts +7 -14
  60. package/dist/claude-agent/index.js +26 -30
  61. package/dist/clerk/index.d.ts +9 -8
  62. package/dist/clerk/index.js +15 -45
  63. package/dist/cli/bin.js +36 -17
  64. package/dist/cli/index.d.ts +20 -6
  65. package/dist/cli/index.js +164 -4
  66. package/dist/cloud/index.d.ts +102 -63
  67. package/dist/cloud/index.js +240 -347
  68. package/dist/cloud-CIoHNevm.js +87 -0
  69. package/dist/codemod-Bmj9dWMH.js +37 -0
  70. package/dist/codemod-Cxvvc70T.js +2084 -0
  71. package/dist/{collect-DSTE3ml9.js → collect-CrZJJnEF.js} +79 -17
  72. package/dist/collect-DloHbDVa.js +55 -0
  73. package/dist/collect-FlXJlhus.js +1 -0
  74. package/dist/command-L8EstFNm.js +6 -0
  75. package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
  76. package/dist/compile/index.d.ts +1 -1
  77. package/dist/compile/index.js +1 -2
  78. package/dist/{compile-Ba4blROR.js → compile-CMqMx_IN.js} +13 -9
  79. package/dist/{compile-BkRyBDBw.d.ts → compile-Ckm2HqBZ.d.ts} +5 -3
  80. package/dist/completion-BqhRBJD8.js +143 -0
  81. package/dist/config-B46rm9sc.js +26 -0
  82. package/dist/{config-BJiM-xAm.js → config-bs_kZeYq.js} +15 -10
  83. package/dist/{config-report-CdCgbRnV.js → config-report-DUgj62Ai.js} +17 -8
  84. package/dist/{connection-CU8MQxRi.js → connection-DjhIJSs2.js} +8 -6
  85. package/dist/constants-DcJABbLW.js +20 -0
  86. package/dist/{context-Bcy85rGi.js → context-bbFHR-0D.js} +16 -5
  87. package/dist/convex/index.d.ts +11 -10
  88. package/dist/convex/index.js +18 -18
  89. package/dist/create-0VZ7Pdiu.d.ts +107 -0
  90. package/dist/create-BDS7wc3y.d.ts +138 -0
  91. package/dist/create-BFfm17UA.d.ts +110 -0
  92. package/dist/create-BWRSXgOF.js +403 -0
  93. package/dist/create-C3b4B5eF.js +130 -0
  94. package/dist/create-DnrmLnLW.js +323 -0
  95. package/dist/{credential-8P81ff4u.js → credential-DM2um3ha.js} +153 -32
  96. package/dist/credentials-DPyHmYz5.js +74 -0
  97. package/dist/deciding-columns-Sq12mEA6.js +123 -0
  98. package/dist/delegation-DaXVdDhM.js +286 -0
  99. package/dist/delegation-input-FTxMK3ji.js +19 -0
  100. package/dist/delivery/index.d.ts +5 -0
  101. package/dist/delivery/index.js +5 -0
  102. package/dist/delivery-DCAgNyCn.js +499 -0
  103. package/dist/delivery-event-xX4obB8F.js +40 -0
  104. package/dist/denied-text-D8jDHgsl.d.ts +19 -0
  105. package/dist/{describe-DMYiCjJV.d.ts → describe-BZUsixiL.d.ts} +11 -2
  106. package/dist/{describe-B_gVZEVi.js → describe-BybMRY7A.js} +10 -5
  107. package/dist/device-BARQ8eYG.js +193 -0
  108. package/dist/diff-CxZo9pTq.js +37 -0
  109. package/dist/{diff-2VaeChzX.js → diff-DCePiN6M.js} +61 -55
  110. package/dist/doctor-CxiwSfqH.js +44 -0
  111. package/dist/doctor-DFjUxf2f.js +2361 -0
  112. package/dist/drizzle/index.d.ts +5 -3
  113. package/dist/drizzle/index.js +6 -14
  114. package/dist/elysia/index.d.ts +16 -13
  115. package/dist/elysia/index.js +14 -13
  116. package/dist/env-DJZrPjRi.js +8 -0
  117. package/dist/errors-BDUwg2qC.js +27 -0
  118. package/dist/{errors-Cz5zn0mW.js → errors-BKy0EfhN.js} +6 -10
  119. package/dist/errors-BWXBMaKK.d.ts +11 -0
  120. package/dist/{errors-DPdqI50v.d.ts → errors-C2PXIv4c.d.ts} +2 -2
  121. package/dist/errors-CWTY6uih.js +143 -0
  122. package/dist/evaluate-_R8QkWaf.js +1732 -0
  123. package/dist/evaluations-16UqqQ6W.d.ts +32 -0
  124. package/dist/{evaluations-tz6D9A2G.js → evaluations-DZ2CbV5k.js} +181 -32
  125. package/dist/eve/index.d.ts +12 -16
  126. package/dist/eve/index.js +23 -29
  127. package/dist/event-time-CsegxXxB.js +23 -0
  128. package/dist/expiry-D4_YKRKD.js +52 -0
  129. package/dist/express/index.d.ts +13 -10
  130. package/dist/express/index.js +14 -12
  131. package/dist/fastify/index.d.ts +26 -13
  132. package/dist/fastify/index.js +17 -36
  133. package/dist/fetch-adapter-g0ypLkID.js +120 -0
  134. package/dist/fetch-adapter-qKJBuRqy.d.ts +31 -0
  135. package/dist/fields-D9tPVUUS.js +203 -0
  136. package/dist/files-C5ZI27eV.js +124 -0
  137. package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
  138. package/dist/{fixtures-ht28Cplr.js → fixtures-B7XrLeY7.js} +12 -12
  139. package/dist/flags-C3DktBDY.js +86 -0
  140. package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-D2lZt4Dd.js} +78 -65
  141. package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
  142. package/dist/grantee-Cn1HyUsI.js +254 -0
  143. package/dist/grantee-conditions-CcXf8hMM.js +271 -0
  144. package/dist/grantee-match-DHArqY82.js +142 -0
  145. package/dist/h3/index.d.ts +37 -0
  146. package/dist/h3/index.js +35 -0
  147. package/dist/headers-BIjfMaoC.js +8 -0
  148. package/dist/headers-CIbDTUTK.d.ts +7 -0
  149. package/dist/{helpers-H_KARDVC.js → helpers-DTcZOzhF.js} +47 -39
  150. package/dist/hono/index.d.ts +12 -9
  151. package/dist/hono/index.js +14 -13
  152. package/dist/{hosted-DJQw46hn.js → hosted-BZIAYvon.js} +64 -15
  153. package/dist/hosted-DBDX0M-P.d.ts +78 -0
  154. package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
  155. package/dist/{http-DPuDaChb.d.ts → http-B_LphE5Y.d.ts} +1 -2
  156. package/dist/http-DXJ37RDA.js +46 -0
  157. package/dist/{http-BIhmLfCc.js → http-ujKm0JXE.js} +3 -26
  158. package/dist/{openapi-import-cmwetbzw.js → import-CNV7I2Dw.js} +30 -26
  159. package/dist/{rls-import-4lLW_pbb.js → import-DMmOG27O.js} +271 -128
  160. package/dist/index-BcHcPyIo.d.ts +110 -0
  161. package/dist/index-CaxrMD1A.d.ts +1 -0
  162. package/dist/index-Chrs5fQ-.d.ts +210 -0
  163. package/dist/index-CxGUswIC.d.ts +431 -0
  164. package/dist/index-D8EovfsP.d.ts +91 -0
  165. package/dist/index-DOVf7RJv.d.ts +900 -0
  166. package/dist/{index-C8z4RjQV.d.ts → index-SGdIQyzc.d.ts} +34 -16
  167. package/dist/index.d.ts +20 -14
  168. package/dist/index.js +33 -23
  169. package/dist/init-DeMYsnYJ.js +96 -0
  170. package/dist/init-GG6Jh8HD.js +95 -0
  171. package/dist/{instance-options-jARdwfPN.d.ts → instance-options-Omfd793B.d.ts} +2 -2
  172. package/dist/instance-snapshot-Cxq4ygFc.js +600 -0
  173. package/dist/instant-BluqDvyS.js +19 -0
  174. package/dist/internals-Dk8A8fX3.js +17 -0
  175. package/dist/{json-schema-BsAflUGW.js → json-schema-B-vAwlde.js} +46 -9
  176. package/dist/json-schema-CN_iLFH2.d.ts +40 -0
  177. package/dist/jwt/index.d.ts +18 -11
  178. package/dist/jwt/index.js +166 -145
  179. package/dist/{kernel-_RRHmnMY.js → kernel-5JatXyND.js} +120 -43
  180. package/dist/kysely/index.d.ts +5 -3
  181. package/dist/kysely/index.js +6 -14
  182. package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
  183. package/dist/load-BxEtwlBS.js +1 -0
  184. package/dist/{load-DhWEB9P1.js → load-DFS_ry0W.js} +31 -7
  185. package/dist/{load-jose-BL6LH4bL.js → load-jose-DZ4dnjri.js} +1 -4
  186. package/dist/login-Dfe5RlYk.js +68 -0
  187. package/dist/login-imhuwvn3.js +254 -0
  188. package/dist/manifest-C8IzW3Do.js +42 -0
  189. package/dist/manifest-CtKR3GXL.js +370 -0
  190. package/dist/manifest-json-CAGzls-C.js +9 -0
  191. package/dist/{manifest-uDS9y5de.d.ts → manifest-mx5ghSFM.d.ts} +9 -2
  192. package/dist/manifest-schema-C0kK_8V0.js +399 -0
  193. package/dist/{map-claims-Z4QZOiFF.js → map-claims-D3oRmTk0.js} +19 -25
  194. package/dist/{map-error-pgUWNNtG.js → map-error-TCAdw-eW.js} +5 -5
  195. package/dist/{markers-B0hYLvMD.js → markers-C6joDI3o.js} +8 -3
  196. package/dist/mcp/index.d.ts +36 -9
  197. package/dist/mcp/index.js +476 -448
  198. package/dist/{rls-migrate-CN4TlJTO.js → migrate-BShP7Vf4.js} +119 -20
  199. package/dist/nest/index.d.ts +12 -9
  200. package/dist/nest/index.js +20 -20
  201. package/dist/next/index.d.ts +40 -20
  202. package/dist/next/index.js +37 -39
  203. package/dist/next/plugin.d.ts +1 -1
  204. package/dist/next/plugin.js +2 -2
  205. package/dist/node/index.d.ts +10 -10
  206. package/dist/node/index.js +7 -9
  207. package/dist/ocsf-BFp-PZZA.js +126 -0
  208. package/dist/openai/index.d.ts +9 -16
  209. package/dist/openai/index.js +18 -25
  210. package/dist/openapi/index.d.ts +3 -3
  211. package/dist/openapi/index.js +1 -1
  212. package/dist/{openapi-BqRBvAng.js → openapi-B7HZ0f-r.js} +9 -12
  213. package/dist/{openapi-CqqEbHsL.js → openapi-DTaEmTSW.js} +23 -26
  214. package/dist/openapi-ahr8rHu3.js +138 -0
  215. package/dist/operation-findings-DgATDpvh.js +36 -0
  216. package/dist/operations-CxybZErY.d.ts +59 -0
  217. package/dist/orpc/index.d.ts +13 -10
  218. package/dist/orpc/index.js +23 -20
  219. package/dist/otel/index.d.ts +3 -2
  220. package/dist/otel/index.js +53 -27
  221. package/dist/output-Dyw8fyPF.js +88 -0
  222. package/dist/output-files-B3IZJksC.js +1 -0
  223. package/dist/output-files-DhRBBE4n.js +138 -0
  224. package/dist/{rls-fields-CZOMlwHl.js → ownership-BatFgAA4.js} +2393 -2349
  225. package/dist/{package-root-zGkxO3oN.js → package-root-Dk_ETdkp.js} +4 -3
  226. package/dist/{parse-BuyjxhZV.js → parse-Cxq0WxrA.js} +3 -3
  227. package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-CVSs6J9I.js} +2 -4
  228. package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DgqtruPn.d.ts} +1 -1
  229. package/dist/parse-request-D_tUXG6M.js +26 -0
  230. package/dist/paths-DXSHAMIa.js +42 -0
  231. package/dist/pdp/index.d.ts +7 -3
  232. package/dist/pdp/index.js +47 -29
  233. package/dist/{peer-BOr_kJE1.js → peer-C8swJ2GH.js} +23 -4
  234. package/dist/peer-CRvC7Hg1.js +1 -0
  235. package/dist/permdock-B9JxkBwC.js +868 -0
  236. package/dist/permdock-D7eeLJER.d.ts +502 -0
  237. package/dist/permission-registry-Br4jSNVi.js +385 -0
  238. package/dist/permissions-B-wCP5ZC.js +30 -0
  239. package/dist/{permissions-RJrovw98.js → permissions-CZKiUfFM.js} +4 -3
  240. package/dist/{permissions-CDgvKG5L.d.ts → permissions-oEWfl-kl.d.ts} +1 -2
  241. package/dist/policies-CO1oOymN.js +295 -0
  242. package/dist/{policy-D2W7uv19.d.ts → policy-Czo4n3Hs.d.ts} +2 -2
  243. package/dist/policy-DLZJ5gAp.js +399 -0
  244. package/dist/policy-from-catalog-DzhEc3Y9.js +450 -0
  245. package/dist/policy-module-CTlwMvLI.js +89 -0
  246. package/dist/{policy-IB6yxpC3.d.ts → policy-types-DzwivBgR.d.ts} +939 -1057
  247. package/dist/postgrest-CW0IqC-U.js +566 -0
  248. package/dist/postgrest-NUqZT-RB.d.ts +100 -0
  249. package/dist/powersync-BHfuehf3.js +62 -0
  250. package/dist/powersync-CbzkqL_M.js +1 -0
  251. package/dist/{powersync-Dv1_aWF7.js → powersync-bYUDLOAO.js} +30 -32
  252. package/dist/preset/index.d.ts +146 -0
  253. package/dist/preset/index.js +1 -0
  254. package/dist/preset-DZdU0tne.js +1768 -0
  255. package/dist/principal-source-pHneukrx.js +103 -0
  256. package/dist/prisma/index.d.ts +6 -3
  257. package/dist/prisma/index.js +6 -12
  258. package/dist/{problem-B43ztKIm.js → problem-CTviyH5L.js} +18 -27
  259. package/dist/{problem-details-DX075l9V.d.ts → problem-details-DXlt-vks.d.ts} +1 -1
  260. package/dist/{problem-details-_AMOYz-w.js → problem-details-cn5X30gZ.js} +45 -42
  261. package/dist/problem-types-CJ2325SZ.js +5 -0
  262. package/dist/{prompt-CXYhLfsS.js → prompt-DfZLJLIn.js} +2 -2
  263. package/dist/protected-DEPHc2xV.d.ts +38 -0
  264. package/dist/{protected-utU-SLuy.js → protected-DfjY5mLA.js} +37 -8
  265. package/dist/{provider-CSQd7m92.js → provider-CC-u3bYP.js} +23 -10
  266. package/dist/{provider-DZY281jO.d.ts → provider-DMVt6yYn.d.ts} +1 -1
  267. package/dist/{provider-store-Cfbvo4DE.js → provider-store-B5Yrkhzr.js} +6 -3
  268. package/dist/pull-BgIfno6w.js +256 -0
  269. package/dist/pull-ByOks_Tg.js +1 -0
  270. package/dist/push-CmFVBOeI.js +116 -0
  271. package/dist/pushed-policy-BImMTTIM.d.ts +57 -0
  272. package/dist/pushed-policy-DobP7AHI.js +238 -0
  273. package/dist/react/index.d.ts +8 -8
  274. package/dist/react/index.js +6 -6
  275. package/dist/react/provider-client.d.ts +1 -1
  276. package/dist/react/provider-client.js +1 -2
  277. package/dist/react-native/index.d.ts +24 -63
  278. package/dist/react-native/index.js +45 -27
  279. package/dist/{refs-KYb4ntaz.js → refs-K6eWUJ0H.js} +1 -1
  280. package/dist/relation-walk-3OyDBF0S.js +186 -0
  281. package/dist/relations-BnCWnJGI.js +163 -0
  282. package/dist/renames-CGj7YwcQ.js +175 -0
  283. package/dist/{replay-CYyiLiwO.d.ts → replay-D7iKcLFe.d.ts} +1 -1
  284. package/dist/{replay-BsOyTrdV.js → replay-Du1tJjG7.js} +2 -1
  285. package/dist/request-context-BTRTG7OT.js +71 -0
  286. package/dist/request-context-C2_1lchh.d.ts +5 -0
  287. package/dist/request-schema-CMreVuFM.js +148 -0
  288. package/dist/resend/index.d.ts +126 -0
  289. package/dist/resend/index.js +379 -0
  290. package/dist/resource-relations-D_jw0BFx.js +243 -0
  291. package/dist/retire-CALjIQ7e.js +240 -0
  292. package/dist/{revocations-Cy06JNx2.d.ts → revocations-R07lFBnX.d.ts} +2 -0
  293. package/dist/rls-BA8an5Ap.js +336 -0
  294. package/dist/{rls-DiinlRML.js → rls-j6t43t7n.js} +1066 -1068
  295. package/dist/role-builder-CXbis5-X.js +623 -0
  296. package/dist/{rpc-DL9Sqvb1.js → rpc-aPyNi6tA.js} +18 -8
  297. package/dist/run-CNsApDpd.js +1 -0
  298. package/dist/run-DOfMtc9V.js +723 -0
  299. package/dist/run-ticket-BjhTOi6u.d.ts +124 -0
  300. package/dist/run-ticket-sxFI6LAk.js +337 -0
  301. package/dist/{runtime-CB8gQFvS.js → runtime-D3VTAiCA.js} +1 -1
  302. package/dist/{saas-DhpJjnBA.js → saas-FO-Q8oj0.js} +18 -15
  303. package/dist/scan-BOI5tJ-6.js +29 -0
  304. package/dist/{schema-D3MnDMxa.js → schema-B4v0Qrac.js} +106 -10
  305. package/dist/schema-yLuBWWsh.js +130 -0
  306. package/dist/scim/index.d.ts +2 -2
  307. package/dist/scim/index.js +1 -2
  308. package/dist/{scim-BFGBBAqR.js → scim-ClHtpyV3.js} +213 -87
  309. package/dist/{scopes-CVdPrWN-.js → scopes-CUPSjo9q.js} +20 -7
  310. package/dist/server/index.d.ts +13 -134
  311. package/dist/server/index.js +13 -10
  312. package/dist/server-DqCT4CgZ.js +453 -0
  313. package/dist/{create-B1d519XK.d.ts → server-types-BLd4MnBX.d.ts} +89 -70
  314. package/dist/{sha256-BO0swRXL.js → sha256-D9ihqmvd.js} +8 -1
  315. package/dist/shared-CD11nBbC.js +304 -0
  316. package/dist/{shared-DRu8VFVM.js → shared-DvvFzYXe.js} +8 -9
  317. package/dist/signals-D3NcTKUS.js +32 -0
  318. package/dist/sink-BEZSlMS_.js +957 -0
  319. package/dist/sink-Bj_lDUDq.d.ts +225 -0
  320. package/dist/{skills-Biq5uup3.js → skills-B6lVdD_W.js} +16 -8
  321. package/dist/skills-CTYwkl44.js +51 -0
  322. package/dist/snapshot-CXUWfsNy.js +269 -0
  323. package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-DZHQ0KF6.js} +11 -5
  324. package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-DqK9eDmM.d.ts} +7 -5
  325. package/dist/solid/index.d.ts +24 -25
  326. package/dist/solid/index.js +41 -11
  327. package/dist/solid-start/index.d.ts +52 -0
  328. package/dist/solid-start/index.js +49 -0
  329. package/dist/{sources-BdOYLw9j.d.ts → sources-BV9j0DeZ.d.ts} +11 -13
  330. package/dist/{sources-4p0-4xQm.js → sources-vs5dGOsS.js} +147 -142
  331. package/dist/sql-B5T1IL83.js +55 -0
  332. package/dist/src-DLfUWFAI.js +1851 -0
  333. package/dist/ssf/index.d.ts +4 -4
  334. package/dist/ssf/index.js +440 -106
  335. package/dist/standard-webhooks-6lg8v_is.d.ts +88 -0
  336. package/dist/standard-webhooks-DgFFD-Ga.js +228 -0
  337. package/dist/storage-C2bWdvJH.js +132 -0
  338. package/dist/{store-DMAWwAJV.js → store-4u_hxjWa.js} +42 -55
  339. package/dist/{store-options-ByhZq1jV.js → store-options-BqEakYRa.js} +38 -24
  340. package/dist/{stores-BT2pSCub.js → stores-BbsD4fAR.js} +7 -3
  341. package/dist/stores-ClZUgeoP.d.ts +30 -0
  342. package/dist/{stream-D1RXubw2.d.ts → stream-7-xmnf3U.d.ts} +2 -2
  343. package/dist/{stream-DDpvynSB.js → stream-CvdUS9V1.js} +10 -2
  344. package/dist/style-DiKWnAvn.js +1 -0
  345. package/dist/{style-CTm1AXDT.js → style-Uao76wO7.js} +3 -2
  346. package/dist/subject-BGoM5o8B.js +17 -0
  347. package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
  348. package/dist/supabase/index.d.ts +23 -106
  349. package/dist/supabase/index.js +5 -4
  350. package/dist/supabase/middleware.d.ts +13 -6
  351. package/dist/supabase/middleware.js +16 -11
  352. package/dist/supabase-9pqI57pW.js +92 -0
  353. package/dist/{supabase-C0qESOa-.js → supabase-Dxlp8A94.js} +124 -247
  354. package/dist/supabase-hook-CXcGpHqq.js +1 -0
  355. package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-DdDQhMlw.js} +1659 -819
  356. package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
  357. package/dist/support-session-C6xJQYut.js +158 -0
  358. package/dist/svelte/Protected.svelte +11 -4
  359. package/dist/svelte/index.d.ts +7 -7
  360. package/dist/svelte/index.js +26 -12
  361. package/dist/svelte/runtime.d.ts +10 -3
  362. package/dist/svelte/runtime.js +4 -3
  363. package/dist/svelte/source.d.ts +7 -7
  364. package/dist/svelte/source.js +5 -5
  365. package/dist/sveltekit/index.d.ts +51 -0
  366. package/dist/sveltekit/index.js +27 -0
  367. package/dist/tanstack-start/index.d.ts +51 -0
  368. package/dist/tanstack-start/index.js +40 -0
  369. package/dist/target-Bw37TFNA.js +51 -0
  370. package/dist/{validity-uOp8UpYn.js → tenancy-DN2QAx9z.js} +6 -42
  371. package/dist/terminal/index.d.ts +11 -9
  372. package/dist/terminal/index.js +36 -316
  373. package/dist/testing/index.d.ts +391 -106
  374. package/dist/testing/index.js +4574 -1286
  375. package/dist/testing/saas/index.d.ts +9 -8
  376. package/dist/testing/saas/index.js +2 -2
  377. package/dist/testing/saas/permissions.d.ts +1 -1
  378. package/dist/testing/saas/permissions.js +1 -2
  379. package/dist/text-BMFRfBqc.js +8 -0
  380. package/dist/{text-diff-BdkkYSbi.js → text-diff-BQcUapOb.js} +1 -1
  381. package/dist/thenable-yD7SabqG.js +31 -0
  382. package/dist/time-DkWjdL98.js +71 -0
  383. package/dist/{token-CIWxJOCh.js → token-BLZcjQgt.js} +15 -6
  384. package/dist/tool-refusal-CMLN5xhn.js +41 -0
  385. package/dist/trpc/index.d.ts +12 -9
  386. package/dist/trpc/index.js +18 -17
  387. package/dist/{types-DbjRN55U.d.ts → types-5Ut_pbKy.d.ts} +141 -13
  388. package/dist/types-BNORmDM8.d.ts +172 -0
  389. package/dist/types-BjV-fqcA.d.ts +57 -0
  390. package/dist/{types-QCuYJh4q.d.ts → types-BqAKcRNW.d.ts} +2 -1
  391. package/dist/types-C8-KNsFf.d.ts +107 -0
  392. package/dist/{types-aqOHTHcp.d.ts → types-CCpc2Dcv.d.ts} +77 -17
  393. package/dist/{types-Dnemwd77.d.ts → types-CZTnPFzM.d.ts} +50 -11
  394. package/dist/{types-C97IskBw.d.ts → types-CdlxsYQh.d.ts} +23 -11
  395. package/dist/{types-DxiCGmmd.d.ts → types-ClZaWzfb.d.ts} +9 -6
  396. package/dist/types-DX0LaKTb.d.ts +157 -0
  397. package/dist/types-DiQjupG2.d.ts +332 -0
  398. package/dist/types-DqzsnlSA.js +67 -0
  399. package/dist/types-_sHy8BQf.d.ts +65 -0
  400. package/dist/types-fbZ7JEXr.d.ts +89 -0
  401. package/dist/types-tIgIeIKJ.d.ts +66 -0
  402. package/dist/unplugin/index.d.ts +1 -1
  403. package/dist/unplugin/index.js +2 -2
  404. package/dist/usage-CHOtwL19.js +1 -0
  405. package/dist/usage-DHtE8j4E.js +32 -0
  406. package/dist/{usage-ItS7nLcl.js → usage-T5mXq98v.js} +56 -13
  407. package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-Bg5Tpluk.d.ts} +1 -1
  408. package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
  409. package/dist/validity-CZ7-y0Cj.js +79 -0
  410. package/dist/{verifier-BxBo6I44.js → verifier-DHuh-okk.js} +68 -21
  411. package/dist/verify-DbCL9bnB.js +1770 -0
  412. package/dist/{version-BvacVTZR.js → version-CEbQ5UeM.js} +4 -3
  413. package/dist/version-DXkYUISx.js +1 -0
  414. package/dist/{views-Soe7bGSC.js → views-DTIIO-oQ.js} +41 -2
  415. package/dist/{permissions-DttnqqxC.d.ts → vocabulary-D6lPdb4Z.d.ts} +120 -29
  416. package/dist/vue/index.d.ts +25 -21
  417. package/dist/vue/index.js +25 -9
  418. package/dist/waiter-BGUtGYoU.js +67 -0
  419. package/dist/waiter-CKy4MQSY.d.ts +36 -0
  420. package/dist/watch-Cb-NcaUw.js +1 -0
  421. package/dist/{watch-DVBJ9cBq.js → watch-CszoTJMq.js} +47 -8
  422. package/dist/webmcp/index.d.ts +2 -2
  423. package/dist/webmcp/index.js +13 -37
  424. package/dist/where-scope-Dv-WTP-w.js +306 -0
  425. package/dist/who-can-D0QoYbEb.js +364 -0
  426. package/dist/wire-delegation-Cv3W2eaM.js +36 -0
  427. package/dist/wire-denial-C7PaVYEM.js +27 -0
  428. package/dist/workflow/index.d.ts +5 -0
  429. package/dist/workflow/index.js +223 -0
  430. package/dist/workflow-sdk/index.d.ts +56 -0
  431. package/dist/workflow-sdk/index.js +78 -0
  432. package/dist/wrap-Bd1Tejur.d.ts +15 -0
  433. package/dist/wrap-gX61EtpB.js +27 -0
  434. package/package.json +140 -41
  435. package/schemas/access-event-v1.json +118 -0
  436. package/schemas/approval-request-v1.json +34 -1
  437. package/schemas/approval-signature-v1.json +94 -0
  438. package/schemas/automation-event-v1.json +113 -0
  439. package/schemas/capability-v1.json +84 -0
  440. package/schemas/catalog-event-v1.json +87 -0
  441. package/schemas/catalog-v1.json +129 -12
  442. package/schemas/cli-commands-v1.json +164 -0
  443. package/schemas/cli-result-v1.json +96 -0
  444. package/schemas/cloud-api-v1.json +5407 -0
  445. package/schemas/connector-v1.json +408 -0
  446. package/schemas/credential-event-v1.json +81 -0
  447. package/schemas/credential-v1.json +134 -0
  448. package/schemas/decision-event-v1.json +288 -0
  449. package/schemas/delivery-event-v1.json +117 -0
  450. package/schemas/directory-event-v1.json +85 -0
  451. package/schemas/evidence-export-v1.json +54 -0
  452. package/schemas/membership-event-v1.json +93 -0
  453. package/schemas/policy-v1.json +68 -0
  454. package/schemas/pushed-policy-v1.json +236 -0
  455. package/schemas/run-v1.json +162 -0
  456. package/schemas/snapshot-v1.json +138 -0
  457. package/schemas/supabase-claims-v1.json +4 -4
  458. package/schemas/supabase-manifest-v1.json +5 -1
  459. package/schemas/support-session-v1.json +111 -0
  460. package/skills/permdock/SKILL.md +16 -12
  461. package/skills/permdock-agents/SKILL.md +40 -23
  462. package/skills/permdock-agents/references/operations-automations-chat.md +221 -0
  463. package/skills/permdock-agents/references/runtimes.md +22 -18
  464. package/skills/permdock-approvals/SKILL.md +29 -13
  465. package/skills/permdock-approvals/references/delivery-and-waiters.md +172 -0
  466. package/skills/permdock-approvals/references/stores-and-resume.md +21 -7
  467. package/skills/permdock-audit/SKILL.md +3 -3
  468. package/skills/permdock-credentials/SKILL.md +5 -5
  469. package/skills/permdock-credentials/references/tokens-keys-links.md +19 -10
  470. package/skills/permdock-data/SKILL.md +6 -6
  471. package/skills/permdock-data/references/queries-and-rls.md +13 -8
  472. package/skills/permdock-data/references/relationships.md +3 -3
  473. package/skills/permdock-tenancy/SKILL.md +20 -7
  474. package/skills/permdock-tenancy/references/contacts-and-support-sessions.md +122 -0
  475. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +6 -4
  476. package/skills/permdock-wire/SKILL.md +18 -12
  477. package/skills/permdock-wire/references/adapters.md +146 -50
  478. package/skills/permdock-wire/references/policy-options.md +15 -6
  479. package/skills/permdock-wire/references/presets-and-recipes.md +58 -0
  480. package/dist/approvals-DbYAuNkG.js +0 -224
  481. package/dist/arazzo-C9chp3ST.js +0 -54
  482. package/dist/bind-C_ob3LKa.d.ts +0 -25
  483. package/dist/catalog-CRW5Mx_B.js +0 -2
  484. package/dist/catalog-CYj-mX2O.js +0 -48
  485. package/dist/catalog-DWF5cYqB.js +0 -55
  486. package/dist/cloud-CfwZ39Rl.js +0 -50
  487. package/dist/cloud-DWLXO2af.js +0 -118
  488. package/dist/collect-CHB22Sx4.js +0 -83
  489. package/dist/collect-DSWOk19R.js +0 -2
  490. package/dist/commands-BeaGNj_o.js +0 -97
  491. package/dist/config-ir40y8m0.js +0 -36
  492. package/dist/create-CMwIl-w2.js +0 -253
  493. package/dist/delegation-D8pUEuce.js +0 -103
  494. package/dist/diff-C9RDjjhs.js +0 -48
  495. package/dist/doctor-Djh6JDEk.js +0 -53
  496. package/dist/doctor-DvH0IQff.js +0 -1194
  497. package/dist/doctor-collect-CjdO0wQX.js +0 -665
  498. package/dist/duration-xK2jGFVc.js +0 -25
  499. package/dist/errors-CWfoNg9N.js +0 -47
  500. package/dist/errors-DRnGZHzF.js +0 -30
  501. package/dist/evaluation-items-CfCCejQd.js +0 -40
  502. package/dist/expiry-BoBJ8Tvc.js +0 -7
  503. package/dist/fields-BMl1RxoX.js +0 -26
  504. package/dist/grantee-Cv5Sogfv.js +0 -602
  505. package/dist/headers-BZSDieNT.js +0 -6
  506. package/dist/headers-Cf-zWvsO.d.ts +0 -6
  507. package/dist/ids-DC4V4CFu.js +0 -18
  508. package/dist/index-0nNqHspG.d.ts +0 -6
  509. package/dist/index-BNc7Wer_.d.ts +0 -483
  510. package/dist/index-C7Tw7MaW.d.ts +0 -201
  511. package/dist/instance-options-Dhhcnm3y.js +0 -22
  512. package/dist/load-DwJUafYN.js +0 -2
  513. package/dist/manifest-iaoBXHKc.js +0 -783
  514. package/dist/map-AcxK9o_D.js +0 -147
  515. package/dist/may-use-DndQ5tQ-.js +0 -77
  516. package/dist/openapi-CQTsgKoB.js +0 -164
  517. package/dist/permdock-CbgvLnuo.js +0 -3479
  518. package/dist/permissions-DVoF6dMt.js +0 -662
  519. package/dist/policy-xyWg7QdR.js +0 -1122
  520. package/dist/powersync-CrBX8hY6.js +0 -2
  521. package/dist/powersync-DE19b9dT.js +0 -62
  522. package/dist/protected-BdnIEFkY.d.ts +0 -27
  523. package/dist/protected-xAr24FEC.d.ts +0 -40
  524. package/dist/relations-cjOL01hZ.js +0 -505
  525. package/dist/rls-P-BeBx0n.js +0 -238
  526. package/dist/rls-graph-CHwx0V8q.js +0 -435
  527. package/dist/rls-ownership-DcYlZ09B.js +0 -843
  528. package/dist/rls-shared-Cewd_-zI.js +0 -495
  529. package/dist/rls-verify-CMv9H3wV.js +0 -746
  530. package/dist/run-BWc9qqvE.js +0 -174
  531. package/dist/server-DlZ28Oz5.js +0 -268
  532. package/dist/sink-B1fZzIMf.d.ts +0 -104
  533. package/dist/sink-Bd9PSyfq.js +0 -141
  534. package/dist/skills-C5CbhhpG.js +0 -56
  535. package/dist/snapshot-Enxd0n-s.js +0 -975
  536. package/dist/sql-xdg5f4Fi.js +0 -27
  537. package/dist/src-BDwEZWrZ.js +0 -568
  538. package/dist/store-jOBYpiXC.d.ts +0 -36
  539. package/dist/stores-C0IMQrGI.d.ts +0 -52
  540. package/dist/subject-C6dLxqHU.js +0 -24
  541. package/dist/supabase-config-Bj3iDH2I.js +0 -472
  542. package/dist/supabase-hook-gd3s8bxB.js +0 -2
  543. package/dist/supabase-s4o9s1O1.js +0 -83
  544. package/dist/thenable-okc2xDsj.js +0 -10
  545. package/dist/types-BnIEz1sU.d.ts +0 -48
  546. package/dist/types-Bvc4oWT6.d.ts +0 -9
  547. package/dist/types-CKNSyIf_.d.ts +0 -52
  548. package/dist/types-Cq_H8N_X.d.ts +0 -197
  549. package/dist/usage-B0YuTQRO.js +0 -42
  550. package/dist/usage-DorAJiU_.js +0 -2
  551. package/dist/vocabulary-DA8a8zif.d.ts +0 -56
  552. package/dist/watch-BwxLTYMZ.js +0 -2
@@ -1,129 +1,90 @@
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-K6eWUJ0H.js";
3
+ import { c as rootScope, d as scopeList, l as scopeChain, o as resolveScope } from "./scopes-CUPSjo9q.js";
4
+ import { t as APPROVAL_REQUEST_SCHEMA } from "./request-schema-CMreVuFM.js";
5
+ import { a as describeError, i as configError } from "./errors-CWTY6uih.js";
6
+ import { i as readJsonFile, o as sqlFiles, s as writeFileEnsuringDir } from "./files-D_JIzUrP.js";
7
+ import { a as SUPABASE_MANIFEST_SCHEMA } from "./version-CEbQ5UeM.js";
8
+ import { c as requirePolicy } from "./load-DFS_ry0W.js";
9
+ import { r as scopeColumn } from "./compile-CMqMx_IN.js";
10
+ import { t as authorizeSql } from "./supabase-Dxlp8A94.js";
9
11
  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);
12
+ import { i as qualifySqlTable } from "./sql-B5T1IL83.js";
13
+ import { a as hookApiNames, d as keptKeys, t as AUTHZ_VERSION_TABLE, u as roleManifest } from "./sources-vs5dGOsS.js";
14
+ import { o as readMemberships } from "./postgrest-CW0IqC-U.js";
15
+ import { t as beforeFirst } from "./text-BMFRfBqc.js";
16
+ import { n as GRANTS_MARKER, r as HOOK_MARKER, s as hookMarkerFields } from "./markers-C6joDI3o.js";
17
+ import { n as formatManifestJson, t as MANIFEST_FILE } from "./manifest-json-CAGzls-C.js";
18
+ import { n as MIGRATION_DIRS } from "./constants-DcJABbLW.js";
19
+ import { f as writeOutputFiles, l as partPath, r as driftOf, u as pgDeltaPath } from "./output-files-DhRBBE4n.js";
20
+ import { E as scopeTypeOf, F as quoteQualified, I as quoteTable, M as qualified, N as quoteIdent, O as tenantTypeOf, P as quoteLiteral, a as customRolesOn, l as USER_ID_HELPER, r as HELPERS, y as subjectIdSql } from "./shared-CD11nBbC.js";
21
+ import { a as splitUndiffed, r as tableKey, t as decidingColumns } from "./deciding-columns-Sq12mEA6.js";
22
+ import { a as readOnlyActorKinds, f as pgConnector, t as ACTOR_MAY_WRITE } from "./policies-CO1oOymN.js";
23
+ import { $ as checkSuspension, A as roleNames, C as API_KEY_ALLOWS, G as memberRoleOf, H as memberForSources, K as permittedForHelper, R as hasMemberFor, T as apiKeyFields, U as memberForTable, V as memberForHelper, W as memberIdsHelper, X as scopeTable, Z as activeRowSql, a as guardedTables, b as globalRoleSource, c as PERMISSION_HELPERS, d as permissionKeysForHelper, f as permittedByPermissionHelper, l as checkName, o as ASSIGNMENTS, q as permittedIdsHelper, r as holderSources, s as OWNERSHIP, t as ownershipRules, y as globalRoleRows, z as hasPermittedUsers } from "./ownership-BatFgAA4.js";
24
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
25
+ import { dirname, join, posix, resolve } from "node:path";
26
+ import { parse } from "smol-toml";
27
+ //#region src/cli/supabase-config.ts
28
+ const SUPABASE_CONFIG = "supabase/config.toml";
29
+ function table(value) {
30
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
31
+ return value;
32
+ }
33
+ function strings(value) {
34
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
35
+ }
36
+ /** `supabase/config.toml`, parsed; `undefined` when the project has none. */
37
+ function readSupabaseConfig(cwd) {
38
+ const file = join(cwd, SUPABASE_CONFIG);
39
+ if (!existsSync(file)) return;
40
+ let root;
41
+ try {
42
+ root = parse(readFileSync(file, "utf8"));
43
+ } catch (error) {
44
+ return {
45
+ ok: false,
46
+ error: describeError(error)
47
+ };
46
48
  }
49
+ const expiry = table(root["auth"])?.["jwt_expiry"];
50
+ const paths = table(table(root["db"])?.["migrations"])?.["schema_paths"];
51
+ const schemaPaths = strings(paths);
52
+ const apiSchemas = strings(table(root["api"])?.["schemas"]);
53
+ const pgDelta = table(table(root["experimental"])?.["pgdelta"]);
54
+ const schemaPath = pgDelta?.["declarative_schema_path"];
55
+ const enabled = pgDelta?.["enabled"];
47
56
  return {
48
- wrote,
49
- printed: printed.join("\n")
57
+ ok: true,
58
+ config: {
59
+ ...typeof expiry === "number" ? { jwtExpiry: expiry } : {},
60
+ ...schemaPaths === void 0 ? {} : { schemaPaths },
61
+ ...apiSchemas === void 0 ? {} : { apiSchemas },
62
+ ...pgDelta === void 0 ? {} : { pgDelta: {
63
+ ...typeof enabled === "boolean" ? { enabled } : {},
64
+ schemaDir: declarativeSchemaDir(schemaPath)
65
+ } }
66
+ }
50
67
  };
51
68
  }
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
- };
69
+ /** `supabase/` joined with `declarative_schema_path`, which defaults to `schemas`. */
70
+ function declarativeSchemaDir(schemaPath) {
71
+ return posix.join("supabase", typeof schemaPath === "string" ? schemaPath : "schemas").replace(/\/$/u, "");
72
+ }
73
+ /** The parsed config, or `{}` when it is missing or does not parse (PD045 reports that). */
74
+ function supabaseConfig(cwd) {
75
+ const read = readSupabaseConfig(cwd);
76
+ return read?.ok === true ? read.config : {};
77
+ }
78
+ /** PD045: a `supabase/config.toml` the CLI cannot parse, so every check that reads it falls back to defaults. */
79
+ function pd045(cwd) {
80
+ const read = readSupabaseConfig(cwd);
81
+ if (read === void 0 || read.ok) return [];
82
+ return [{
83
+ code: "PD045",
84
+ severity: "warning",
85
+ message: `${SUPABASE_CONFIG} does not parse: ${read.error.replace(/\n[\s\S]*/u, "")}`,
86
+ fix: `fix the TOML syntax in ${SUPABASE_CONFIG}; until then PermDock assumes Supabase's defaults for jwt_expiry, schema_paths and [experimental.pgdelta], and generates for pg-delta`
87
+ }];
127
88
  }
128
89
  //#endregion
129
90
  //#region src/cli/claims-schema.ts
@@ -206,11 +167,11 @@ const PERMDOCK_CLAIMS_SCHEMA = {
206
167
  items: { type: "string" }
207
168
  },
208
169
  via: { type: "string" },
209
- expiresAt: {
210
- description: "Seconds since the epoch.",
170
+ expires_at: {
171
+ description: "NumericDate (RFC 7519): seconds since the epoch.",
211
172
  type: "number"
212
173
  },
213
- grantedBy: { type: "string" },
174
+ granted_by: { type: "string" },
214
175
  reason: { type: "string" },
215
176
  member: {
216
177
  description: "The subgroup a membership source's group fills.",
@@ -221,7 +182,7 @@ const PERMDOCK_CLAIMS_SCHEMA = {
221
182
  minLength: 1
222
183
  } }
223
184
  },
224
- managedBy: { const: "idp" },
185
+ managed_by: { const: "idp" },
225
186
  entitlements: {
226
187
  type: "array",
227
188
  items: { type: "string" }
@@ -249,92 +210,7 @@ const PERMDOCK_CLAIMS_SCHEMA = {
249
210
  }
250
211
  };
251
212
  //#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";
213
+ //#region src/cli/supabase-hook/claims.ts
338
214
  const CLAIM_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
339
215
  const PROTOTYPE_KEYS = /* @__PURE__ */ new Set([
340
216
  "__proto__",
@@ -444,7 +320,7 @@ function extraClaimsPlan(claims, tenantClaim) {
444
320
  function beforePlan(before) {
445
321
  const list = before === void 0 ? [] : typeof before === "string" ? [before] : before;
446
322
  const bad = list.filter((fn) => typeof fn !== "string" || !CLAIM_FUNCTION.test(fn));
447
- if (bad.length > 0) throw new Error(`PermDock CLI: supabase.hook.before must name schema-qualified functions such as auth_checks.before_token (got ${bad.map(String).join(", ")})`);
323
+ if (bad.length > 0) throw configError(`PermDock CLI: supabase.hook.before must name schema-qualified functions such as auth_checks.before_token (got ${bad.map(String).join(", ")})`);
448
324
  return [...new Set(list)];
449
325
  }
450
326
  function beforeSql(before) {
@@ -485,128 +361,1432 @@ create extension if not exists pg_jsonschema with schema extensions;
485
361
  grant usage on schema extensions to supabase_auth_admin;
486
362
  grant execute on function extensions.jsonb_matches_schema(json, jsonb) to supabase_auth_admin;` : "";
487
363
  }
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
- }
364
+ /**
365
+ * Fails the migration when a client role can write an `attrs` column: a
366
+ * claim the user can set is not a server-owned attribute.
367
+ */
368
+ function attrsGuardSql(plan) {
369
+ if (plan?.table === void 0 || plan.columns.length === 0) return "";
370
+ const target = quoteLiteral(quoteQualified(plan.table));
371
+ return `-- attrs must be server-owned: refuse columns anon or authenticated can insert or update
372
+ do $$
373
+ begin
374
+ if exists (
375
+ select 1
376
+ from (values ${plan.columns.map((column) => `(${quoteLiteral(column)})`).join(", ")}) c(name)
377
+ cross join (values ('anon'), ('authenticated')) r(role)
378
+ where has_column_privilege(r.role, ${target}, c.name, 'INSERT')
379
+ or has_column_privilege(r.role, ${target}, c.name, 'UPDATE')
380
+ ) then
381
+ 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}
382
+ using errcode = '42501';
383
+ end if;
384
+ end
385
+ $$;`;
386
+ }
387
+ function permdockClaim(name, budget = false) {
501
388
  return {
502
- memberFor,
503
- memberships: [...entries.values()]
389
+ name,
390
+ source: "permdock",
391
+ budget
504
392
  };
505
393
  }
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]
394
+ function hookClaims(parts) {
395
+ return [
396
+ permdockClaim("user_role"),
397
+ permdockClaim("roles"),
398
+ permdockClaim("memberships", true),
399
+ permdockClaim("memberships_truncated"),
400
+ permdockClaim(parts.tenantClaim),
401
+ ...parts.attrs === void 0 ? [] : [permdockClaim("attrs", true)],
402
+ ...parts.version ? [permdockClaim("authz_ver")] : [],
403
+ ...parts.extra.map((entry) => ({
404
+ name: entry.claim,
405
+ source: entry.fn,
406
+ budget: false
407
+ }))
523
408
  ];
524
- 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)]
535
- };
536
409
  }
537
- function table(name) {
538
- return quoteTable(name.includes(".") ? name : `public.${name}`);
410
+ //#endregion
411
+ //#region src/cli/supabase-schemas.ts
412
+ const MODES = [
413
+ "pg-delta",
414
+ "migra",
415
+ "migrations"
416
+ ];
417
+ function isSupabaseSchemas(value) {
418
+ return MODES.some((mode) => mode === value);
539
419
  }
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;
420
+ /** The `--schemas` refusal, or `undefined` when the value is one of the modes. */
421
+ function schemasRefusal(command, value) {
422
+ return value === void 0 || isSupabaseSchemas(value) ? void 0 : `${command} --schemas must be ${MODES.join(", ")} (got ${value})`;
544
423
  }
545
424
  /**
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`.
425
+ * `--schemas`, else `supabase.schemas`, else `migra` when `config.toml`
426
+ * sets `[experimental.pgdelta] enabled = false`, else `pg-delta`: the default
427
+ * of new Supabase projects and of the next Supabase CLI major.
548
428
  */
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)}` };
555
- }
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)
429
+ function resolveSchemas(cwd, config, flag) {
430
+ const toml = supabaseConfig(cwd).pgDelta;
431
+ const mode = flag ?? config.supabase?.schemas ?? (toml?.enabled === false ? "migra" : "pg-delta");
432
+ switch (mode) {
433
+ case "pg-delta": return {
434
+ mode,
435
+ schemaDir: toml?.schemaDir ?? declarativeSchemaDir()
562
436
  };
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
- };
437
+ case "migra":
438
+ case "migrations": return { mode };
439
+ default: return mode;
440
+ }
569
441
  }
570
- /**
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.
574
- */
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("");
442
+ //#endregion
443
+ //#region src/cli/supabase-setup.ts
444
+ const HELPER_NAME = /^(?:permdock_has|permitted_[a-z][a-z0-9_]*_ids|member_[a-z][a-z0-9_]*_ids(?:_for)?)$/u;
445
+ function escape(text) {
446
+ return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
582
447
  }
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");
585
- for (const source of parts.sources) {
586
- 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
- const scope = source.sql.scope;
588
- if (scope === void 0) continue;
589
- if (resolveScope(parts.scopes, scope) !== scope) throw new Error(`PermDock CLI: the ${source.sql.table} source names scope '${scope}', which the policy does not declare by that name`);
590
- const chain = scopeChain(parts.scopes, scope);
591
- const holds = new Set(source.sql.holds ?? [scope]);
592
- const missing = chain.filter((ancestor) => !holds.has(ancestor));
593
- if (missing.length > 0) throw new Error(`PermDock CLI: the ${source.sql.table} source needs within columns for ${missing.join(", ")}: a ${scope} membership without every ancestor grants nothing`);
448
+ /** The helper functions `sql` creates in `schema`; unqualified names count for `public`. */
449
+ function helpersDefined(sql, schema) {
450
+ const qualifier = schema === "public" ? String.raw`(?:(?:"public"|public)\.)?` : String.raw`(?:"${escape(schema)}"|${escape(schema)})\.`;
451
+ const create = new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?function\s+${qualifier}"?([a-z_][a-z0-9_]*)"?\s*\(`, "giu");
452
+ const names = /* @__PURE__ */ new Set();
453
+ for (const match of sql.matchAll(create)) {
454
+ const name = (match[1] ?? "").toLowerCase();
455
+ if (HELPER_NAME.test(name)) names.add(name);
594
456
  }
457
+ return names;
595
458
  }
596
- function entriesSql(parts) {
597
- const tenant = `case when s.scope = ${quoteLiteral(parts.root)} then s.id else s.within ->> ${quoteLiteral(parts.root)} end`;
598
- return parts.sources.map((source, index) => `select ${String(index)} as ord,
599
- (${tenant}) as tenant,
600
- jsonb_strip_nulls(jsonb_build_object(
601
- 'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
602
- 'expiresAt', s.expires_at, 'grantedBy', s.granted_by, 'reason', s.reason,
603
- 'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
604
- 'managedBy', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
605
- )) as entry
606
- from (
607
- ${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
608
- ) s`).join("\nunion all\n");
459
+ /**
460
+ * SQL files that may hold the generated helpers: `rls.out` (else `rls.sql`;
461
+ * its `helpers` part when it has `{part}`), the SQL files next to the hook,
462
+ * and the migration folders, not the hook itself.
463
+ */
464
+ function helperSources(cwd, config, hookOut) {
465
+ const hook = resolve(cwd, hookOut);
466
+ const files = new Set(sqlFiles(cwd, config.doctor?.migrations ?? MIGRATION_DIRS));
467
+ const rlsOut = resolve(cwd, partPath(config.rls?.out ?? "rls.sql", "helpers"));
468
+ if (rlsOut.endsWith(".sql") && existsSync(rlsOut)) files.add(rlsOut);
469
+ const beside = dirname(hook);
470
+ if (existsSync(beside)) {
471
+ for (const entry of readdirSync(beside, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".sql")) files.add(join(beside, entry.name));
472
+ }
473
+ files.delete(hook);
474
+ return [...files];
475
+ }
476
+ function missingHelpersInFiles(cwd, config, manifest) {
477
+ const found = /* @__PURE__ */ new Set();
478
+ for (const file of helperSources(cwd, config, manifest.hook.out)) for (const name of helpersDefined(readFileSync(file, "utf8"), manifest.helpers.schema)) found.add(name);
479
+ return manifest.helpers.functions.filter((name) => !found.has(name));
480
+ }
481
+ async function missingHelpersInDb(db, manifest, connect = pgConnector("permdock supabase hook generate --db-url-stdin")) {
482
+ const client = await connect(db);
483
+ try {
484
+ const result = await client.query(`select p.proname from pg_proc p join pg_namespace n on n.oid = p.pronamespace
485
+ where n.nspname = $1 and p.proname = any($2::text[])`, [manifest.helpers.schema, [...manifest.helpers.functions]]);
486
+ const found = new Set(result.rows.map((row) => row["proname"]));
487
+ return manifest.helpers.functions.filter((name) => !found.has(name));
488
+ } finally {
489
+ await client.end();
490
+ }
491
+ }
492
+ function missingHelpersMessage(manifest, missing) {
493
+ 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`;
494
+ }
495
+ /** Bytes of JSON of each extra claim in the largest sample, for claims that pass the budget. */
496
+ function oversizedClaims(samples, manifest) {
497
+ const encoder = new TextEncoder();
498
+ const oversized = [];
499
+ for (const claim of manifest.claims) {
500
+ if (claim.source === "permdock") continue;
501
+ let bytes = 0;
502
+ for (const sample of samples) {
503
+ if (sample === null || typeof sample !== "object") continue;
504
+ const value = Object.hasOwn(sample, claim.name) ? sample[claim.name] : void 0;
505
+ if (value !== void 0) bytes = Math.max(bytes, encoder.encode(JSON.stringify(value)).byteLength);
506
+ }
507
+ if (bytes > manifest.budget.bytes) oversized.push({
508
+ name: claim.name,
509
+ bytes
510
+ });
511
+ }
512
+ return oversized;
513
+ }
514
+ /** `memberships[i]` entries `subjectFromSupabase` drops, per sample index. */
515
+ function droppedMemberships(samples) {
516
+ const out = [];
517
+ for (const [index, sample] of samples.entries()) {
518
+ if (sample === null || typeof sample !== "object") continue;
519
+ const claim = Object.hasOwn(sample, "memberships") ? sample["memberships"] : void 0;
520
+ const { dropped } = readMemberships(claim);
521
+ if (dropped.length > 0) out.push({
522
+ sample: index,
523
+ entries: dropped
524
+ });
525
+ }
526
+ return out;
527
+ }
528
+ function pd039(input) {
529
+ const findings = [];
530
+ const missing = missingHelpersInFiles(input.cwd, input.config, input.manifest);
531
+ if (missing.length > 0) findings.push({
532
+ code: "PD039",
533
+ severity: "warning",
534
+ message: missingHelpersMessage(input.manifest, missing).slice(6),
535
+ 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"
536
+ });
537
+ const fixture = input.config.doctor?.claims;
538
+ if (fixture !== void 0) {
539
+ const parsed = readClaimsFixture(resolve(input.cwd, fixture));
540
+ if (!parsed.ok) {
541
+ findings.push({
542
+ code: "PD039",
543
+ severity: "warning",
544
+ message: `doctor.claims ${fixture} is not a JSON file doctor can read (${parsed.message}), so the claim budget went unchecked`,
545
+ fix: "point doctor.claims at a JSON array of sample claims"
546
+ });
547
+ return findings;
548
+ }
549
+ const samples = Array.isArray(parsed.value) ? parsed.value : [];
550
+ for (const claim of oversizedClaims(samples, input.manifest)) findings.push({
551
+ code: "PD039",
552
+ severity: "warning",
553
+ message: `claim ${claim.name} is ${String(claim.bytes)} bytes of JSON in ${fixture}, more than the ${String(input.manifest.budget.bytes)}-byte memberships budget`,
554
+ fix: "return less from the claim function, or read the data per request instead of from the token"
555
+ });
556
+ for (const { sample, entries } of droppedMemberships(samples)) findings.push({
557
+ code: "PD039",
558
+ severity: "warning",
559
+ message: `sample ${String(sample)} in ${fixture} has memberships ${entries.map((entry) => `[${String(entry)}]`).join(", ")} that subjectFromSupabase drops (membership-dropped)`,
560
+ fix: "emit { scope, id, roles } (or tenant, team or on) with a non-empty roles array for each entry"
561
+ });
562
+ }
563
+ return findings;
564
+ }
565
+ function readClaimsFixture(path) {
566
+ try {
567
+ return {
568
+ ok: true,
569
+ value: JSON.parse(readFileSync(path, "utf8"))
570
+ };
571
+ } catch (error) {
572
+ return {
573
+ ok: false,
574
+ message: error instanceof Error ? error.message : String(error)
575
+ };
576
+ }
577
+ }
578
+ //#endregion
579
+ //#region src/cli/rls/rbac.ts
580
+ /**
581
+ * Where the helpers read roles and memberships: the flag, `rls.authorize`,
582
+ * `rls.rbac.authorize`, then `database` with `rls.membershipSources`, with
583
+ * `--rbac` or with a mapped memberships table, else `jwt`.
584
+ */
585
+ function resolveAuthorize(config, flags = {}) {
586
+ const rls = config.rls;
587
+ const explicit = flags.authorize ?? rls?.authorize ?? rls?.rbac?.authorize;
588
+ if (explicit !== void 0) return explicit;
589
+ if (rls?.membershipSources !== void 0 || flags.rbac === true) return "database";
590
+ const memberships = flags.memberships ?? rls?.memberships;
591
+ return memberships?.tenant !== void 0 || memberships?.team !== void 0 || Object.keys(memberships?.scopes ?? {}).length > 0 ? "database" : "jwt";
592
+ }
593
+ function parseRbacAuthorize(raw) {
594
+ if (raw === void 0) return;
595
+ if (raw === "database" || raw === "jwt") return raw;
596
+ throw configError(`PermDock CLI: --authorize must be database or jwt, got '${raw}'`);
597
+ }
598
+ function hookUri(schema) {
599
+ return `pg-functions://postgres/${schema}/custom_access_token_hook`;
600
+ }
601
+ function createEnum(schema, name, values) {
602
+ const list = values.map((value) => quoteLiteral(value)).join(", ");
603
+ return `do $$ begin
604
+ create type ${quoteTable(`${schema}.${name}`)} as enum (${list});
605
+ exception when duplicate_object then null;
606
+ end $$;`;
607
+ }
608
+ /** A read policy and `select` grant for `supabase_auth_admin` on a table the hook reads. */
609
+ function authAdminRead(table, label) {
610
+ const t = quoteQualified(table);
611
+ const policy = quoteIdent(`permdock_auth_admin_read_${label}`);
612
+ return `grant select on table ${t} to supabase_auth_admin;
613
+ drop policy if exists ${policy} on ${t};
614
+ create policy ${policy} on ${t}
615
+ as permissive for select
616
+ to supabase_auth_admin
617
+ using (true);`;
618
+ }
619
+ /**
620
+ * Supabase's Custom Claims and RBAC scaffold on top of the PermDock helpers:
621
+ * enums, `user_roles` and `authorize()` over the shared `role_permissions`.
622
+ * The token hook comes only from `permdock supabase hook generate`, so one
623
+ * function writes the claims. Policies never call `authorize()` per row; they
624
+ * call the helpers. Never grants anything to `service_role`.
625
+ */
626
+ function rbacScaffold(policy, options) {
627
+ const schema = options.schema;
628
+ const q = (name) => quoteTable(`${schema}.${name}`);
629
+ const s = quoteIdent(schema);
630
+ const permissions = [...new Set(policy.grants.map((grant) => grant.permission.key))];
631
+ const authorizeFn = authorizeSql({
632
+ schema,
633
+ authorize: options.authorize,
634
+ ...options.scope === void 0 ? {} : { scope: options.scope },
635
+ ...options.memberships === void 0 ? {} : { tenant: options.memberships },
636
+ ...options.customRoles === void 0 ? {} : { customRoles: options.customRoles },
637
+ ...options.context?.suspension === void 0 ? {} : { suspension: options.context.suspension }
638
+ });
639
+ return {
640
+ head: `-- rbac scaffold (Supabase Custom Claims and RBAC)
641
+ -- authorize: ${options.authorize}${options.authorize === "jwt" ? " (reads the hook claims; stale until the token refreshes)" : " (reads user_roles on every statement)"}
642
+ -- the custom access token hook that writes user_role${options.authorize === "jwt" ? " and memberships" : ""}: permdock supabase hook generate
643
+ ${schema === "public" ? "" : `create schema if not exists ${s};\n`}${createEnum(schema, "app_role", roleNames(policy))}
644
+ ${createEnum(schema, "app_permission", permissions)}
645
+
646
+ create table if not exists ${q("user_roles")} (
647
+ user_id uuid not null references auth.users on delete cascade,
648
+ role ${options.customRoles === void 0 ? q("app_role") : "text"} not null,
649
+ primary key (user_id, role)
650
+ );
651
+
652
+ alter table ${q("user_roles")} enable row level security;
653
+ revoke all on table ${q("user_roles")} from authenticated, anon, public;
654
+ `,
655
+ tail: authorizeFn,
656
+ warnings: []
657
+ };
658
+ }
659
+ //#endregion
660
+ //#region src/cli/supabase-hook/memberships.ts
661
+ function memberForPlan(input) {
662
+ const memberFor = input.scopes.map((scope) => scope.name).filter((name) => hasMemberFor({
663
+ dialect: "supabase",
664
+ ...input
665
+ }, name));
666
+ const holders = memberFor.filter((name) => hasPermittedUsers(input, name));
667
+ const entries = /* @__PURE__ */ new Map();
668
+ for (const name of memberFor) {
669
+ const mapped = memberForTable(input, name);
670
+ const found = mapped === void 0 ? memberForSources(input, name).map((source) => source.sql.manifest) : [mappedMembership(mapped, input.scopes, name)];
671
+ for (const entry of found) entries.set(JSON.stringify(entry), entry);
672
+ }
673
+ return {
674
+ memberFor,
675
+ holders,
676
+ memberships: [...entries.values()]
677
+ };
678
+ }
679
+ /** An `rls.memberships` table in the manifest's `tableMembershipSource` / `junctionMembershipSource` shape, for scope `name`. */
680
+ function mappedMembership(mapped, scopes, name) {
681
+ const id = scopeColumn(mapped, scopes, name) ?? "";
682
+ const within = {};
683
+ for (const ancestor of scopeChain(scopes, name).slice(1)) {
684
+ const column = scopeColumn(mapped, scopes, ancestor);
685
+ if (column !== void 0) within[ancestor] = column;
686
+ }
687
+ const role = memberRoleOf(mapped);
688
+ const columns = [
689
+ mapped.user,
690
+ id,
691
+ ...role.columns,
692
+ ...Object.values(within),
693
+ ...typeof mapped.via === "string" ? [mapped.via] : [],
694
+ ...mapped.expiresAt === void 0 ? [] : [mapped.expiresAt],
695
+ ...mapped.disabledAt === void 0 ? [] : [mapped.disabledAt]
696
+ ];
697
+ return {
698
+ table: qualifySqlTable(mapped.table),
699
+ user: { column: mapped.user },
700
+ scope: { value: name },
701
+ id: { column: id },
702
+ role: roleManifest(role),
703
+ ...Object.keys(within).length === 0 ? {} : { within: { columns: within } },
704
+ ...mapped.via === void 0 ? {} : { via: typeof mapped.via === "string" ? { column: mapped.via } : { value: mapped.via.value } },
705
+ ...mapped.expiresAt === void 0 ? {} : { expiresAt: { column: mapped.expiresAt } },
706
+ ...mapped.disabledAt === void 0 ? {} : { disabledAt: { column: mapped.disabledAt } },
707
+ columns: [...new Set(columns)]
708
+ };
709
+ }
710
+ function positiveInteger(value, label) {
711
+ const number = typeof value === "string" ? Number(value) : value;
712
+ if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) throw configError(`PermDock CLI: ${label} must be a positive integer`);
713
+ return number;
714
+ }
715
+ /**
716
+ * SQL for the active first-scope id, as text, for user `uid`; a table source
717
+ * compares its id column with `v_active_user`, declared as `userType`.
718
+ */
719
+ function activeFromSql(input, root) {
720
+ const spec = input ?? `app_metadata.active_${root}`;
721
+ if (typeof spec === "string" && spec.startsWith("app_metadata.")) {
722
+ const key = spec.slice(13);
723
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw configError(`PermDock CLI: unsafe app_metadata key '${key}'`);
724
+ return { sql: `claims -> 'app_metadata' ->> ${quoteLiteral(key)}` };
725
+ }
726
+ const parsed = typeof spec === "string" ? (() => {
727
+ const dot = spec.lastIndexOf(".");
728
+ if (dot <= 0) throw configError(`PermDock CLI: --active-from must be app_metadata.<key> or <table>.<column>, got '${spec}'`);
729
+ return {
730
+ table: spec.slice(0, dot),
731
+ column: spec.slice(dot + 1)
732
+ };
733
+ })() : spec;
734
+ const id = "id" in parsed && parsed.id !== void 0 ? parsed.id : "id";
735
+ return {
736
+ sql: `(select a.${quoteIdent(parsed.column)}::text from ${quoteQualified(parsed.table)} a where a.${quoteIdent(id)} = v_active_user)`,
737
+ userType: `${quoteQualified(parsed.table)}.${quoteIdent(id)}%type`
738
+ };
739
+ }
740
+ /**
741
+ * One variable per user-keyed table the hook reads, holding `uid` (a uuid, as
742
+ * Supabase Auth issues) as that column's type, so each lookup compares the
743
+ * column uncast and its index applies.
744
+ */
745
+ function typedUsers(parts) {
746
+ return [
747
+ ...parts.sources.map((source, index) => `v_user_${String(index)} ${source.sql.userType} := uid;`),
748
+ ...parts.roles === void 0 ? [] : [`v_roles_user ${quoteQualified(parts.roles.table)}.${quoteIdent(parts.roles.user)}%type := uid;`],
749
+ ...parts.attrs?.table === void 0 || parts.attrs.columns.length === 0 ? [] : [`v_attrs_user ${quoteQualified(parts.attrs.table)}.${quoteIdent(parts.attrs.id)}%type := uid;`],
750
+ ...parts.active.userType === void 0 ? [] : [`v_active_user ${parts.active.userType} := uid;`]
751
+ ].map((line) => `\n ${line}`).join("");
752
+ }
753
+ function checkSources(parts) {
754
+ if (parts.sources.length === 0) throw configError("PermDock CLI: supabase.hook.memberships needs at least one tableMembershipSource or junctionMembershipSource source");
755
+ for (const source of parts.sources) {
756
+ if (source.sql === void 0 || typeof source.sql.select !== "function") throw configError("PermDock CLI: supabase.hook.memberships takes tableMembershipSource / junctionMembershipSource sources from permdock/supabase");
757
+ const scope = source.sql.scope;
758
+ if (scope === void 0) continue;
759
+ if (resolveScope(parts.scopes, scope) !== scope) throw configError(`PermDock CLI: the ${source.sql.table} source names scope '${scope}', which the policy does not declare by that name`);
760
+ const chain = scopeChain(parts.scopes, scope);
761
+ const holds = new Set(source.sql.holds ?? [scope]);
762
+ const missing = chain.filter((ancestor) => !holds.has(ancestor));
763
+ if (missing.length > 0) throw configError(`PermDock CLI: the ${source.sql.table} source needs within columns for ${missing.join(", ")}: a ${scope} membership without every ancestor grants nothing`);
764
+ }
765
+ }
766
+ function entriesSql(parts) {
767
+ const tenant = `case when s.scope = ${quoteLiteral(parts.root)} then s.id else s.within ->> ${quoteLiteral(parts.root)} end`;
768
+ return parts.sources.map((source, index) => `select ${String(index)} as ord,
769
+ (${tenant}) as tenant,
770
+ jsonb_strip_nulls(jsonb_build_object(
771
+ 'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
772
+ 'expires_at', s.expires_at, 'granted_by', s.granted_by, 'reason', s.reason,
773
+ 'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
774
+ 'managed_by', s.managed_by, 'entitlements', s.seats${source.sql.keeps ? ", 'keep', s.keep" : ""}
775
+ )) as entry
776
+ from (
777
+ ${source.sql.select(`v_user_${String(index)}`).replaceAll(/^/gmu, " ")}
778
+ ) s`).join("\nunion all\n");
779
+ }
780
+ //#endregion
781
+ //#region src/cli/supabase-hook/api.ts
782
+ const SUBJECT_FOR = "subject_for";
783
+ /**
784
+ * `subject_for(p_user uuid) returns jsonb`: what the hook would put in a
785
+ * token for `p_user`, from the same membership, role, suspension and version
786
+ * reads, plus the custom roles the user holds when `database` mode keeps them
787
+ * in tables. A backend that only has PostgREST (`postgrestSources`) builds a
788
+ * subject for an acting user or a job from it. Naming the user is why no
789
+ * client role may execute it.
790
+ */
791
+ function subjectForSql(parts, config) {
792
+ const schema = quoteIdent(parts.schema);
793
+ const fn = `${schema}.${SUBJECT_FOR}`;
794
+ const rows = parts.roles;
795
+ const roles = rows === void 0 ? ` held := '[]'::jsonb;` : ` select coalesce(jsonb_agg(distinct ${rows.roleSql} order by ${rows.roleSql}), '[]'::jsonb)
796
+ into held
797
+ from ${rows.from}
798
+ where ${rows.userSql} = v_roles_user;`;
799
+ const suspended = parts.users === void 0 ? "" : `
800
+ if not ${activeRowSql(parts.users, "uid")} then
801
+ return jsonb_build_object('id', uid::text, 'active', false);
802
+ end if;`;
803
+ const version = parts.version ? `
804
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
805
+ ver := coalesce(ver, 0);` : "";
806
+ const custom = customRolesOn(config.rls) && resolveAuthorize(config) === "database" ? customRolesOfSql(parts) : "";
807
+ return `-- the subject the hook would mint for p_user, for a backend acting for a stored user; no client role may execute it
808
+ create or replace function ${fn}(p_user uuid)
809
+ returns jsonb
810
+ language plpgsql
811
+ stable
812
+ security definer
813
+ set search_path = ''
814
+ as $$
815
+ declare
816
+ uid uuid := p_user;
817
+ held jsonb;
818
+ memberships jsonb;
819
+ custom jsonb := '[]'::jsonb;
820
+ ver bigint;${typedUsers(parts)}
821
+ begin
822
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
823
+ return null;
824
+ end if;${suspended}
825
+ ${roles}
826
+ select coalesce(jsonb_agg(x.entry order by x.ord, x.entry ->> 'scope', x.entry ->> 'id', x.entry::text), '[]'::jsonb)
827
+ into memberships
828
+ from (
829
+ ${entriesSql(parts).replaceAll(/^/gmu, " ")}
830
+ ) x;${custom}${version}
831
+ return jsonb_strip_nulls(jsonb_build_object(
832
+ 'id', uid::text,
833
+ 'active', true,
834
+ 'roles', held,
835
+ 'memberships', memberships,
836
+ 'customRoles', custom,
837
+ 'authzVersion', ver
838
+ ));
839
+ end;
840
+ $$;
841
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
842
+ }
843
+ /**
844
+ * The PostgREST wrappers `supabase.hook.api` asks for: one `security definer`
845
+ * function per reader, in the API schema, executable by `service_role` only.
846
+ */
847
+ function apiWrappersSql(parts) {
848
+ const api = parts.hook.api;
849
+ if (api === void 0) return "";
850
+ const target = api.schema ?? "public";
851
+ const names = hookApiNames(api.prefix);
852
+ const source = quoteIdent(parts.schema);
853
+ const wrapper = (name, inner, params, args, types, returns) => {
854
+ const fn = `${quoteIdent(target)}.${quoteIdent(name)}`;
855
+ if (target === parts.schema && name === inner) throw configError(`PermDock CLI: supabase.hook.api would replace ${source}.${inner}: set api.schema or api.prefix`);
856
+ return `create or replace function ${fn}(${params})
857
+ returns ${returns}
858
+ language sql
859
+ stable
860
+ security definer
861
+ set search_path = ''
862
+ as $$ select ${source}.${inner}(${args}) $$;
863
+ revoke execute on function ${fn}(${types}) from public, anon, authenticated;
864
+ grant execute on function ${fn}(${types}) to service_role;`;
865
+ };
866
+ 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
867
+ ${wrapper(names.subjectFor, SUBJECT_FOR, "p_user uuid", "p_user", "uuid", "jsonb")}
868
+
869
+ ${wrapper(names.membersOf, MEMBERS_OF, "p_scope text, p_id text", "p_scope, p_id", "text, text", "jsonb")}
870
+
871
+ ${wrapper(names.authzVersionFor, AUTHZ_VERSION_FOR, "p_user uuid", "p_user", "uuid", "bigint")}`;
872
+ }
873
+ const AUTHZ_VERSION_FOR = "authz_version_for";
874
+ /**
875
+ * `authz_version_for(p_user uuid) returns bigint`: the authorization version
876
+ * `subject_for` reports, without reading roles or memberships, so a
877
+ * `claimsFirst` reread can check a token's freshness on every request and read
878
+ * the full subject only when it is behind. `null` for an unknown or suspended
879
+ * user, and when the version is off.
880
+ */
881
+ function authzVersionForSql(parts) {
882
+ const schema = quoteIdent(parts.schema);
883
+ const fn = `${schema}.${AUTHZ_VERSION_FOR}`;
884
+ return `-- the authorization version subject_for reports for p_user, for a cheap freshness check; no client role may execute it
885
+ create or replace function ${fn}(p_user uuid)
886
+ returns bigint
887
+ language plpgsql
888
+ stable
889
+ security definer
890
+ set search_path = ''
891
+ as $$
892
+ declare
893
+ uid uuid := p_user;
894
+ ver bigint;
895
+ begin
896
+ if uid is null or not exists (select 1 from auth.users u where u.id = uid) then
897
+ return null;
898
+ end if;${parts.users === void 0 ? "" : `
899
+ if not ${activeRowSql(parts.users, "uid")} then
900
+ return null;
901
+ end if;`}${parts.version ? `
902
+ select v.version into ver from ${schema}.${quoteIdent(AUTHZ_VERSION_TABLE)} v where v.user_id = uid;
903
+ ver := coalesce(ver, 0);` : ""}
904
+ return ver;
905
+ end;
906
+ $$;
907
+ revoke execute on function ${fn}(uuid) from public, anon, authenticated;`;
908
+ }
909
+ const MEMBERS_OF = "members_of";
910
+ function membersOfSql(parts) {
911
+ const fn = `${quoteIdent(parts.schema)}.${MEMBERS_OF}`;
912
+ const keep = parts.sources.some((source) => source.sql.keeps);
913
+ const rows = parts.sources.map((source) => source.sql.list(keep).replaceAll(/^/gmu, " ")).join("\n union all\n");
914
+ return `-- the live memberships of one scope instance, for a backend that lists members over PostgREST; no client role may execute it
915
+ create or replace function ${fn}(p_scope text, p_id text)
916
+ returns jsonb
917
+ language sql
918
+ stable
919
+ security definer
920
+ set search_path = ''
921
+ as $$
922
+ select coalesce(jsonb_agg(jsonb_build_object(
923
+ 'principal', jsonb_build_object('id', s.user_id),
924
+ 'membership', jsonb_strip_nulls(jsonb_build_object(
925
+ 'scope', s.scope, 'id', s.id, 'within', s.within, 'roles', s.roles, 'via', s.via,
926
+ 'expires_at', s.expires_at, 'granted_by', s.granted_by, 'reason', s.reason,
927
+ 'member', case when s.member_group is not null then jsonb_build_object('group', s.member_group) end,
928
+ 'managed_by', s.managed_by, 'entitlements', s.seats${keep ? ", 'keep', s.keep" : ""}
929
+ ))
930
+ ) order by s.user_id, s.roles::text), '[]'::jsonb)
931
+ from (
932
+ ${rows}
933
+ ) s
934
+ $$;
935
+ revoke execute on function ${fn}(text, text) from public, anon, authenticated;`;
936
+ }
937
+ /**
938
+ * The custom roles `p_user` holds: tenant roles named on a membership of the
939
+ * scope (and instance) they live at, and platform roles among the global
940
+ * roles, each with its grants and includes in `CustomRole` form.
941
+ */
942
+ function customRolesOfSql(parts) {
943
+ const helpers = quoteIdent(parts.helpers.schema);
944
+ const perms = `${helpers}.custom_role_permissions`;
945
+ const includes = `${helpers}.custom_role_includes`;
946
+ const root = quoteLiteral(parts.root);
947
+ 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`;
948
+ return `
949
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
950
+ 'name', c.role,
951
+ 'tenant', c.tenant_id::text,
952
+ 'scope', c.scope,
953
+ 'id', c.scope_id,
954
+ 'grants', (
955
+ select coalesce(jsonb_agg(jsonb_strip_nulls(jsonb_build_object(
956
+ 'permission', p.permission,
957
+ 'effect', p.effect,
958
+ 'level', to_jsonb(p) ->> 'level'
959
+ )) order by p.permission, p.effect), '[]'::jsonb)
960
+ from ${perms} p
961
+ where ${match}
962
+ ),
963
+ 'includes', (
964
+ select jsonb_agg(p.include_role order by p.include_role)
965
+ from ${includes} p
966
+ where ${match}
967
+ )
968
+ )) order by c.tenant_id::text, c.scope, c.scope_id, c.role), '[]'::jsonb)
969
+ into custom
970
+ from (
971
+ select tenant_id, scope, scope_id, role from ${perms}
972
+ union
973
+ select tenant_id, scope, scope_id, role from ${includes}
974
+ ) c
975
+ where (c.scope = 'global' and c.tenant_id is null and held ? c.role)
976
+ or exists (
977
+ select 1
978
+ from jsonb_array_elements(memberships) m
979
+ where m -> 'roles' ? c.role
980
+ and m ->> 'scope' = c.scope
981
+ and c.tenant_id::text = case when m ->> 'scope' = ${root} then m ->> 'id' else m -> 'within' ->> ${root} end
982
+ and (c.scope_id is null or c.scope_id = m ->> 'id')
983
+ );`;
984
+ }
985
+ //#endregion
986
+ //#region src/cli/rls/approvals.ts
987
+ /** `rls.approvals`' approver check; better-supabase's `isApprover` template calls it. */
988
+ const IS_APPROVER = "permdock_is_approver";
989
+ /** The objects `rls.approvals` adds. Names are part of the SQL contract `supabaseApprovalStore` calls. */
990
+ const APPROVAL_STORE = {
991
+ table: "approval_requests",
992
+ open: "permdock_approval_open",
993
+ get: "permdock_approval_get",
994
+ resolve: "permdock_approval_resolve",
995
+ consume: "permdock_approval_consume",
996
+ list: "permdock_approval_list",
997
+ expire: "permdock_approval_expire",
998
+ cancel: "permdock_approval_cancel",
999
+ isApprover: IS_APPROVER
1000
+ };
1001
+ /** `body` as the row's filter columns hold it. */
1002
+ const COLUMNS = `p_request ->> 'token',
1003
+ p_request,
1004
+ p_request ->> 'status',
1005
+ p_request #>> '{subject,principal,tenant}',
1006
+ p_request #>> '{subject,principal,id}',
1007
+ p_request #>> '{subject,actor,id}',
1008
+ p_request #>> '{subject,session}',
1009
+ coalesce(jsonb_array_length(p_request -> 'approvals'), 0),
1010
+ p_request ->> 'createdAt',
1011
+ (p_request ->> 'expiresAt')::timestamptz,
1012
+ (p_request ->> 'consumedAt')::timestamptz`;
1013
+ /** The `ApprovalListFilter` in `p_filter`, over the row aliased `a`. */
1014
+ const FILTER = `(p_filter ->> 'status' is null or a.status = p_filter ->> 'status')
1015
+ and (p_filter ->> 'tenant' is null or a.tenant = p_filter ->> 'tenant')
1016
+ and (p_filter ->> 'principalId' is null or a.principal_id = p_filter ->> 'principalId')
1017
+ and (p_filter ->> 'actorId' is null or a.actor_id = p_filter ->> 'actorId')
1018
+ and (p_filter ->> 'session' is null or a.session = p_filter ->> 'session')`;
1019
+ function serverOnly(fn, args) {
1020
+ return `revoke execute on function ${fn}(${args}) from public, anon, authenticated;`;
1021
+ }
1022
+ const BODY_CHECK = `${APPROVAL_STORE.table}_body_schema`;
1023
+ /**
1024
+ * `rls.jsonSchema`: a pg_jsonschema check that `body` matches
1025
+ * `approval-request-v1.json`, added `not valid` and then validated. `auto`
1026
+ * adds it only where the extension is available.
1027
+ */
1028
+ function bodySchemaSql(table, mode, body = "body", check = BODY_CHECK) {
1029
+ const statements = [
1030
+ "create schema if not exists extensions;",
1031
+ "create extension if not exists pg_jsonschema with schema extensions;",
1032
+ `alter table ${table} drop constraint if exists "${check}";`,
1033
+ `alter table ${table} add constraint "${check}" check (extensions.jsonb_matches_schema(${quoteLiteral(JSON.stringify(APPROVAL_REQUEST_SCHEMA))}::json, ${body})) not valid;`,
1034
+ `alter table ${table} validate constraint "${check}";`
1035
+ ];
1036
+ const comment = "-- approval store: body matches schemas/approval-request-v1.json through pg_jsonschema";
1037
+ if (mode === true) return `\n${comment}\n${statements.join("\n")}\n`;
1038
+ return `\n${comment}, where the extension is available
1039
+ do $permdock$
1040
+ begin
1041
+ if exists (select 1 from pg_available_extensions where name = 'pg_jsonschema') then
1042
+ ${statements.map((statement) => ` ${statement}`).join("\n")}
1043
+ end if;
1044
+ end
1045
+ $permdock$;\n`;
1046
+ }
1047
+ /**
1048
+ * The approval store (`rls.approvals`): one table of `ApprovalRequest`
1049
+ * bodies with the columns a list filters on, and one `security definer`
1050
+ * function per `ApprovalStore` method, each one atomic statement. No client
1051
+ * role may read the table or execute the functions: approvals are opened and
1052
+ * resolved by the server, which grants its own role.
1053
+ */
1054
+ function approvalStoreSql(ctx, jsonSchema = false) {
1055
+ const table = qualified(ctx, APPROVAL_STORE.table);
1056
+ const fn = (name) => qualified(ctx, name);
1057
+ const index = (name) => `"${APPROVAL_STORE.table}_${name}"`;
1058
+ return `-- approval store: ApprovalRequest bodies, opened, resolved and consumed through the functions below; supabaseApprovalStore calls them
1059
+ create table if not exists ${table} (
1060
+ token text primary key,
1061
+ body jsonb not null,
1062
+ status text not null check (status in ('pending', 'approved', 'rejected', 'expired')),
1063
+ tenant text,
1064
+ principal_id text,
1065
+ actor_id text,
1066
+ session text,
1067
+ approvals integer not null default 0,
1068
+ created_at text not null,
1069
+ expires_at timestamptz not null,
1070
+ consumed_at timestamptz
1071
+ );
1072
+ create index if not exists ${index("page")} on ${table} (created_at collate "C", token collate "C");
1073
+ create index if not exists ${index("pending")} on ${table} (status, expires_at);
1074
+ alter table ${table} enable row level security;
1075
+ revoke all on table ${table} from anon, authenticated, public;
1076
+ ${jsonSchema === false ? "" : bodySchemaSql(table, jsonSchema)}
1077
+ -- open a request; a repeated ask keeps the open record and replaces only an expired one
1078
+ create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
1079
+ returns void
1080
+ language sql
1081
+ volatile
1082
+ security definer
1083
+ set search_path = ''
1084
+ as $$
1085
+ insert into ${table} as a (token, body, status, tenant, principal_id, actor_id, session, approvals, created_at, expires_at, consumed_at)
1086
+ values (
1087
+ ${COLUMNS}
1088
+ )
1089
+ on conflict (token) do update
1090
+ set body = excluded.body, status = excluded.status, tenant = excluded.tenant,
1091
+ principal_id = excluded.principal_id, actor_id = excluded.actor_id, session = excluded.session,
1092
+ approvals = excluded.approvals, created_at = excluded.created_at,
1093
+ expires_at = excluded.expires_at, consumed_at = excluded.consumed_at
1094
+ where a.status = 'expired' or a.expires_at <= now()
1095
+ $$;
1096
+ ${serverOnly(fn(APPROVAL_STORE.open), "jsonb")}
1097
+
1098
+ create or replace function ${fn(APPROVAL_STORE.get)}(p_token text)
1099
+ returns jsonb
1100
+ language sql
1101
+ stable
1102
+ security definer
1103
+ set search_path = ''
1104
+ as $$
1105
+ select a.body from ${table} a where a.token = p_token
1106
+ $$;
1107
+ ${serverOnly(fn(APPROVAL_STORE.get), "text")}
1108
+
1109
+ -- write a verdict over the record it was computed from: still pending, unexpired, with p_seen approvals
1110
+ create or replace function ${fn(APPROVAL_STORE.resolve)}(p_token text, p_next jsonb, p_seen integer)
1111
+ returns jsonb
1112
+ language sql
1113
+ volatile
1114
+ security definer
1115
+ set search_path = ''
1116
+ as $$
1117
+ update ${table} a
1118
+ set body = p_next,
1119
+ status = p_next ->> 'status',
1120
+ approvals = coalesce(jsonb_array_length(p_next -> 'approvals'), 0)
1121
+ where a.token = p_token
1122
+ and a.status = 'pending'
1123
+ and a.expires_at > now()
1124
+ and a.approvals = p_seen
1125
+ returning a.body
1126
+ $$;
1127
+ ${serverOnly(fn(APPROVAL_STORE.resolve), "text, jsonb, integer")}
1128
+
1129
+ -- consume an approved, unexpired, unconsumed request once
1130
+ create or replace function ${fn(APPROVAL_STORE.consume)}(p_token text, p_now text)
1131
+ returns jsonb
1132
+ language sql
1133
+ volatile
1134
+ security definer
1135
+ set search_path = ''
1136
+ as $$
1137
+ update ${table} a
1138
+ set consumed_at = p_now::timestamptz,
1139
+ body = a.body || jsonb_build_object('consumedAt', p_now)
1140
+ where a.token = p_token
1141
+ and a.status = 'approved'
1142
+ and a.consumed_at is null
1143
+ and a.expires_at > p_now::timestamptz
1144
+ returning a.body
1145
+ $$;
1146
+ ${serverOnly(fn(APPROVAL_STORE.consume), "text, text")}
1147
+
1148
+ -- one page in (createdAt, token) order after the cursor position
1149
+ create or replace function ${fn(APPROVAL_STORE.list)}(p_filter jsonb, p_after_created text, p_after_token text, p_limit integer)
1150
+ returns setof jsonb
1151
+ language sql
1152
+ stable
1153
+ security definer
1154
+ set search_path = ''
1155
+ as $$
1156
+ select a.body
1157
+ from ${table} a
1158
+ where ${FILTER}
1159
+ and (p_after_created is null
1160
+ or (a.created_at collate "C", a.token collate "C") > (p_after_created collate "C", p_after_token collate "C"))
1161
+ order by a.created_at collate "C", a.token collate "C"
1162
+ limit p_limit
1163
+ $$;
1164
+ ${serverOnly(fn(APPROVAL_STORE.list), "jsonb, text, text, integer")}
1165
+
1166
+ create or replace function ${fn(APPROVAL_STORE.expire)}(p_now text)
1167
+ returns integer
1168
+ language sql
1169
+ volatile
1170
+ security definer
1171
+ set search_path = ''
1172
+ as $$
1173
+ with expired as (
1174
+ update ${table} a
1175
+ set status = 'expired', body = a.body || '{"status":"expired"}'::jsonb
1176
+ where a.status = 'pending' and a.expires_at <= p_now::timestamptz
1177
+ returning 1
1178
+ )
1179
+ select count(*)::integer from expired
1180
+ $$;
1181
+ ${serverOnly(fn(APPROVAL_STORE.expire), "text")}
1182
+
1183
+ -- reject every pending request the filter matches, as system:<by>
1184
+ create or replace function ${fn(APPROVAL_STORE.cancel)}(p_filter jsonb, p_by text, p_note text, p_now text)
1185
+ returns integer
1186
+ language sql
1187
+ volatile
1188
+ security definer
1189
+ set search_path = ''
1190
+ as $$
1191
+ with cancelled as (
1192
+ update ${table} a
1193
+ set status = 'rejected',
1194
+ body = (a.body - 'note') || jsonb_strip_nulls(jsonb_build_object(
1195
+ 'status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note
1196
+ ))
1197
+ where a.status = 'pending'
1198
+ and ${FILTER.replaceAll("\n ", "\n ")}
1199
+ returning 1
1200
+ )
1201
+ select count(*)::integer from cancelled
1202
+ $$;
1203
+ ${serverOnly(fn(APPROVAL_STORE.cancel), "jsonb, text, text, text")}
1204
+ `;
1205
+ }
1206
+ /**
1207
+ * `permdock_is_approver(p_approvers jsonb, p_tenant text default null)`: true
1208
+ * when the signed-in user matches the serialised approvers of a request
1209
+ * (`approvers.by`), for policies on the app's own approval views. A list
1210
+ * needs every item and `any-of` one of them; `user` compares the user id and
1211
+ * `holder(permission)` reads `permitted_<root>_ids_by_permission` in
1212
+ * `p_tenant`, or `permdock_has_permission` without one. Role, relation and
1213
+ * other grantee approvers need facts the database does not hold for every
1214
+ * membership source, so they match nobody.
1215
+ */
1216
+ function isApproverSql(ctx) {
1217
+ const fn = qualified(ctx, APPROVAL_STORE.isApprover);
1218
+ const root = ctx.scopes[0]?.name;
1219
+ const inTenant = root === void 0 ? "false" : `exists (
1220
+ select 1
1221
+ from ${qualified(ctx, checkName(permittedByPermissionHelper(root)))}(p_approvers ->> 'permission') i(id)
1222
+ where i.id::text = p_tenant
1223
+ )`;
1224
+ return `
1225
+ -- approvers: whether the signed-in user matches a request's approvers.by; policies on approval views call it
1226
+ create or replace function ${fn}(p_approvers jsonb, p_tenant text default null)
1227
+ returns boolean
1228
+ language plpgsql
1229
+ stable
1230
+ security definer
1231
+ set search_path = ''
1232
+ as $$
1233
+ declare
1234
+ v_item jsonb;
1235
+ v_user text := ${subjectIdSql(ctx)}::text;
1236
+ begin
1237
+ if coalesce(v_user, '') = '' or p_approvers is null then
1238
+ return false;
1239
+ end if;
1240
+ if jsonb_typeof(p_approvers) = 'array' then
1241
+ if jsonb_array_length(p_approvers) = 0 then
1242
+ return false;
1243
+ end if;
1244
+ for v_item in select value from jsonb_array_elements(p_approvers) loop
1245
+ if not ${fn}(v_item, p_tenant) then
1246
+ return false;
1247
+ end if;
1248
+ end loop;
1249
+ return true;
1250
+ end if;
1251
+ if jsonb_typeof(p_approvers) <> 'object' then
1252
+ return false;
1253
+ end if;
1254
+ case p_approvers ->> 'kind'
1255
+ when 'user' then
1256
+ return coalesce(p_approvers ->> 'id' = v_user, false);
1257
+ when 'permission' then
1258
+ if p_tenant is null then
1259
+ return coalesce(${qualified(ctx, PERMISSION_HELPERS.has)}(p_approvers ->> 'permission'), false);
1260
+ end if;
1261
+ return ${inTenant};
1262
+ when 'any-of' then
1263
+ if jsonb_typeof(p_approvers -> 'of') <> 'array' then
1264
+ return false;
1265
+ end if;
1266
+ for v_item in select value from jsonb_array_elements(p_approvers -> 'of') loop
1267
+ if ${fn}(v_item, p_tenant) then
1268
+ return true;
1269
+ end if;
1270
+ end loop;
1271
+ return false;
1272
+ else
1273
+ return false;
1274
+ end case;
1275
+ end;
1276
+ $$;
1277
+ revoke execute on function ${fn}(jsonb, text) from public, anon;
1278
+ grant execute on function ${fn}(jsonb, text) to authenticated;
1279
+ `;
1280
+ }
1281
+ /**
1282
+ * Everything `rls.approvals` writes: the generated or adopted store, the
1283
+ * approver check and, with `delegations: true`, the covers.
1284
+ */
1285
+ function approvalsSql(ctx, approvals, jsonSchema) {
1286
+ if (approvals === void 0 || approvals === false) return;
1287
+ if (approvals === true) return approvalStoreSql(ctx, jsonSchema) + isApproverSql(ctx);
1288
+ const { table, delegations } = approvals;
1289
+ if (delegations !== void 0 && typeof delegations !== "boolean") throw new TypeError("PermDock CLI: rls.approvals.delegations must be a boolean");
1290
+ let store;
1291
+ if (table === void 0) {
1292
+ const adoptOnly = [
1293
+ "token",
1294
+ "body",
1295
+ "open",
1296
+ "schema",
1297
+ "mirror"
1298
+ ].find((key) => approvals[key] !== void 0);
1299
+ if (adoptOnly !== void 0) throw configError(`PermDock CLI: rls.approvals.${adoptOnly} adopts a table; set rls.approvals.table too`);
1300
+ store = approvalStoreSql(ctx, jsonSchema);
1301
+ } else store = adoptedApprovalStoreSql(ctx, {
1302
+ ...approvals,
1303
+ table
1304
+ }, jsonSchema);
1305
+ return store + isApproverSql(ctx) + (delegations === true ? approvalDelegationsSql(ctx) : "");
1306
+ }
1307
+ /** The objects `rls.approvals.delegations` adds; `tableApprovalDelegationSource` reads the table. */
1308
+ const DELEGATIONS = {
1309
+ table: "approval_delegations",
1310
+ covers: "permdock_cover_approves"
1311
+ };
1312
+ /**
1313
+ * `rls.approvals.delegations`: one table of out-of-office covers that a
1314
+ * user writes for themselves, and a four-argument `permdock_is_approver`
1315
+ * that also answers for a delegate inside an active cover. One hop: a cover
1316
+ * is checked against the delegator's own approver rights, never the
1317
+ * delegator's covers. A delegator who is the requester never counts. A
1318
+ * `holder()` approver through a cover needs `rls.mode: "database"`, where
1319
+ * the `_for` helpers exist; in `jwt` mode only `user()` approvers pass.
1320
+ */
1321
+ function approvalDelegationsSql(ctx) {
1322
+ const table = qualified(ctx, DELEGATIONS.table);
1323
+ const fn = qualified(ctx, APPROVAL_STORE.isApprover);
1324
+ const covers = qualified(ctx, DELEGATIONS.covers);
1325
+ const user = ctx.dialect === "supabase" ? "uuid" : "text";
1326
+ const me = `${subjectIdSql(ctx)}::text`;
1327
+ const defaultMe = subjectIdSql(ctx).replace(/^\(select (.+)\)$/su, "$1");
1328
+ const root = ctx.scopes[0]?.name;
1329
+ const member = (tenant) => root === void 0 ? "false" : `${tenant} in (select m.id::text from ${qualified(ctx, memberIdsHelper(root))}() m(id))`;
1330
+ const database = ctx.authorize === "database";
1331
+ const holderGlobal = database ? `coalesce(${qualified(ctx, PERMISSION_HELPERS.hasFor)}(p_delegator, p_approvers ->> 'permission'), false)` : "false";
1332
+ const holderTenant = database && root !== void 0 ? `exists (
1333
+ select 1
1334
+ from ${qualified(ctx, checkName(`${permittedByPermissionHelper(root)}_for`))}(p_delegator, p_approvers ->> 'permission') i(id)
1335
+ where i.id::text = p_tenant
1336
+ )` : "false";
1337
+ const policy = (name) => quoteIdent(`${DELEGATIONS.table}_${name}`);
1338
+ const own = `delegator::text = ${me} and (tenant is null or ${member("tenant")})`;
1339
+ return `
1340
+ -- approval delegations: out-of-office covers a user writes for themselves; tableApprovalDelegationSource and permdock_is_approver read them
1341
+ create table if not exists ${table} (
1342
+ id text primary key default gen_random_uuid()::text,
1343
+ delegator ${user} not null default (${defaultMe})::${user},
1344
+ delegate ${user} not null,
1345
+ tenant text,
1346
+ permissions text[],
1347
+ resources text[],
1348
+ "from" timestamptz not null default now(),
1349
+ "until" timestamptz not null,
1350
+ created_at timestamptz not null default now(),
1351
+ constraint ${quoteIdent(`${DELEGATIONS.table}_window`)} check ("from" < "until"),
1352
+ constraint ${quoteIdent(`${DELEGATIONS.table}_distinct`)} check (delegator <> delegate)
1353
+ );
1354
+ create index if not exists ${quoteIdent(`${DELEGATIONS.table}_delegate`)} on ${table} (delegate, "until");
1355
+ alter table ${table} enable row level security;
1356
+ revoke all on table ${table} from anon, public;
1357
+ grant select, insert, update, delete on table ${table} to authenticated;
1358
+ drop policy if exists ${policy("read")} on ${table};
1359
+ create policy ${policy("read")} on ${table}
1360
+ for select to authenticated
1361
+ using (delegator::text = ${me} or delegate::text = ${me});
1362
+ drop policy if exists ${policy("insert")} on ${table};
1363
+ create policy ${policy("insert")} on ${table}
1364
+ for insert to authenticated
1365
+ with check (${own});
1366
+ drop policy if exists ${policy("update")} on ${table};
1367
+ create policy ${policy("update")} on ${table}
1368
+ for update to authenticated
1369
+ using (delegator::text = ${me})
1370
+ with check (${own});
1371
+ drop policy if exists ${policy("delete")} on ${table};
1372
+ create policy ${policy("delete")} on ${table}
1373
+ for delete to authenticated
1374
+ using (delegator::text = ${me});
1375
+
1376
+ -- approval delegations: whether a delegator's own rights match approvers.by; only permdock_is_approver calls it
1377
+ create or replace function ${covers}(p_delegator ${user}, p_approvers jsonb, p_tenant text)
1378
+ returns boolean
1379
+ language plpgsql
1380
+ stable
1381
+ security definer
1382
+ set search_path = ''
1383
+ as $$
1384
+ declare
1385
+ v_item jsonb;
1386
+ begin
1387
+ if p_delegator is null or p_approvers is null then
1388
+ return false;
1389
+ end if;
1390
+ if jsonb_typeof(p_approvers) = 'array' then
1391
+ if jsonb_array_length(p_approvers) = 0 then
1392
+ return false;
1393
+ end if;
1394
+ for v_item in select value from jsonb_array_elements(p_approvers) loop
1395
+ if not ${covers}(p_delegator, v_item, p_tenant) then
1396
+ return false;
1397
+ end if;
1398
+ end loop;
1399
+ return true;
1400
+ end if;
1401
+ if jsonb_typeof(p_approvers) <> 'object' then
1402
+ return false;
1403
+ end if;
1404
+ case p_approvers ->> 'kind'
1405
+ when 'user' then
1406
+ return coalesce(p_approvers ->> 'id' = p_delegator::text, false);
1407
+ when 'permission' then
1408
+ if p_tenant is null then
1409
+ return ${holderGlobal};
1410
+ end if;
1411
+ return ${holderTenant};
1412
+ when 'any-of' then
1413
+ if jsonb_typeof(p_approvers -> 'of') <> 'array' then
1414
+ return false;
1415
+ end if;
1416
+ for v_item in select value from jsonb_array_elements(p_approvers -> 'of') loop
1417
+ if ${covers}(p_delegator, v_item, p_tenant) then
1418
+ return true;
1419
+ end if;
1420
+ end loop;
1421
+ return false;
1422
+ else
1423
+ return false;
1424
+ end case;
1425
+ end;
1426
+ $$;
1427
+ revoke execute on function ${covers}(${user}, jsonb, text) from public, anon, authenticated;
1428
+
1429
+ -- approvers with covers: the signed-in user, or a delegate inside an active cover of a delegator who is not the requester
1430
+ create or replace function ${fn}(p_approvers jsonb, p_tenant text, p_requester text, p_resource text)
1431
+ returns boolean
1432
+ language plpgsql
1433
+ stable
1434
+ security definer
1435
+ set search_path = ''
1436
+ as $$
1437
+ declare
1438
+ v_delegator ${user};
1439
+ v_user text := ${me};
1440
+ begin
1441
+ if ${fn}(p_approvers, p_tenant) then
1442
+ return true;
1443
+ end if;
1444
+ if coalesce(v_user, '') = '' or coalesce(p_requester, '') = '' then
1445
+ return false;
1446
+ end if;
1447
+ if p_tenant is not null and not (${member("p_tenant")}) then
1448
+ return false;
1449
+ end if;
1450
+ for v_delegator in
1451
+ select d.delegator
1452
+ from ${table} d
1453
+ where d.delegate::text = v_user
1454
+ and d.delegator::text <> v_user
1455
+ and d.delegator::text <> p_requester
1456
+ and d."from" <= now()
1457
+ and now() < d."until"
1458
+ and (d.tenant is null or d.tenant = p_tenant)
1459
+ and d.permissions is null
1460
+ and (d.resources is null or p_resource = any (d.resources))
1461
+ loop
1462
+ if ${covers}(v_delegator, p_approvers, p_tenant) then
1463
+ return true;
1464
+ end if;
1465
+ end loop;
1466
+ return false;
1467
+ end;
1468
+ $$;
1469
+ revoke execute on function ${fn}(jsonb, text, text, text) from public, anon;
1470
+ grant execute on function ${fn}(jsonb, text, text, text) to authenticated;
1471
+ `;
1472
+ }
1473
+ /** The request fields an adopted table may mirror into its own columns, as SQL over a body. */
1474
+ const MIRRORS = {
1475
+ status: (body) => `${body} ->> 'status'`,
1476
+ permission: (body) => `${body} ->> 'permission'`,
1477
+ tenant: (body) => `${body} #>> '{subject,principal,tenant}'`,
1478
+ principalId: (body) => `${body} #>> '{subject,principal,id}'`,
1479
+ actorId: (body) => `${body} #>> '{subject,actor,id}'`,
1480
+ session: (body) => `${body} #>> '{subject,session}'`,
1481
+ approvals: (body) => `coalesce(jsonb_array_length(${body} -> 'approvals'), 0)`,
1482
+ createdAt: (body) => `${body} ->> 'createdAt'`,
1483
+ expiresAt: (body) => `${body} ->> 'expiresAt'`,
1484
+ resolvedAt: (body) => `${body} ->> 'resolvedAt'`,
1485
+ resolvedBy: (body) => `${body} ->> 'resolvedBy'`,
1486
+ consumedAt: (body) => `${body} ->> 'consumedAt'`
1487
+ };
1488
+ function mirrorsOf(adopt) {
1489
+ const out = [];
1490
+ for (const [field, column] of Object.entries(adopt.mirror ?? {})) {
1491
+ if (!Object.hasOwn(MIRRORS, field)) throw configError(`PermDock CLI: rls.approvals.mirror.${field} is not a request field; use ${Object.keys(MIRRORS).join(", ")}`);
1492
+ if (typeof column !== "string") throw new TypeError(`PermDock CLI: rls.approvals.mirror.${field} must be a column name`);
1493
+ out.push([column, field]);
1494
+ }
1495
+ return out;
1496
+ }
1497
+ /** One row of `table` from `values` (column, SQL), each converted to the column's own type. */
1498
+ function populate(table, values) {
1499
+ return `pg_catalog.jsonb_populate_record(null::${table}, pg_catalog.jsonb_build_object(${values.map(([column, sql]) => `${quoteLiteral(column)}, ${sql}`).join(", ")}))`;
609
1500
  }
1501
+ /** The mirrored columns' values for the request `body`. */
1502
+ function mirrorRow(table, mirror, body) {
1503
+ return populate(table, mirror.map(([column, field]) => [column, MIRRORS[field](body)]));
1504
+ }
1505
+ /** `, (<mirrored columns>) = (select …)` for an update that writes `body`; empty without mirrors. */
1506
+ function mirrorSet(table, mirror, body) {
1507
+ if (mirror.length === 0) return "";
1508
+ const columns = mirror.map(([column]) => quoteIdent(column));
1509
+ const picked = columns.map((column) => `r.${column}`).join(", ");
1510
+ return `,\n ${columns.length === 1 ? columns[0] ?? "" : `(${columns.join(", ")})`} = (select ${picked} from ${mirrorRow(table, mirror, body)} r)`;
1511
+ }
1512
+ /**
1513
+ * The approval store over a table the app already has (`rls.approvals:
1514
+ * { table, token?, body?, mirror? }`): the same `permdock_approval_*`
1515
+ * functions, reading every field from the request body in `body`, keyed by
1516
+ * the text column `token`, and copying the fields `mirror` names into the
1517
+ * app's own columns, converted to their types, on every write. The table's
1518
+ * grants and policies stay the app's.
1519
+ */
1520
+ function adoptedApprovalStoreSql(ctx, adopt, jsonSchema = false) {
1521
+ const table = quoteQualified(adopt.table);
1522
+ const base = adopt.table.split(".").at(-1) ?? adopt.table;
1523
+ const token = quoteIdent(adopt.token ?? "token");
1524
+ const body = quoteIdent(adopt.body ?? "body");
1525
+ const mirror = mirrorsOf(adopt);
1526
+ for (const [column] of mirror) if (column === (adopt.token ?? "token") || column === (adopt.body ?? "body")) throw configError(`PermDock CLI: rls.approvals.mirror names the ${column} column, which already holds the token or the body`);
1527
+ const open = adopt.open ?? "insert";
1528
+ if (open !== "insert" && open !== "attach") throw configError(`PermDock CLI: rls.approvals.open must be 'insert' or 'attach', got '${String(open)}'`);
1529
+ const schema = adopt.schema;
1530
+ if (schema !== void 0 && (typeof schema !== "string" || schema === "")) throw configError("PermDock CLI: rls.approvals.schema must be a schema name");
1531
+ const fn = (name) => schema === void 0 ? qualified(ctx, name) : `${quoteIdent(schema)}.${name}`;
1532
+ const index = (name) => quoteIdent(`${base}_permdock_${name}`);
1533
+ const status = `a.${body} ->> 'status'`;
1534
+ const expires = `(a.${body} ->> 'expiresAt')::timestamptz`;
1535
+ const created = `(a.${body} ->> 'createdAt')`;
1536
+ const filter = `(p_filter ->> 'status' is null or ${status} = p_filter ->> 'status')
1537
+ and (p_filter ->> 'tenant' is null or a.${body} #>> '{subject,principal,tenant}' = p_filter ->> 'tenant')
1538
+ and (p_filter ->> 'principalId' is null or a.${body} #>> '{subject,principal,id}' = p_filter ->> 'principalId')
1539
+ and (p_filter ->> 'actorId' is null or a.${body} #>> '{subject,actor,id}' = p_filter ->> 'actorId')
1540
+ and (p_filter ->> 'session' is null or a.${body} #>> '{subject,session}' = p_filter ->> 'session')`;
1541
+ const columns = [
1542
+ token,
1543
+ body,
1544
+ ...mirror.map(([column]) => quoteIdent(column))
1545
+ ];
1546
+ const inserted = populate(table, [
1547
+ [adopt.token ?? "token", "p_request ->> 'token'"],
1548
+ [adopt.body ?? "body", "p_request"],
1549
+ ...mirror.map(([column, field]) => [column, MIRRORS[field]("p_request")])
1550
+ ]);
1551
+ const insertSql = `-- open a request; a repeated ask keeps the open record and replaces only an expired one
1552
+ create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
1553
+ returns void
1554
+ language sql
1555
+ volatile
1556
+ security definer
1557
+ set search_path = ''
1558
+ as $$
1559
+ insert into ${table} as a (${columns.join(", ")})
1560
+ select ${columns.map((column) => `r.${column}`).join(", ")}
1561
+ from ${inserted} r
1562
+ on conflict (${token}) do update
1563
+ set ${columns.filter((column) => column !== token).map((column) => `${column} = excluded.${column}`).join(", ")}
1564
+ where ${status} = 'expired' or ${expires} <= now()
1565
+ $$;`;
1566
+ const attachSql = `create or replace function ${fn(APPROVAL_STORE.open)}(p_request jsonb)
1567
+ returns void
1568
+ language plpgsql
1569
+ volatile
1570
+ security definer
1571
+ set search_path = ''
1572
+ as $$
1573
+ begin
1574
+ update ${table} a
1575
+ set ${body} = p_request${mirrorSet(table, mirror, "p_request")}
1576
+ where a.${token} = p_request ->> 'token'
1577
+ and (a.${body} is null or ${status} = 'expired' or ${expires} <= now());
1578
+ if not found and not exists (select 1 from ${table} a where a.${token} = p_request ->> 'token') then
1579
+ raise exception using
1580
+ errcode = 'P0002',
1581
+ message = 'permdock: no row of ${adopt.table.replaceAll("'", "''")} holds approval token ' || (p_request ->> 'token'),
1582
+ hint = 'insert the application row with this token before opening the request';
1583
+ end if;
1584
+ end;
1585
+ $$;`;
1586
+ return `-- approval store adopted onto ${adopt.table}: ApprovalRequest bodies in ${adopt.body ?? "body"}, keyed by ${adopt.token ?? "token"}; supabaseApprovalStore calls the functions below
1587
+ alter table ${table} add column if not exists ${token} text;
1588
+ alter table ${table} add column if not exists ${body} jsonb;
1589
+ create unique index if not exists ${index("token")} on ${table} (${token});
1590
+ create index if not exists ${index("page")} on ${table} ((${body} ->> 'createdAt') collate "C", ${token} collate "C");
1591
+ create index if not exists ${index("status")} on ${table} ((${body} ->> 'status'));
1592
+ ${jsonSchema === false ? "" : bodySchemaSql(table, jsonSchema, body, `${base}_permdock_body_schema`)}
1593
+ ${open === "attach" ? attachSql : insertSql}
1594
+ ${serverOnly(fn(APPROVAL_STORE.open), "jsonb")}
1595
+
1596
+ create or replace function ${fn(APPROVAL_STORE.get)}(p_token text)
1597
+ returns jsonb
1598
+ language sql
1599
+ stable
1600
+ security definer
1601
+ set search_path = ''
1602
+ as $$
1603
+ select a.${body} from ${table} a where a.${token} = p_token
1604
+ $$;
1605
+ ${serverOnly(fn(APPROVAL_STORE.get), "text")}
1606
+
1607
+ -- write a verdict over the record it was computed from: still pending, unexpired, with p_seen approvals
1608
+ create or replace function ${fn(APPROVAL_STORE.resolve)}(p_token text, p_next jsonb, p_seen integer)
1609
+ returns jsonb
1610
+ language sql
1611
+ volatile
1612
+ security definer
1613
+ set search_path = ''
1614
+ as $$
1615
+ update ${table} a
1616
+ set ${body} = p_next${mirrorSet(table, mirror, "p_next")}
1617
+ where a.${token} = p_token
1618
+ and ${status} = 'pending'
1619
+ and ${expires} > now()
1620
+ and coalesce(jsonb_array_length(a.${body} -> 'approvals'), 0) = p_seen
1621
+ returning a.${body}
1622
+ $$;
1623
+ ${serverOnly(fn(APPROVAL_STORE.resolve), "text, jsonb, integer")}
1624
+
1625
+ -- consume an approved, unexpired, unconsumed request once
1626
+ create or replace function ${fn(APPROVAL_STORE.consume)}(p_token text, p_now text)
1627
+ returns jsonb
1628
+ language sql
1629
+ volatile
1630
+ security definer
1631
+ set search_path = ''
1632
+ as $$
1633
+ update ${table} a
1634
+ set ${body} = a.${body} || jsonb_build_object('consumedAt', p_now)${mirrorSet(table, mirror, `(a.${body} || jsonb_build_object('consumedAt', p_now))`)}
1635
+ where a.${token} = p_token
1636
+ and ${status} = 'approved'
1637
+ and a.${body} ->> 'consumedAt' is null
1638
+ and ${expires} > p_now::timestamptz
1639
+ returning a.${body}
1640
+ $$;
1641
+ ${serverOnly(fn(APPROVAL_STORE.consume), "text, text")}
1642
+
1643
+ -- one page in (createdAt, token) order after the cursor position
1644
+ create or replace function ${fn(APPROVAL_STORE.list)}(p_filter jsonb, p_after_created text, p_after_token text, p_limit integer)
1645
+ returns setof jsonb
1646
+ language sql
1647
+ stable
1648
+ security definer
1649
+ set search_path = ''
1650
+ as $$
1651
+ select a.${body}
1652
+ from ${table} a
1653
+ where a.${body} is not null
1654
+ and ${filter}
1655
+ and (p_after_created is null
1656
+ or (${created} collate "C", a.${token} collate "C") > (p_after_created collate "C", p_after_token collate "C"))
1657
+ order by ${created} collate "C", a.${token} collate "C"
1658
+ limit p_limit
1659
+ $$;
1660
+ ${serverOnly(fn(APPROVAL_STORE.list), "jsonb, text, text, integer")}
1661
+
1662
+ create or replace function ${fn(APPROVAL_STORE.expire)}(p_now text)
1663
+ returns integer
1664
+ language sql
1665
+ volatile
1666
+ security definer
1667
+ set search_path = ''
1668
+ as $$
1669
+ with expired as (
1670
+ update ${table} a
1671
+ set ${body} = a.${body} || '{"status":"expired"}'::jsonb${mirrorSet(table, mirror, `(a.${body} || '{"status":"expired"}'::jsonb)`)}
1672
+ where ${status} = 'pending' and ${expires} <= p_now::timestamptz
1673
+ returning 1
1674
+ )
1675
+ select count(*)::integer from expired
1676
+ $$;
1677
+ ${serverOnly(fn(APPROVAL_STORE.expire), "text")}
1678
+
1679
+ -- reject every pending request the filter matches, as system:<by>
1680
+ create or replace function ${fn(APPROVAL_STORE.cancel)}(p_filter jsonb, p_by text, p_note text, p_now text)
1681
+ returns integer
1682
+ language sql
1683
+ volatile
1684
+ security definer
1685
+ set search_path = ''
1686
+ as $$
1687
+ with cancelled as (
1688
+ update ${table} a
1689
+ set ${body} = (a.${body} - 'note') || jsonb_strip_nulls(jsonb_build_object(
1690
+ 'status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note
1691
+ ))${mirrorSet(table, mirror, `((a.${body} - 'note') || jsonb_strip_nulls(jsonb_build_object('status', 'rejected', 'resolvedAt', p_now, 'resolvedBy', 'system:' || p_by, 'note', p_note)))`)}
1692
+ where ${status} = 'pending'
1693
+ and ${filter.replaceAll("\n ", "\n ")}
1694
+ returning 1
1695
+ )
1696
+ select count(*)::integer from cancelled
1697
+ $$;
1698
+ ${serverOnly(fn(APPROVAL_STORE.cancel), "jsonb, text, text, text")}
1699
+ `;
1700
+ }
1701
+ //#endregion
1702
+ //#region src/cli/rls/permission-holders.ts
1703
+ /** `permitted_<scope>_users(p_permission, p_id)`. Names are part of the SQL contract. */
1704
+ function permittedUsersHelper(scope) {
1705
+ return checkName(`permitted_${scope}_users`);
1706
+ }
1707
+ /** The users with a membership row on the instance `p_id`, or `undefined` when the scope's memberships are not all in tables. */
1708
+ function candidatesSql(ctx, scope, user) {
1709
+ const mapped = scopeTable(ctx, scope);
1710
+ if (mapped !== void 0) return {
1711
+ sql: ` select m.${quoteIdent(mapped.table.user)}::${user}
1712
+ from ${quoteQualified(mapped.table.table)} m
1713
+ where m.${quoteIdent(mapped.column)} = p_id`,
1714
+ keys: []
1715
+ };
1716
+ const sources = holderSources(ctx, scope);
1717
+ if (sources === void 0 || sources === "not-tables") return;
1718
+ return {
1719
+ sql: sources.map((source, index) => ` select h.user_id::${user}
1720
+ from (
1721
+ ${source.holders.rows(scope, { id: `v_key_${String(index)}` }).replaceAll(/^/gmu, " ")}
1722
+ ) h`).join("\n union\n"),
1723
+ keys: sources.map((source, index) => `v_key_${String(index)} ${source.holders.idType}`)
1724
+ };
1725
+ }
1726
+ /** `language sql`, or PL/pgSQL when a source's id column needs `p_id` in a `%type` variable. */
1727
+ function functionBody(keys, query) {
1728
+ if (keys.length === 0) return `language sql
1729
+ stable
1730
+ security definer
1731
+ set search_path = ''
1732
+ as $$
1733
+ ${query}
1734
+ $$;`;
1735
+ return `language plpgsql
1736
+ stable
1737
+ security definer
1738
+ set search_path = ''
1739
+ as $$
1740
+ declare
1741
+ ${keys.map((key) => ` ${key} := p_id;`).join("\n")}
1742
+ begin
1743
+ return query
1744
+ ${query.replaceAll(/^/gmu, " ")};
1745
+ end
1746
+ $$;`;
1747
+ }
1748
+ /**
1749
+ * One `permitted_<scope>_users(p_permission, p_id)` per scope in `database`
1750
+ * mode: the users who hold a permission key on one instance, read from the
1751
+ * scope's membership rows and the global-roles table and kept when
1752
+ * `permitted_<scope>_ids_by_permission_for` lists the instance or
1753
+ * `permdock_has_permission_for` answers, so denies, custom roles, expiry and
1754
+ * suspension apply as for the user. It names other users, so no client role
1755
+ * may execute it, and a backend role needs its own grant.
1756
+ */
1757
+ function permissionHoldersSql(ctx) {
1758
+ if (ctx.authorize !== "database") return "";
1759
+ const user = ctx.dialect === "supabase" ? "uuid" : "text";
1760
+ const global = globalRoleRows(ctx);
1761
+ const hasFor = qualified(ctx, PERMISSION_HELPERS.hasFor);
1762
+ return `${ctx.scopes.map(({ name }) => {
1763
+ const candidates = hasPermittedUsers(ctx, name) ? candidatesSql(ctx, name, user) : void 0;
1764
+ if (candidates === void 0) return `-- ${name}: no memberships table or table source, so permitted_${name}_users is not written`;
1765
+ const fn = qualified(ctx, permittedUsersHelper(name));
1766
+ const ids = qualified(ctx, checkName(`${permittedByPermissionHelper(name)}_for`));
1767
+ const type = scopeTypeOf(ctx, name);
1768
+ return `-- the users who hold a permission on one ${name}: its members and the global-role holders for whom the _for helpers answer; no client role may execute it
1769
+ create or replace function ${fn}(p_permission text, p_id ${type})
1770
+ returns setof ${user}
1771
+ ${functionBody(candidates.keys, ` select c.user_id
1772
+ from (
1773
+ ${candidates.sql}
1774
+ union
1775
+ select ${global.userSql}::${user}
1776
+ from ${global.from}
1777
+ ) c(user_id)
1778
+ where c.user_id is not null
1779
+ and (
1780
+ p_id in (select ${ids}(c.user_id, p_permission))
1781
+ or ${hasFor}(c.user_id, p_permission)
1782
+ )
1783
+ order by 1`)}
1784
+ revoke execute on function ${fn}(text, ${type}) from public, anon, authenticated;`;
1785
+ }).join("\n\n")}\n`;
1786
+ }
1787
+ //#endregion
1788
+ //#region src/cli/supabase-hook/sql.ts
1789
+ const MANAGED_TRIGGER = "permdock_protect_managed";
610
1790
  function hookSql(parts) {
611
1791
  const schema = quoteIdent(parts.schema);
612
1792
  const fn = `${schema}.custom_access_token_hook`;
@@ -616,14 +1796,14 @@ function hookSql(parts) {
616
1796
  from ${rows.from}
617
1797
  where ${rows.userSql} = v_roles_user;`;
618
1798
  const plan = parts.attrs;
619
- const fromTable = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
1799
+ const tableMembershipSource = plan === void 0 || plan.columns.length === 0 || plan.table === void 0 ? "" : `
620
1800
  select jsonb_strip_nulls(jsonb_build_object(${plan.columns.map((column) => `${quoteLiteral(column)}, to_jsonb(p.${quoteIdent(column)})`).join(", ")}))
621
1801
  into attrs
622
- from ${table(plan.table)} p
1802
+ from ${quoteQualified(plan.table)} p
623
1803
  where p.${quoteIdent(plan.id)} = v_attrs_user;`;
624
1804
  const fromMeta = plan === void 0 || plan.meta.length === 0 ? "" : `
625
1805
  attrs := coalesce(attrs, '{}'::jsonb) || jsonb_strip_nulls(jsonb_build_object(${plan.meta.map((key) => `${quoteLiteral(key)}, claims -> 'app_metadata' -> ${quoteLiteral(key)}`).join(", ")}));`;
626
- const attrs = plan === void 0 ? "" : `${fromTable}${fromMeta}
1806
+ const attrs = plan === void 0 ? "" : `${tableMembershipSource}${fromMeta}
627
1807
  if attrs is not null and attrs <> '{}'::jsonb then
628
1808
  used := octet_length(attrs::text);
629
1809
  if used > budget then
@@ -721,7 +1901,7 @@ function extraGrantsSql(extra) {
721
1901
  }
722
1902
  function beforeGrantsSql(before, extra) {
723
1903
  const granted = new Set(extra.map((entry) => entry.fn.split(".")[0]));
724
- return [...[...new Set(before.map((fn) => fn.split(".")[0] ?? ""))].filter((name) => !granted.has(name)).map((name) => `\ngrant usage on schema ${quoteIdent(name)} to supabase_auth_admin;`), ...before.map((fn) => `\ngrant execute on function ${quoteTable(fn)}(jsonb) to supabase_auth_admin;`)].join("");
1904
+ return [...[...new Set(before.map((fn) => beforeFirst(fn, ".")))].filter((name) => !granted.has(name)).map((name) => `\ngrant usage on schema ${quoteIdent(name)} to supabase_auth_admin;`), ...before.map((fn) => `\ngrant execute on function ${quoteTable(fn)}(jsonb) to supabase_auth_admin;`)].join("");
725
1905
  }
726
1906
  /**
727
1907
  * Execute on each `member_<scope>_ids_for(uuid)` for the `hook.claims`
@@ -752,6 +1932,92 @@ function readsSql(parts) {
752
1932
  if (parts.users !== void 0) reads.set(parts.users.table, reads.get(parts.users.table) ?? "status_users");
753
1933
  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
1934
  }
1935
+ function managedSql(parts) {
1936
+ const managed = parts.sources.filter((source) => source.sql.managed !== void 0);
1937
+ if (managed.length === 0) return "";
1938
+ const guard = `${quoteIdent(parts.schema)}.permdock_protect_managed`;
1939
+ return `-- memberships the identity provider owns: clients (anon, authenticated) cannot write them
1940
+ create or replace function ${guard}()
1941
+ returns trigger
1942
+ language plpgsql
1943
+ set search_path = ''
1944
+ as $$
1945
+ declare
1946
+ column_name text := tg_argv[0];
1947
+ begin
1948
+ if current_user in ('anon', 'authenticated') and (
1949
+ column_name = ''
1950
+ or (tg_op <> 'INSERT' and to_jsonb(old) ->> column_name = 'idp')
1951
+ or (tg_op <> 'DELETE' and to_jsonb(new) ->> column_name = 'idp')
1952
+ ) then
1953
+ raise exception 'PermDock: % rows managed by the identity provider cannot be changed here', tg_table_name
1954
+ using errcode = '42501';
1955
+ end if;
1956
+ return coalesce(new, old);
1957
+ end;
1958
+ $$;
1959
+ ${managed.map((source) => `drop trigger if exists ${quoteIdent(MANAGED_TRIGGER)} on ${quoteQualified(source.sql.table)};
1960
+ create trigger ${quoteIdent(MANAGED_TRIGGER)}
1961
+ before insert or update or delete on ${quoteQualified(source.sql.table)}
1962
+ for each row execute function ${guard}(${quoteLiteral(source.sql.managed ?? "")});`).join("\n")}`;
1963
+ }
1964
+ /** The `config.toml` block that enables the hook and bounds token staleness. */
1965
+ function configToml(schema, jwtExpiry) {
1966
+ return `[auth]
1967
+ jwt_expiry = ${String(jwtExpiry)}
1968
+
1969
+ [auth.hook.custom_access_token]
1970
+ enabled = true
1971
+ uri = "${hookUri(schema)}"`;
1972
+ }
1973
+ const BUDGET_MEASURE = "octet_length(memberships::text) + octet_length(attrs::text)";
1974
+ function hookParts(scopes, config, overrides) {
1975
+ const hook = config.supabase?.hook;
1976
+ if (hook === void 0) throw configError("PermDock CLI: supabase hook generate needs supabase.hook in permdock.config.ts");
1977
+ const root = rootScope(scopes) ?? "tenant";
1978
+ const suspension = checkSuspension(hook.suspension ?? config.rls?.suspension, scopes);
1979
+ const schema = overrides.schema ?? hook.schema ?? config.rls?.schema ?? "permdock";
1980
+ quoteIdent(schema);
1981
+ const tenantClaim = config.rls?.tenantClaim ?? "tenant_id";
1982
+ const extraPlan = extraClaimsPlan(hook.claims, tenantClaim);
1983
+ checkSources({
1984
+ sources: hook.memberships,
1985
+ scopes
1986
+ });
1987
+ const parts = {
1988
+ schema,
1989
+ scopes,
1990
+ root,
1991
+ sources: hook.memberships,
1992
+ budget: positiveInteger(overrides.budget ?? hook.budget ?? 1024, "budget"),
1993
+ version: hook.version !== false,
1994
+ jwtExpiry: positiveInteger(hook.jwtExpiry ?? 900, "jwtExpiry"),
1995
+ tenantClaim,
1996
+ users: suspension?.users,
1997
+ hook,
1998
+ roles: hook.roles === false ? void 0 : globalRoleSource(hook.roles ?? config.rls?.roles ?? { table: `${schema}.user_roles` }, "public", "r", config.rls?.customRoleWrites?.roles),
1999
+ active: activeFromSql(overrides.activeFrom ?? hook.activeFrom, root),
2000
+ attrs: hook.attrs === void 0 ? void 0 : attrsPlan(hook.attrs),
2001
+ extra: extraPlan.claims,
2002
+ before: beforePlan(hook.before),
2003
+ helpers: {
2004
+ schema: config.rls?.schema ?? "permdock",
2005
+ ...memberForPlan({
2006
+ scopes,
2007
+ ...config.rls?.memberships === void 0 ? {} : { memberships: config.rls.memberships },
2008
+ memberSources: config.rls?.membershipSources ?? hook.memberships
2009
+ })
2010
+ }
2011
+ };
2012
+ if (parts.attrs !== void 0 && parts.attrs.errors.length > 0) throw configError(`PermDock CLI: ${parts.attrs.errors.join("; ")}`);
2013
+ if (extraPlan.errors.length > 0) throw configError(`PermDock CLI: ${extraPlan.errors.join("; ")}`);
2014
+ quoteIdent(parts.tenantClaim);
2015
+ return parts;
2016
+ }
2017
+ //#endregion
2018
+ //#region src/cli/supabase-hook/version.ts
2019
+ const VERSION_TRIGGER = "permdock_authz_version";
2020
+ const AUTHZ_VERSION_BUMP = "permdock_bump_authz_version_for";
755
2021
  function versionSql(parts) {
756
2022
  if (!parts.version) return "";
757
2023
  const schema = quoteIdent(parts.schema);
@@ -787,225 +2053,22 @@ returns trigger
787
2053
  language plpgsql
788
2054
  security definer
789
2055
  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
- );`;
2056
+ as $$
2057
+ declare
2058
+ column_name text := tg_argv[0];
2059
+ begin
2060
+ perform ${bumpFor}(array[
2061
+ case when tg_op <> 'INSERT' then to_jsonb(old) ->> column_name end,
2062
+ case when tg_op <> 'DELETE' then to_jsonb(new) ->> column_name end
2063
+ ]::uuid[]);
2064
+ return null;
2065
+ end;
2066
+ $$;
2067
+ revoke execute on function ${bump}() from public, anon, authenticated;
2068
+ ${tables.map(([name, user]) => `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${quoteQualified(name)};
2069
+ create trigger ${quoteIdent(VERSION_TRIGGER)}
2070
+ after insert or update or delete on ${quoteQualified(name)}
2071
+ for each row execute function ${bump}(${quoteLiteral(user)});`).join("\n")}${memberUsersVersionSql(parts, bumpFor)}${roleKeysVersionSql(parts, versionTable)}`;
1009
2072
  }
1010
2073
  function signedUp(user) {
1011
2074
  return `exists (select 1 from auth.users a where a.id = ${user})`;
@@ -1046,7 +2109,7 @@ function memberUsersVersionSql(parts, bumpFor) {
1046
2109
  const linkedBump = `${quoteIdent(parts.schema)}.permdock_bump_authz_version_linked_users`;
1047
2110
  const groups = [...byTable];
1048
2111
  const lookup = (users, indent) => `${indent}perform ${bumpFor}(array(
1049
- ${indent} select u.${quoteIdent(users.key)}::uuid from ${table(users.table)} u
2112
+ ${indent} select u.${quoteIdent(users.key)}::uuid from ${quoteQualified(users.table)} u
1050
2113
  ${indent} where ((tg_op <> 'INSERT' and u.${quoteIdent(users.id)} = old.${quoteIdent(users.ref)})
1051
2114
  ${indent} or (tg_op <> 'DELETE' and u.${quoteIdent(users.id)} = new.${quoteIdent(users.ref)}))
1052
2115
  ${indent} and ${signedUp(`u.${quoteIdent(users.key)}::uuid`)}
@@ -1059,7 +2122,7 @@ ${group.users.map((users) => lookup(users, " ")).join("\n")}
1059
2122
  end if;`;
1060
2123
  }).join("\n");
1061
2124
  const memberTriggers = groups.map(([, group]) => {
1062
- const target = table(group.table);
2125
+ const target = quoteQualified(group.table);
1063
2126
  return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1064
2127
  create trigger ${quoteIdent(VERSION_TRIGGER)}
1065
2128
  after insert or update or delete on ${target}
@@ -1077,7 +2140,7 @@ create trigger ${quoteIdent(VERSION_TRIGGER)}
1077
2140
  userTables.set(key, entry);
1078
2141
  }
1079
2142
  const userTriggers = [...userTables.values()].map((entry) => {
1080
- const target = table(entry.table);
2143
+ const target = quoteQualified(entry.table);
1081
2144
  const name = quoteIdent(`${VERSION_TRIGGER}_${entry.key}`);
1082
2145
  return `drop trigger if exists ${name} on ${target};
1083
2146
  create trigger ${name}
@@ -1169,7 +2232,7 @@ ${indent}end if;
1169
2232
  ${indent}insert into ${versionTable} as v (user_id, version)
1170
2233
  ${indent}select distinct h.user_id, 1
1171
2234
  ${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} `)}
2235
+ ${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
2236
  ${indent}) h
1174
2237
  ${indent}where h.user_id is not null
1175
2238
  ${indent} and exists (select 1 from auth.users au where au.id = h.user_id)
@@ -1217,143 +2280,15 @@ end;
1217
2280
  $$;
1218
2281
  revoke execute on function ${bump}() from public, anon, authenticated;
1219
2282
  ${groups.map(([, group]) => {
1220
- const target = table(group.table);
2283
+ const target = quoteQualified(group.table);
1221
2284
  return `drop trigger if exists ${quoteIdent(VERSION_TRIGGER)} on ${target};
1222
2285
  create trigger ${quoteIdent(VERSION_TRIGGER)}
1223
2286
  after update or delete on ${target}
1224
2287
  for each row execute function ${bump}();`;
1225
2288
  }).join("\n")}`;
1226
2289
  }
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
2290
  //#endregion
1356
- //#region src/cli/supabase-hook-manifest.ts
2291
+ //#region src/cli/supabase-hook/manifest.ts
1357
2292
  function helperList(parts, types) {
1358
2293
  const client = ["authenticated"];
1359
2294
  return [helperEntry(HELPERS.has, "p_grant text", "boolean", client), ...parts.scopes.flatMap((scope) => {
@@ -1380,18 +2315,21 @@ function helperEntry(name, args, returns, execute) {
1380
2315
  */
1381
2316
  function trustedHelpers(parts, config, types, tenantType, policy) {
1382
2317
  const database = resolveAuthorize(config) === "database";
1383
- const custom = database && config.rls?.customRoles === true;
2318
+ const custom = database && customRolesOn(config.rls);
1384
2319
  const everyFor = parts.scopes.every((scope) => parts.helpers.memberFor.includes(scope.name));
1385
2320
  const client = ["authenticated"];
1386
2321
  const scopeTypes = parts.scopes.map((scope) => [scope.name, types.get(scope.name) ?? "uuid"]);
1387
2322
  const forUser = database ? [
1388
2323
  helperEntry(HELPERS.hasFor, "p_user uuid, p_grant text", "boolean", []),
1389
2324
  ...scopeTypes.map(([scope, type]) => helperEntry(permittedForHelper(scope), "p_user uuid, p_grant text", `setof ${type}`, [])),
1390
- ...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", []))
2325
+ ...scopeTypes.map(([scope, type]) => helperEntry(permissionKeysForHelper(scope), `p_user uuid, p_id ${type}`, "setof text", [])),
2326
+ ...scopeTypes.filter(([scope]) => parts.helpers.holders.includes(scope)).map(([scope, type]) => helperEntry(permittedUsersHelper(scope), `p_permission text, p_id ${type}`, "setof uuid", []))
1391
2327
  ] : [];
1392
2328
  const keyed = [
1393
2329
  helperEntry(USER_ID_HELPER, "", "uuid", client),
1394
2330
  ...config.rls?.apiKeys === void 0 ? [] : [helperEntry(API_KEY_ALLOWS, "p_grant text", "boolean", client)],
2331
+ ...readOnlyActorKinds(config.rls?.readOnlyActors) === void 0 ? [] : [helperEntry(ACTOR_MAY_WRITE, "", "boolean", client)],
2332
+ ...!config.rls?.approvals ? [] : [helperEntry(IS_APPROVER, typeof config.rls.approvals === "object" && config.rls.approvals.delegations === true ? "p_approvers jsonb, p_tenant text, p_requester text, p_resource text" : "p_approvers jsonb, p_tenant text", "boolean", client)],
1395
2333
  ...forUser
1396
2334
  ];
1397
2335
  if (!(policy !== void 0 && (ownershipRules(policy, parts.scopes)?.assigns.length ?? 0) > 0)) return keyed;
@@ -1410,7 +2348,7 @@ function trustedHelpers(parts, config, types, tenantType, policy) {
1410
2348
  function activeRowManifest(row) {
1411
2349
  const keep = "keep" in row ? keptKeys(row) : [];
1412
2350
  return {
1413
- table: qualifiedTable(row.table),
2351
+ table: qualifySqlTable(row.table),
1414
2352
  id: row.id,
1415
2353
  ...row.disabledAt === void 0 ? {} : { disabledAt: row.disabledAt },
1416
2354
  ...row.status === void 0 ? {} : { status: row.status },
@@ -1425,9 +2363,9 @@ function rlsSettings(parts, config) {
1425
2363
  const scopes = Object.entries(suspension?.scopes ?? {});
1426
2364
  const assigned = rls?.assignments === void 0 ? void 0 : guardedTables(config, parts.scopes);
1427
2365
  return {
1428
- customRoles: resolveAuthorize(config) === "database" && rls?.customRoles === true,
2366
+ customRoles: resolveAuthorize(config) === "database" && customRolesOn(rls),
1429
2367
  ...parts.roles === void 0 ? {} : { roles: {
1430
- table: qualifiedTable(parts.roles.table),
2368
+ table: qualifySqlTable(parts.roles.table),
1431
2369
  user: { column: parts.roles.user },
1432
2370
  role: parts.roles.role
1433
2371
  } },
@@ -1537,142 +2475,28 @@ function defaultOut(config) {
1537
2475
  return config.supabase?.hook?.out ?? "supabase/permdock-hook.sql";
1538
2476
  }
1539
2477
  //#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
2478
+ //#region src/cli/supabase-hook/supabase-hook.ts
1655
2479
  const SUPABASE_HELP = `permdock supabase hook generate | inspect
1656
2480
 
1657
- hook generate [--out supabase/permdock-hook.sql] [--check] [--db <url>]
2481
+ hook generate [--out <file>] [--check] [--db-url-stdin] [--schemas pg-delta|migra|migrations]
1658
2482
  [--active-from app_metadata.active_<scope>|<table>.<column>]
1659
2483
  [--budget 1024] [--schema public] [--grants-out <file>|-]
1660
2484
  inspect [--json] [--out [permdock.manifest.json]] [--check]
1661
2485
 
1662
- Reads supabase.hook from permdock.config.ts: the fromTable / fromJunction sources the app
2486
+ Reads supabase.hook from permdock.config.ts: the tableMembershipSource / junctionMembershipSource sources the app
1663
2487
  passes as memberships. hook generate emits custom_access_token_hook(jsonb), the grants it
1664
- needs (the ones db diff drops in --grants-out, for declarative schemas), the permdock_authz_version table
1665
- and its triggers. Never grants anything to
2488
+ needs, the permdock_authz_version table and its triggers. Under pg-delta (the default) --out defaults to
2489
+ supabase/schemas/<schema>/functions/custom_access_token_hook.sql and the grants stay in it; under
2490
+ --schemas migra, --grants-out takes the privileges supabase db diff drops. Never grants anything to
1666
2491
  service_role unless supabase.hook.api is set, which grants its wrappers to it. inspect prints the manifest: helper schema, names and signatures, tenant claim,
1667
2492
  budget, the claims the hook writes, the membership sources and the columns that decide them.
1668
2493
  --out writes it as JSON, to permdock.manifest.json without a path; --check exits 1 when that
1669
2494
  file differs.
1670
2495
  `;
1671
- const MANIFEST_FILE = "permdock.manifest.json";
1672
- /** `supabase.hook.out`, else pg-delta's per-schema path, else `supabase/permdock-hook.sql`. */
1673
- function hookOut(cwd, config, schema) {
1674
- const pgDelta = supabaseConfig(cwd).pgDelta;
1675
- return config.supabase?.hook?.out !== void 0 || pgDelta === void 0 ? defaultOut(config) : pgDeltaPath(pgDelta.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
2496
+ /** `supabase.hook.out`, else pg-delta's per-schema path under pg-delta, else `supabase/permdock-hook.sql`. */
2497
+ function hookOut(cwd, config, schema, flag) {
2498
+ const schemas = resolveSchemas(cwd, config, flag);
2499
+ return config.supabase?.hook?.out !== void 0 || schemas.mode !== "pg-delta" ? defaultOut(config) : pgDeltaPath(schemas.schemaDir, "hook", schema ?? config.supabase?.hook?.schema ?? config.rls?.schema ?? "permdock");
1676
2500
  }
1677
2501
  /**
1678
2502
  * The manifest `supabase inspect` prints. With `policy`, `rls.helpers` also
@@ -1680,7 +2504,14 @@ function hookOut(cwd, config, schema) {
1680
2504
  * `assigns`.
1681
2505
  */
1682
2506
  function supabaseHookManifest(scopes, config, overrides = {}, policy) {
1683
- return manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
2507
+ const manifest = manifestOf(hookParts(scopes, config, overrides), overrides.out ?? defaultOut(config), config, policy);
2508
+ return overrides.schemas === void 0 ? manifest : {
2509
+ ...manifest,
2510
+ hook: {
2511
+ ...manifest.hook,
2512
+ schemas: overrides.schemas
2513
+ }
2514
+ };
1684
2515
  }
1685
2516
  /**
1686
2517
  * The hook migration. With `grantsOut`, the statements `supabase db diff`
@@ -1745,8 +2576,7 @@ function inspectText(manifest) {
1745
2576
  function manifestFile(cwd, rel, manifest, check) {
1746
2577
  const path = resolve(cwd, rel);
1747
2578
  if (!check) {
1748
- mkdirSync(dirname(path), { recursive: true });
1749
- writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`);
2579
+ writeFileEnsuringDir(path, formatManifestJson(manifest));
1750
2580
  return {
1751
2581
  code: 0,
1752
2582
  output: `wrote ${rel}`
@@ -1758,14 +2588,14 @@ function manifestFile(cwd, rel, manifest, check) {
1758
2588
  };
1759
2589
  let onDisk;
1760
2590
  try {
1761
- onDisk = JSON.parse(readFileSync(path, "utf8"));
2591
+ onDisk = readJsonFile(path);
1762
2592
  } catch {
1763
2593
  return {
1764
2594
  code: 1,
1765
2595
  output: `supabase manifest drift: ${rel} is not JSON`
1766
2596
  };
1767
2597
  }
1768
- const found = typeof onDisk === "object" && onDisk !== null && !Array.isArray(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
2598
+ const found = isRecord(onDisk) ? Object.fromEntries(Object.entries(onDisk)) : {};
1769
2599
  const changed = [.../* @__PURE__ */ new Set([...Object.keys(manifest), ...Object.keys(found)])].filter((key) => JSON.stringify(Reflect.get(manifest, key)) !== JSON.stringify(found[key]));
1770
2600
  return changed.length === 0 ? {
1771
2601
  code: 0,
@@ -1778,12 +2608,18 @@ function manifestFile(cwd, rel, manifest, check) {
1778
2608
  async function loadScopes(cwd, config) {
1779
2609
  return scopeList((await loadHookPolicy(cwd, config)).scopes);
1780
2610
  }
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"]));
2611
+ function loadHookPolicy(cwd, config) {
2612
+ return requirePolicy(cwd, config.policy, "supabase hook generate needs policy in permdock.config.ts");
1784
2613
  }
1785
2614
  async function runSupabase(input) {
1786
2615
  const [area, action] = input.rest;
2616
+ const badSchemas = schemasRefusal("supabase", input.schemas);
2617
+ if (badSchemas !== void 0) return {
2618
+ code: 2,
2619
+ output: badSchemas
2620
+ };
2621
+ const flag = isSupabaseSchemas(input.schemas) ? input.schemas : void 0;
2622
+ const schemas = resolveSchemas(input.cwd, input.config, flag);
1787
2623
  const overrides = Object.fromEntries(Object.entries({
1788
2624
  activeFrom: input.activeFrom,
1789
2625
  budget: input.budget,
@@ -1793,7 +2629,8 @@ async function runSupabase(input) {
1793
2629
  const policy = await loadHookPolicy(input.cwd, input.config);
1794
2630
  const manifest = supabaseHookManifest(scopeList(policy.scopes), input.config, {
1795
2631
  ...overrides,
1796
- out: hookOut(input.cwd, input.config, input.schema)
2632
+ out: hookOut(input.cwd, input.config, input.schema, flag),
2633
+ schemas: schemas.mode
1797
2634
  }, policy);
1798
2635
  if (input.out !== void 0 || input.check) return manifestFile(input.cwd, typeof input.out === "string" ? input.out : MANIFEST_FILE, manifest, input.check);
1799
2636
  return {
@@ -1805,8 +2642,12 @@ async function runSupabase(input) {
1805
2642
  code: 2,
1806
2643
  output: SUPABASE_HELP
1807
2644
  };
2645
+ if (input.grantsOut !== void 0 && schemas.mode !== "migra") return {
2646
+ code: 2,
2647
+ output: `supabase hook generate --grants-out is for --schemas migra: ${schemas.mode === "pg-delta" ? "pg-delta keeps the grants in the hook file" : "a hand-written migration keeps its grants"}, so drop --grants-out`
2648
+ };
1808
2649
  const { sql, grants, manifest } = supabaseHookSql(await loadScopes(input.cwd, input.config), input.config, overrides, grantsLabel(input.grantsOut));
1809
- const outRel = typeof input.out === "string" ? input.out : hookOut(input.cwd, input.config, input.schema);
2650
+ const outRel = typeof input.out === "string" ? input.out : hookOut(input.cwd, input.config, input.schema, flag);
1810
2651
  const outPath = resolve(input.cwd, outRel);
1811
2652
  const hook = input.config.supabase?.hook;
1812
2653
  const grantsFile = input.grantsOut === void 0 ? [] : [{
@@ -1834,9 +2675,8 @@ async function runSupabase(input) {
1834
2675
  output: `supabase hook drift: ${drift.join("; ")}`
1835
2676
  };
1836
2677
  }
1837
- mkdirSync(dirname(outPath), { recursive: true });
1838
- writeFileSync(outPath, sql);
1839
- const grantsWritten = writeSqlFiles(input.cwd, grantsFile);
2678
+ writeFileEnsuringDir(outPath, sql);
2679
+ const grantsWritten = writeOutputFiles(input.cwd, grantsFile);
1840
2680
  const placed = {
1841
2681
  ...manifest,
1842
2682
  hook: {
@@ -1857,4 +2697,4 @@ async function runSupabase(input) {
1857
2697
  };
1858
2698
  }
1859
2699
  //#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 };
2700
+ export { pd045 as _, supabaseHookManifest as a, approvalsSql as c, resolveAuthorize as d, pd039 as f, attrsPlan as g, schemasRefusal as h, runSupabase as i, parseRbacAuthorize as l, resolveSchemas as m, hookOut as n, supabaseHookSql as o, isSupabaseSchemas as p, loadScopes as r, permissionHoldersSql as s, grantsLabel as t, rbacScaffold as u, readSupabaseConfig as v, supabaseConfig as y };