permdock 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/dist/a2a/index.d.ts +7 -5
  2. package/dist/a2a/index.js +14 -21
  3. package/dist/ai-sdk/index.d.ts +18 -6
  4. package/dist/ai-sdk/index.js +8 -5
  5. package/dist/app-data-CV8pqqMi.js +59 -0
  6. package/dist/approvals/index.d.ts +2 -2
  7. package/dist/approvals/index.js +3 -3
  8. package/dist/{approvals-DbYAuNkG.js → approvals-DZL76lKW.js} +35 -19
  9. package/dist/{approvers-DT4AVyIg.js → approvers-CqPOLD-o.js} +1 -1
  10. package/dist/{arazzo-BAgw9ap6.js → arazzo-C4BcskbX.js} +6 -5
  11. package/dist/{arazzo-C9chp3ST.js → arazzo-CD8d0WLb.js} +2 -2
  12. package/dist/authzen/index.d.ts +3 -3
  13. package/dist/authzen/index.js +161 -23
  14. package/dist/better-auth/index.d.ts +2 -2
  15. package/dist/better-auth/index.js +6 -19
  16. package/dist/better-supabase/index.d.ts +112 -87
  17. package/dist/better-supabase/index.js +257 -156
  18. package/dist/{bind-C_ob3LKa.d.ts → bind-BzqG8SMb.d.ts} +16 -5
  19. package/dist/{bind-CuzGytRf.js → bind-kk-uyCwH.js} +14 -6
  20. package/dist/build-instance-DIIV9DbC.js +51 -0
  21. package/dist/{canonical-json-DemiK4Vp.js → canonical-json-CFKyFOke.js} +1 -3
  22. package/dist/{capability-2kYTMWDv.d.ts → capability-CksyCWDH.d.ts} +2 -2
  23. package/dist/{capability-4qPdO9gk.js → capability-txATtKV4.js} +3 -2
  24. package/dist/catalog/index.d.ts +1 -1
  25. package/dist/catalog/index.js +1 -1
  26. package/dist/catalog-6yWAX6Ps.js +2 -0
  27. package/dist/{catalog-CYj-mX2O.js → catalog-WHG4kBq7.js} +2 -2
  28. package/dist/{catalog-doc-DdGm82XT.js → catalog-doc-DWtg0gGR.js} +17 -10
  29. package/dist/{catalog-DWF5cYqB.js → catalog-tZUyOfOv.js} +4 -4
  30. package/dist/claude-agent/index.d.ts +4 -4
  31. package/dist/claude-agent/index.js +4 -5
  32. package/dist/clerk/index.d.ts +3 -3
  33. package/dist/clerk/index.js +8 -33
  34. package/dist/cli/bin.js +1 -1
  35. package/dist/cli/index.d.ts +3 -3
  36. package/dist/cli/index.js +3 -3
  37. package/dist/cloud/index.d.ts +4 -4
  38. package/dist/cloud/index.js +9 -15
  39. package/dist/{cloud-CfwZ39Rl.js → cloud-C7bSDgX4.js} +2 -2
  40. package/dist/{cloud-DWLXO2af.js → cloud-PeBN3IEn.js} +5 -5
  41. package/dist/{collect-DSTE3ml9.js → collect-2SrB7lfk.js} +6 -8
  42. package/dist/collect-Bw2Jdq1q.js +2 -0
  43. package/dist/{collect-CHB22Sx4.js → collect-CQx4hX4N.js} +3 -3
  44. package/dist/{commands-BeaGNj_o.js → commands-Cpip4ULx.js} +13 -13
  45. package/dist/{compact-y6vfu2_f.js → compact-CAf557ZB.js} +11 -1
  46. package/dist/compile/index.d.ts +1 -1
  47. package/dist/compile/index.js +1 -1
  48. package/dist/{compile-Ba4blROR.js → compile-CcEu2ZQ1.js} +6 -5
  49. package/dist/{compile-BkRyBDBw.d.ts → compile-CtTP7eur.d.ts} +2 -2
  50. package/dist/{config-BJiM-xAm.js → config-BIjREpyE.js} +4 -4
  51. package/dist/{config-ir40y8m0.js → config-DLt6BvV7.js} +2 -2
  52. package/dist/{config-report-CdCgbRnV.js → config-report--Xb9APJS.js} +5 -6
  53. package/dist/{connection-CU8MQxRi.js → connection-DUsczpB5.js} +3 -2
  54. package/dist/constants-DcJABbLW.js +20 -0
  55. package/dist/{context-Bcy85rGi.js → context-DieZVJXu.js} +16 -5
  56. package/dist/convex/index.d.ts +4 -4
  57. package/dist/convex/index.js +14 -16
  58. package/dist/create-BBO24vj6.js +324 -0
  59. package/dist/{credential-8P81ff4u.js → credential-kue0qpbm.js} +4 -4
  60. package/dist/deciding-columns-Sq12mEA6.js +123 -0
  61. package/dist/delegation-input-FTxMK3ji.js +19 -0
  62. package/dist/denied-text-DfRk15uw.d.ts +19 -0
  63. package/dist/{describe-DMYiCjJV.d.ts → describe-C3ldA9os.d.ts} +11 -2
  64. package/dist/{describe-B_gVZEVi.js → describe-Cgkp7eP0.js} +5 -4
  65. package/dist/{diff-C9RDjjhs.js → diff-Cnghh_Ip.js} +2 -2
  66. package/dist/{diff-2VaeChzX.js → diff-rpqoe7Ag.js} +9 -9
  67. package/dist/{doctor-Djh6JDEk.js → doctor-CuVJvhV_.js} +2 -2
  68. package/dist/doctor-owlv6ddh.js +2166 -0
  69. package/dist/drizzle/index.d.ts +2 -2
  70. package/dist/drizzle/index.js +6 -14
  71. package/dist/elysia/index.d.ts +4 -4
  72. package/dist/elysia/index.js +7 -7
  73. package/dist/{errors-DPdqI50v.d.ts → errors-OD_4c__H.d.ts} +2 -2
  74. package/dist/{errors-Cz5zn0mW.js → errors-drnfxYtm.js} +3 -3
  75. package/dist/{evaluations-tz6D9A2G.js → evaluations-C9YJsv3H.js} +45 -7
  76. package/dist/eve/index.d.ts +5 -5
  77. package/dist/eve/index.js +5 -5
  78. package/dist/express/index.d.ts +4 -4
  79. package/dist/express/index.js +8 -7
  80. package/dist/fastify/index.d.ts +15 -5
  81. package/dist/fastify/index.js +9 -29
  82. package/dist/fields-fwfXN01n.js +1016 -0
  83. package/dist/{files-DWUN9PzI.js → files-D_JIzUrP.js} +12 -3
  84. package/dist/{fixtures-ht28Cplr.js → fixtures-h2eG2OZV.js} +5 -6
  85. package/dist/{from-snapshot-CTLijvdm.js → from-snapshot-7REQZe0H.js} +32 -22
  86. package/dist/{generate-CH7ThJ14.js → generate-CNU1pIOo.js} +1 -3
  87. package/dist/grantee-CXY-0C87.js +254 -0
  88. package/dist/grantee-conditions-MQs5NIuX.js +270 -0
  89. package/dist/grantee-match-fxYp9Nmt.js +141 -0
  90. package/dist/{helpers-H_KARDVC.js → helpers-C9YmyBIa.js} +7 -6
  91. package/dist/hono/index.d.ts +4 -4
  92. package/dist/hono/index.js +8 -8
  93. package/dist/hosted-CBHgkzDl.js +655 -0
  94. package/dist/{http-SO7qGttf.js → http-BBoKefeS.js} +35 -2
  95. package/dist/{http-BIhmLfCc.js → http-BEbdMZ05.js} +2 -24
  96. package/dist/{rls-import-4lLW_pbb.js → import-BFIlhetD.js} +86 -80
  97. package/dist/{openapi-import-cmwetbzw.js → import-UHk1N7vH.js} +6 -7
  98. package/dist/{index-C8z4RjQV.d.ts → index-BwMe94Xq.d.ts} +1 -1
  99. package/dist/{index-0nNqHspG.d.ts → index-DgM7w96K.d.ts} +2 -2
  100. package/dist/{index-C7Tw7MaW.d.ts → index-Dz7didpC.d.ts} +3 -3
  101. package/dist/{index-BNc7Wer_.d.ts → index-UdVQgq48.d.ts} +105 -38
  102. package/dist/index.d.ts +15 -13
  103. package/dist/index.js +22 -22
  104. package/dist/{instance-options-jARdwfPN.d.ts → instance-options-plkrsAog.d.ts} +1 -1
  105. package/dist/{json-schema-BsAflUGW.js → json-schema-DBkdUuCO.js} +1 -3
  106. package/dist/jwt/index.d.ts +4 -4
  107. package/dist/jwt/index.js +13 -13
  108. package/dist/{kernel-_RRHmnMY.js → kernel-CB3LrIJM.js} +81 -32
  109. package/dist/kysely/index.d.ts +2 -2
  110. package/dist/kysely/index.js +6 -14
  111. package/dist/{live-options-H2eG8dfJ.js → live-options-BaAFCIiu.js} +16 -3
  112. package/dist/load-B_shHrmd.js +2 -0
  113. package/dist/{load-DhWEB9P1.js → load-C3_oTHyO.js} +11 -3
  114. package/dist/{manifest-iaoBXHKc.js → manifest-BngWKryL.js} +5 -18
  115. package/dist/{map-claims-Z4QZOiFF.js → map-claims-AVlBaJoa.js} +7 -16
  116. package/dist/{map-error-pgUWNNtG.js → map-error-BE27PYkE.js} +5 -5
  117. package/dist/{markers-B0hYLvMD.js → markers-CiwAOZtO.js} +6 -2
  118. package/dist/{may-use-DndQ5tQ-.js → may-use-BOn_dR_w.js} +4 -3
  119. package/dist/mcp/index.d.ts +26 -9
  120. package/dist/mcp/index.js +456 -446
  121. package/dist/{rls-migrate-CN4TlJTO.js → migrate-BwlB5-Wk.js} +75 -15
  122. package/dist/nest/index.d.ts +4 -4
  123. package/dist/nest/index.js +11 -12
  124. package/dist/next/index.d.ts +31 -8
  125. package/dist/next/index.js +29 -33
  126. package/dist/next/plugin.d.ts +1 -1
  127. package/dist/next/plugin.js +1 -1
  128. package/dist/node/index.d.ts +4 -4
  129. package/dist/node/index.js +4 -4
  130. package/dist/ocsf-uYFXGVw2.js +115 -0
  131. package/dist/openai/index.d.ts +5 -5
  132. package/dist/openai/index.js +4 -4
  133. package/dist/openapi/index.d.ts +3 -3
  134. package/dist/openapi/index.js +1 -1
  135. package/dist/{openapi-CqqEbHsL.js → openapi-BJdBmYDG.js} +19 -23
  136. package/dist/{openapi-BqRBvAng.js → openapi-DBzHQbov.js} +2 -5
  137. package/dist/{openapi-CQTsgKoB.js → openapi-DcQ1Kg40.js} +3 -3
  138. package/dist/orpc/index.d.ts +5 -5
  139. package/dist/orpc/index.js +9 -11
  140. package/dist/otel/index.d.ts +2 -2
  141. package/dist/otel/index.js +26 -23
  142. package/dist/output-files-CgIsvdSa.js +140 -0
  143. package/dist/{rls-ownership-DcYlZ09B.js → ownership-BgnbOJFT.js} +387 -379
  144. package/dist/{package-root-zGkxO3oN.js → package-root-DtNnpmUd.js} +3 -2
  145. package/dist/{parse-BuyjxhZV.js → parse-CVFQHk6c.js} +3 -3
  146. package/dist/{parse-local-manifest-BrMozJCo.js → parse-local-manifest-C-q02Pal.js} +2 -4
  147. package/dist/{parse-local-manifest-JLnaBH0S.d.ts → parse-local-manifest-DvYUR0QL.d.ts} +1 -1
  148. package/dist/pdp/index.d.ts +2 -2
  149. package/dist/pdp/index.js +8 -9
  150. package/dist/{permdock-CbgvLnuo.js → permdock-L26udHSk.js} +1387 -1220
  151. package/dist/{rls-fields-CZOMlwHl.js → permission-keys-B26jeyUv.js} +1268 -2053
  152. package/dist/permission-registry-BsblpB-z.js +385 -0
  153. package/dist/{permissions-DttnqqxC.d.ts → permission-types-Dg39z9Kv.d.ts} +59 -31
  154. package/dist/{permissions-RJrovw98.js → permissions-CCJ6lmxB.js} +3 -3
  155. package/dist/{permissions-CDgvKG5L.d.ts → permissions-COH-KRgT.d.ts} +2 -2
  156. package/dist/{policy-IB6yxpC3.d.ts → policy-types-sp9P1iIV.d.ts} +661 -582
  157. package/dist/{policy-D2W7uv19.d.ts → policy-ur0BBK_e.d.ts} +2 -2
  158. package/dist/{powersync-DE19b9dT.js → powersync-BFSbSElQ.js} +2 -2
  159. package/dist/powersync-DCDgZgfZ.js +2 -0
  160. package/dist/{powersync-Dv1_aWF7.js → powersync-cZe4kzhL.js} +26 -27
  161. package/dist/prisma/index.d.ts +1 -1
  162. package/dist/prisma/index.js +6 -12
  163. package/dist/{problem-B43ztKIm.js → problem-CnWraVhC.js} +8 -7
  164. package/dist/{problem-details-_AMOYz-w.js → problem-details-DECUnXAn.js} +2 -2
  165. package/dist/{problem-details-DX075l9V.d.ts → problem-details-rIIo8B2C.d.ts} +1 -1
  166. package/dist/{protected-BdnIEFkY.d.ts → protected-B3_P7xuw.d.ts} +9 -6
  167. package/dist/{protected-utU-SLuy.js → protected-BN7BUdY1.js} +20 -8
  168. package/dist/{provider-CSQd7m92.js → provider-Bi-0HNL3.js} +23 -10
  169. package/dist/{provider-DZY281jO.d.ts → provider-kwHC9CZD.d.ts} +1 -1
  170. package/dist/{provider-store-Cfbvo4DE.js → provider-store-Ctt-81Ni.js} +6 -3
  171. package/dist/react/index.d.ts +7 -7
  172. package/dist/react/index.js +5 -5
  173. package/dist/react/provider-client.d.ts +1 -1
  174. package/dist/react/provider-client.js +1 -1
  175. package/dist/react-native/index.d.ts +20 -11
  176. package/dist/react-native/index.js +33 -17
  177. package/dist/{refs-KYb4ntaz.js → refs-f3imMjNW.js} +1 -1
  178. package/dist/relation-walk-BXnlLtg2.js +186 -0
  179. package/dist/relations-CvmSpG67.js +163 -0
  180. package/dist/{replay-CYyiLiwO.d.ts → replay-COpmZUfx.d.ts} +1 -1
  181. package/dist/request-context-BVM7aWNs.js +71 -0
  182. package/dist/request-context-C2_1lchh.d.ts +5 -0
  183. package/dist/resource-relations-D1Un3pcz.js +243 -0
  184. package/dist/retire-BFcxL6un.js +240 -0
  185. package/dist/{rls-P-BeBx0n.js → rls-55DQTuf5.js} +14 -2
  186. package/dist/{rls-DiinlRML.js → rls-BEktUaqg.js} +884 -484
  187. package/dist/{policy-xyWg7QdR.js → role-builder-B4GbKM-i.js} +235 -541
  188. package/dist/{rpc-DL9Sqvb1.js → rpc-nYC3cR_u.js} +15 -5
  189. package/dist/{run-BWc9qqvE.js → run-DMcNW_8T.js} +3 -3
  190. package/dist/{runtime-CB8gQFvS.js → runtime-D_tMXnsq.js} +1 -1
  191. package/dist/{saas-DhpJjnBA.js → saas-DtaA4JuW.js} +9 -8
  192. package/dist/{openapi-schema--ARjyfQr.js → schema-Bol6gqXA.js} +3 -5
  193. package/dist/{schema-D3MnDMxa.js → schema-DjQBcAP9.js} +4 -2
  194. package/dist/scim/index.d.ts +1 -1
  195. package/dist/scim/index.js +1 -1
  196. package/dist/{scim-BFGBBAqR.js → scim-vFR1O5oG.js} +6 -12
  197. package/dist/{scopes-CVdPrWN-.js → scopes-tvpa34ep.js} +10 -2
  198. package/dist/server/index.d.ts +18 -8
  199. package/dist/server/index.js +8 -8
  200. package/dist/{server-DlZ28Oz5.js → server-ZYEHtNHS.js} +4 -4
  201. package/dist/{create-B1d519XK.d.ts → server-types-DdqBwZN_.d.ts} +69 -33
  202. package/dist/shared-1uhI-e-b.js +294 -0
  203. package/dist/{shared-DRu8VFVM.js → shared-JhMeOsDF.js} +1 -1
  204. package/dist/{sink-Bd9PSyfq.js → sink-BTtmfPnE.js} +1 -1
  205. package/dist/{sink-B1fZzIMf.d.ts → sink-CPN4zLfs.d.ts} +1 -1
  206. package/dist/{skills-Biq5uup3.js → skills-BZdRQkxs.js} +4 -3
  207. package/dist/{skills-C5CbhhpG.js → skills-Dle4ZoLF.js} +2 -2
  208. package/dist/{snapshot-Enxd0n-s.js → snapshot-Dc9CHPME.js} +116 -13
  209. package/dist/{snapshot-cache-D6jLelln.js → snapshot-cache-Cod40D-X.js} +1 -1
  210. package/dist/{snapshot-cache-B-LxUahj.d.ts → snapshot-cache-J5T7t_vD.d.ts} +1 -1
  211. package/dist/solid/index.d.ts +22 -22
  212. package/dist/solid/index.js +40 -10
  213. package/dist/{sources-4p0-4xQm.js → sources-B1-b-R0t.js} +25 -26
  214. package/dist/{sources-BdOYLw9j.d.ts → sources-Dljy06zL.d.ts} +6 -2
  215. package/dist/{sql-xdg5f4Fi.js → sql-t8teWOfo.js} +19 -3
  216. package/dist/{src-BDwEZWrZ.js → src-Bb7no-hS.js} +194 -123
  217. package/dist/ssf/index.d.ts +3 -3
  218. package/dist/ssf/index.js +58 -61
  219. package/dist/store-9KO4BlkB.d.ts +147 -0
  220. package/dist/{store-DMAWwAJV.js → store-ILkLUj_o.js} +4 -4
  221. package/dist/{store-options-ByhZq1jV.js → store-options-CbUHDoSe.js} +16 -7
  222. package/dist/{stores-BT2pSCub.js → stores-BHXdSbw9.js} +7 -3
  223. package/dist/stores-Cxz_80g-.d.ts +31 -0
  224. package/dist/{stream-DDpvynSB.js → stream-BMui98KK.js} +2 -1
  225. package/dist/{stream-D1RXubw2.d.ts → stream-Dwsg6WwE.d.ts} +2 -2
  226. package/dist/{subject-settings-BwBmCdTR.js → subject-settings-CkEwrw3W.js} +23 -5
  227. package/dist/supabase/index.d.ts +4 -4
  228. package/dist/supabase/index.js +3 -3
  229. package/dist/supabase/middleware.d.ts +11 -5
  230. package/dist/supabase/middleware.js +10 -7
  231. package/dist/{supabase-C0qESOa-.js → supabase-C1TWlnYt.js} +36 -52
  232. package/dist/{supabase-s4o9s1O1.js → supabase-Dbqr141s.js} +2 -2
  233. package/dist/supabase-hook-BULg7Ut_.js +2 -0
  234. package/dist/{supabase-hook-ntETYCQu.js → supabase-hook-Cva3ikXz.js} +730 -781
  235. package/dist/{supautils-u8jXTGrt.js → supautils-B6HYL9eL.js} +2 -2
  236. package/dist/svelte/Protected.svelte +9 -2
  237. package/dist/svelte/index.d.ts +6 -6
  238. package/dist/svelte/index.js +25 -11
  239. package/dist/svelte/runtime.d.ts +10 -3
  240. package/dist/svelte/runtime.js +4 -3
  241. package/dist/svelte/source.d.ts +6 -6
  242. package/dist/svelte/source.js +4 -4
  243. package/dist/{validity-uOp8UpYn.js → tenancy-DshjqY0n.js} +3 -39
  244. package/dist/terminal/index.d.ts +5 -5
  245. package/dist/terminal/index.js +18 -34
  246. package/dist/testing/index.d.ts +89 -77
  247. package/dist/testing/index.js +1276 -1262
  248. package/dist/testing/saas/index.d.ts +4 -4
  249. package/dist/testing/saas/index.js +2 -2
  250. package/dist/testing/saas/permissions.d.ts +1 -1
  251. package/dist/testing/saas/permissions.js +1 -1
  252. package/dist/thenable-QSmvt-vZ.js +65 -0
  253. package/dist/{token-CIWxJOCh.js → token-Bx9qy30p.js} +1 -1
  254. package/dist/tool-refusal-CQ7OEia9.js +41 -0
  255. package/dist/trpc/index.d.ts +5 -5
  256. package/dist/trpc/index.js +7 -8
  257. package/dist/{types-DbjRN55U.d.ts → types-7rJVLWe5.d.ts} +82 -4
  258. package/dist/{types-aqOHTHcp.d.ts → types-Br9IXUBG.d.ts} +1 -1
  259. package/dist/types-ByUo56un.d.ts +88 -0
  260. package/dist/{types-Cq_H8N_X.d.ts → types-C0dee0Qf.d.ts} +2 -2
  261. package/dist/{types-DxiCGmmd.d.ts → types-CIlTojhq.d.ts} +2 -2
  262. package/dist/{types-QCuYJh4q.d.ts → types-CUcFZfpU.d.ts} +1 -1
  263. package/dist/{types-C97IskBw.d.ts → types-CqrjB9o6.d.ts} +1 -1
  264. package/dist/{types-BnIEz1sU.d.ts → types-DHrmBJLH.d.ts} +24 -6
  265. package/dist/{types-Dnemwd77.d.ts → types-DpWfQdSh.d.ts} +5 -1
  266. package/dist/{types-Bvc4oWT6.d.ts → types-DvupZE0w.d.ts} +1 -1
  267. package/dist/unplugin/index.d.ts +1 -1
  268. package/dist/unplugin/index.js +1 -1
  269. package/dist/{usage-B0YuTQRO.js → usage-4G6t643F.js} +2 -2
  270. package/dist/{usage-ItS7nLcl.js → usage-CG9nOYZw.js} +7 -7
  271. package/dist/usage-iFLJ0-rr.js +2 -0
  272. package/dist/{validation-error-Ba264OaJ.d.ts → validation-error-BSSw0l_8.d.ts} +1 -1
  273. package/dist/{validation-error-CnjZ4t92.js → validation-error-ZfUHbKvf.js} +1 -1
  274. package/dist/validity-dVrUEPJt.js +62 -0
  275. package/dist/{verifier-BxBo6I44.js → verifier-DMr_Zmb_.js} +51 -15
  276. package/dist/{rls-verify-CMv9H3wV.js → verify-BztUB3GX.js} +668 -239
  277. package/dist/{version-BvacVTZR.js → version-BEogC-rB.js} +1 -1
  278. package/dist/{views-Soe7bGSC.js → views-CJsrBS1f.js} +23 -1
  279. package/dist/{vocabulary-DA8a8zif.d.ts → vocabulary-hhEFLFoL.d.ts} +15 -6
  280. package/dist/vue/index.d.ts +24 -19
  281. package/dist/vue/index.js +24 -8
  282. package/dist/watch-C8TpsAl4.js +2 -0
  283. package/dist/{watch-DVBJ9cBq.js → watch-zeucB7eF.js} +6 -6
  284. package/dist/webmcp/index.d.ts +2 -2
  285. package/dist/webmcp/index.js +13 -37
  286. package/dist/wrap-Cr93qqdP.d.ts +14 -0
  287. package/dist/wrap-DWsU_5Bd.js +26 -0
  288. package/package.json +8 -8
  289. package/schemas/catalog-v1.json +6 -0
  290. package/skills/permdock-credentials/SKILL.md +1 -1
  291. package/skills/permdock-credentials/references/tokens-keys-links.md +7 -2
  292. package/skills/permdock-data/references/queries-and-rls.md +4 -0
  293. package/skills/permdock-tenancy/references/ownership-and-custom-roles.md +1 -0
  294. package/skills/permdock-wire/SKILL.md +2 -2
  295. package/skills/permdock-wire/references/adapters.md +9 -1
  296. package/skills/permdock-wire/references/policy-options.md +10 -1
  297. package/dist/catalog-CRW5Mx_B.js +0 -2
  298. package/dist/collect-DSWOk19R.js +0 -2
  299. package/dist/create-CMwIl-w2.js +0 -253
  300. package/dist/delegation-D8pUEuce.js +0 -103
  301. package/dist/doctor-DvH0IQff.js +0 -1194
  302. package/dist/doctor-collect-CjdO0wQX.js +0 -665
  303. package/dist/evaluation-items-CfCCejQd.js +0 -40
  304. package/dist/fields-BMl1RxoX.js +0 -26
  305. package/dist/grantee-Cv5Sogfv.js +0 -602
  306. package/dist/hosted-DJQw46hn.js +0 -266
  307. package/dist/instance-options-Dhhcnm3y.js +0 -22
  308. package/dist/load-DwJUafYN.js +0 -2
  309. package/dist/map-AcxK9o_D.js +0 -147
  310. package/dist/permissions-DVoF6dMt.js +0 -662
  311. package/dist/powersync-CrBX8hY6.js +0 -2
  312. package/dist/protected-xAr24FEC.d.ts +0 -40
  313. package/dist/relations-cjOL01hZ.js +0 -505
  314. package/dist/rls-graph-CHwx0V8q.js +0 -435
  315. package/dist/rls-shared-Cewd_-zI.js +0 -495
  316. package/dist/store-jOBYpiXC.d.ts +0 -36
  317. package/dist/stores-C0IMQrGI.d.ts +0 -52
  318. package/dist/supabase-config-Bj3iDH2I.js +0 -472
  319. package/dist/supabase-hook-gd3s8bxB.js +0 -2
  320. package/dist/thenable-okc2xDsj.js +0 -10
  321. package/dist/types-CKNSyIf_.d.ts +0 -52
  322. package/dist/usage-DorAJiU_.js +0 -2
  323. package/dist/watch-BwxLTYMZ.js +0 -2
@@ -1,24 +1,24 @@
1
- import { _ as listPermissions, b as resource, n as definePermissions } from "../permissions-DVoF6dMt.js";
2
- import { r as principal } from "../refs-KYb4ntaz.js";
3
- import { _ as memoryLimitStore, a as definePolicy, m as role, t as allow } from "../policy-xyWg7QdR.js";
4
- import { _ as memoryRoleSource, u as snapshotFor } from "../src-BDwEZWrZ.js";
1
+ import { r as principal } from "../refs-f3imMjNW.js";
2
+ import { n as role, r as allow, u as memoryLimitStore } from "../role-builder-B4GbKM-i.js";
3
+ import { c as snapshotFor, g as memoryRoleSource, s as definePermissions } from "../src-Bb7no-hS.js";
5
4
  import { t as memoryRevocationFeed } from "../revocations-Bhr0aMIC.js";
6
- import { a as normalizeMemberships, d as scopeList } from "../scopes-CVdPrWN-.js";
7
- import { n as parseSnapshot, p as evaluateCondition } from "../snapshot-Enxd0n-s.js";
8
- import { i as parsePolicyDocument, r as mergeHostedGrants } from "../hosted-DJQw46hn.js";
9
- import { E as approvalPoliciesFor, b as validateCustomRole, t as createPermDock, v as membershipsClaim } from "../permdock-CbgvLnuo.js";
5
+ import { a as normalizeMemberships, d as scopeList } from "../scopes-tvpa34ep.js";
6
+ import { _ as evaluateCondition, n as parseSnapshot } from "../snapshot-Dc9CHPME.js";
7
+ import { _ as resource, c as listPermissions } from "../permission-registry-BsblpB-z.js";
8
+ import { i as parsePolicyDocument, o as definePolicy, r as mergeHostedGrants } from "../hosted-CBHgkzDl.js";
9
+ import { E as approvalPoliciesFor, b as validateCustomRole, t as createPermDock, v as membershipsClaim } from "../permdock-L26udHSk.js";
10
10
  import { t as byCodePoint } from "../compare-pfyVnBjE.js";
11
- import { n as fromSnapshot } from "../from-snapshot-CTLijvdm.js";
12
- import { o as parseCredential } from "../credential-8P81ff4u.js";
13
- import { o as problemFromDecision } from "../problem-B43ztKIm.js";
14
- import { l as APPROVAL_HEADER, r as memoryApprovalStore } from "../store-DMAWwAJV.js";
15
- import { o as resolveApproval } from "../helpers-H_KARDVC.js";
16
- import { a as parseApiKey } from "../server-DlZ28Oz5.js";
17
- import { S as sha256Hex, n as directoryMembershipSource, r as scimHandler } from "../scim-BFGBBAqR.js";
11
+ import { n as fromSnapshot } from "../from-snapshot-7REQZe0H.js";
12
+ import { o as parseCredential } from "../credential-kue0qpbm.js";
13
+ import { s as problemFromDecision } from "../problem-CnWraVhC.js";
14
+ import { l as APPROVAL_HEADER, r as memoryApprovalStore } from "../store-ILkLUj_o.js";
15
+ import { o as resolveApproval } from "../helpers-C9YmyBIa.js";
16
+ import { a as parseApiKey } from "../server-ZYEHtNHS.js";
17
+ import { S as sha256Hex, n as directoryMembershipSource, r as scimHandler } from "../scim-vFR1O5oG.js";
18
18
  import { t as supabaseMembershipsBudget } from "../budget-NbEtbJC_.js";
19
- import { a as quoteSqlTable, r as quoteSqlIdent, t as SQL_IDENT } from "../sql-xdg5f4Fi.js";
20
- import { i as saasPermissions } from "../permissions-RJrovw98.js";
21
- import { C as saasOrg, E as saasSeed, S as saasMemberships, a as saasProject, g as verifySaasSession, h as signSaasToken, k as saasPolicy, t as saasDoc, w as saasPrincipal, x as saasCustomRoles } from "../saas-DhpJjnBA.js";
19
+ import { a as quoteSqlIdent, s as quoteSqlTable, t as SQL_IDENT } from "../sql-t8teWOfo.js";
20
+ import { i as saasPermissions } from "../permissions-CCJ6lmxB.js";
21
+ import { C as saasOrg, E as saasSeed, S as saasMemberships, a as saasProject, g as verifySaasSession, h as signSaasToken, k as saasPolicy, t as saasDoc, w as saasPrincipal, x as saasCustomRoles } from "../saas-DtaA4JuW.js";
22
22
  import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest";
23
23
  //#region src/testing/describe-policy.ts
24
24
  function isOutcomeCell(value) {
@@ -707,7 +707,7 @@ async function snapshotFixture(policy, subject, options = {}) {
707
707
  return snapshot;
708
708
  }
709
709
  //#endregion
710
- //#region src/testing/http-adapter.ts
710
+ //#region src/testing/http-adapter/domain.ts
711
711
  function project$1(id) {
712
712
  const row = saasSeed.projects.find((candidate) => candidate.id === id);
713
713
  if (row === void 0) throw new Error(`no seed project ${id}`);
@@ -783,6 +783,11 @@ function createDomain$1() {
783
783
  project: (id) => id === void 0 ? null : saasSeed.projects.find((row) => row.id === id) ?? null
784
784
  };
785
785
  }
786
+ async function approverSubject(user, tenant) {
787
+ return (await createPermDock(saasPolicy, saasPrincipal(user, tenant), { tenant })).subject;
788
+ }
789
+ //#endregion
790
+ //#region src/testing/http-adapter/wire.ts
786
791
  function restRequest(base, call) {
787
792
  const headers = new Headers();
788
793
  if (call.authorization !== null) headers.set("authorization", call.authorization);
@@ -847,9 +852,6 @@ function reasonsOf(body) {
847
852
  const denials = field(body, "denials");
848
853
  return Array.isArray(denials) ? denials.map((denial) => field(denial, "reason")) : [];
849
854
  }
850
- async function approverSubject(user, tenant) {
851
- return (await createPermDock(saasPolicy, saasPrincipal(user, tenant), { tenant })).subject;
852
- }
853
855
  const PARALLEL = [
854
856
  {
855
857
  user: "alice",
@@ -976,6 +978,8 @@ async function untilEnd(stream) {
976
978
  };
977
979
  }
978
980
  }
981
+ //#endregion
982
+ //#region src/testing/http-adapter/scenarios.ts
979
983
  /**
980
984
  * Real-life HTTP scenarios over the shared SaaS domain. `mount` builds the
981
985
  * application once with the adapter under test; every expectation below is
@@ -1768,1329 +1772,1339 @@ function authzenTodoVectors() {
1768
1772
  };
1769
1773
  }
1770
1774
  //#endregion
1771
- //#region src/testing/jwt-fixtures.ts
1772
- const jwtFixtureJwks = { keys: [{
1773
- crv: "Ed25519",
1774
- x: "79ab4WR6Eb9LkefWpmh5ZlvjXg7wqVGNMwIEHQqduIQ",
1775
- kty: "OKP",
1776
- kid: "2026-09",
1777
- alg: "Ed25519",
1778
- use: "sig"
1779
- }] };
1780
- const jwtFixtureIssuer = "https://login.example.com";
1781
- const jwtFixtureAudience = "https://api.example.com";
1782
- const jwtFixtureTokens = {
1783
- valid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJjbGllbnRfaWQiOiJhcHAiLCJyb2xlcyI6WyJtZW1iZXIiXSwianRpIjoianRpLXZhbGlkIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMDAwMDAwMDB9.ENFMmLSyVde-2s4Gdz4iP7srZvU0Xuy_ZLz-OJGHD_-W1lEayEBeSPTnab0L_TBdj8p5BmkFrT-7VE7o20GbAw",
1784
- expired: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjEwMDAwMDAwMDAsImV4cCI6MTEwMDAwMDAwMH0.9X3d6kH3_2nmrbqMxuoxzFW-wsWJVCe-lR-aQ-MmsGGXlEUBMgQ-4MtAq1UR9lLE0h2b5JrE_GfjzuC04h5xCg",
1785
- wrongAud: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9vdGhlci5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.PgWdzi56J31OwoMqH4ysHzVKSKUDhi-AtHFUp1ewFzi-qGE-tknzrNKnKhDOncVzj0okb_flyy3vQgfzJwG5AQ",
1786
- wrongIss: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2V2aWwuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.tIcLHLa9JLtKz1gqjRGdr5DFbUzRDs5KsoPsJCxspvbBcsFfRFG2G0XJnd1h4QHyZ_5B4OhY3W_jIIsnOPD9Bg",
1787
- unknownKid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoibm9wZSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0.walgG5GvpCVBnkMtkdw-epi5c93JqfvAM_N4EWR9Tqhiw_cnzITnlQyoVfihECmV1fqtFrhwM1RCIRJvd38zAQ",
1788
- none: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjIwMDAwMDAwMDAsImlhdCI6MTcwMDAwMDAwMH0.",
1789
- snapshot: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLXNuYXBzaG90K2p3dCJ9.eyJzbmFwc2hvdCI6eyJ2IjoyLCJpc3N1ZWRBdCI6MTcwMDAwMDAwMCwic3ViamVjdCI6eyJwcmluY2lwYWwiOnsiaWQiOiJ1XzEiLCJyb2xlcyI6WyJtZW1iZXIiXX0sImNvbnRleHQiOnt9fSwicm9sZXMiOlsibWVtYmVyIl0sImdyYW50cyI6W10sInRlbmFudHMiOltdfSwic3ViIjoidV8xIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJzbmFwX2ZpeHR1cmUifQ.4itaYctluoCwU-syytUxArtS_FRyNAti2SZxuSH-nKIMr7-Sb3h1Q3xugn_vPR1eupV4fVNIe6dH27_EiyDvCA",
1790
- approval: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLWFwcHJvdmFsK2p3dCJ9.eyJhcHByb3ZhbCI6eyJ0b2tlbiI6InBkMS5hYmMiLCJwZXJtaXNzaW9uIjoicG9zdC5kZWxldGUiLCJyZXNvdXJjZSI6eyJ0eXBlIjoicG9zdCIsImlkIjoiNDIifSwic3RhdHVzIjoiYXBwcm92ZWQifSwic3ViIjoidV8xIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJhcHJfZml4dHVyZSJ9.UHsoSzQCo68G7ee-EFqdzMLvmqaKlFIMdQKFuqZEM4yUQnJsJJTXbld_aCXcyfN-IdjF9cj2F5EHzsV4nLKbBg",
1791
- decisions: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLWRlY2lzaW9ucytqd3QifQ.eyJldmVudHMiOltdLCJpc3MiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJkZWNfZml4dHVyZSJ9.UZhJd72yFGxHOdjYbzcGYUPgYjvLwOPCtHKxEbLu56SdKSKUDCREXmEBI9WaCPKWjlTBnnIKMYwjG8I8RATJCg"
1792
- };
1793
- //#endregion
1794
- //#region src/testing/conformance.ts
1795
- function testSubjectResolver(resolver, options) {
1796
- it("never throws and fails closed to anonymous", async () => {
1797
- let result;
1798
- try {
1799
- result = await resolver(options.invalid);
1800
- } catch {
1801
- throw new Error("SubjectResolver must not throw");
1802
- }
1803
- expect(result.principal).toBeNull();
1804
- });
1805
- }
1806
- function testMembershipSource(source, options) {
1807
- const query = options.tenant === void 0 ? {} : { tenant: options.tenant };
1808
- it("returns well-formed memberships and fails closed on throw", async () => {
1809
- for (const principal of options.principals) {
1810
- let memberships = [];
1811
- try {
1812
- memberships = await source.membershipsFor(principal, query);
1813
- } catch {
1814
- memberships = [];
1815
- }
1816
- for (const membership of memberships) {
1817
- const shapes = [
1818
- membership.scope !== void 0,
1819
- membership.tenant !== void 0 || membership.team !== void 0,
1820
- membership.on !== void 0
1821
- ].filter(Boolean);
1822
- expect(shapes.length).toBe(1);
1823
- expect(Array.isArray(membership.roles)).toBe(true);
1824
- if (options.policy !== void 0) expect(normalizeMemberships([membership], scopeList(options.policy.scopes))).toHaveLength(1);
1825
- }
1826
- const expected = options.expect?.[principal.id];
1827
- if (expected !== void 0) expect(memberships).toEqual(expected);
1828
- }
1829
- });
1830
- if (source.list !== void 0) it("lists every member of an instance it returns a membership for", async () => {
1831
- for (const principal of options.principals) {
1832
- let memberships = [];
1833
- try {
1834
- memberships = await source.membershipsFor(principal, query);
1835
- } catch {
1836
- continue;
1775
+ //#region src/testing/conformance/approvals.ts
1776
+ function sampleApproval(token) {
1777
+ return {
1778
+ v: 1,
1779
+ token,
1780
+ permission: "post.delete",
1781
+ scope: "post:delete",
1782
+ resource: {
1783
+ type: "post",
1784
+ id: "42"
1785
+ },
1786
+ subject: {
1787
+ principal: {
1788
+ id: "u_1",
1789
+ roles: ["member"]
1790
+ },
1791
+ actor: {
1792
+ id: "agent-1",
1793
+ kind: "eve"
1837
1794
  }
1838
- for (const membership of memberships) {
1839
- if (membership.scope === void 0 || membership.id === void 0) continue;
1840
- const members = await source.list?.({
1841
- scope: membership.scope,
1842
- id: membership.id
1843
- }) ?? [];
1844
- expect(members.some((entry) => entry.principal.id === principal.id && entry.membership.scope === membership.scope && entry.membership.id === membership.id)).toBe(true);
1795
+ },
1796
+ detail: "post.delete requires human approval.",
1797
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1798
+ expiresAt: new Date(Date.now() + 36e5).toISOString(),
1799
+ status: "pending"
1800
+ };
1801
+ }
1802
+ const approver = {
1803
+ principal: {
1804
+ id: "u_9",
1805
+ roles: ["admin"]
1806
+ },
1807
+ context: {}
1808
+ };
1809
+ const secondApprover = {
1810
+ principal: {
1811
+ id: "u_10",
1812
+ roles: ["admin"]
1813
+ },
1814
+ context: {}
1815
+ };
1816
+ const admin = {
1817
+ kind: "role",
1818
+ role: "admin",
1819
+ scope: "global"
1820
+ };
1821
+ function tenantApproval(token, tenant) {
1822
+ const base = sampleApproval(token);
1823
+ return {
1824
+ ...base,
1825
+ subject: {
1826
+ ...base.subject,
1827
+ principal: {
1828
+ id: "u_1",
1829
+ roles: ["member"],
1830
+ tenant
1845
1831
  }
1846
1832
  }
1847
- });
1848
- if (source.version !== void 0) it("reports a finite version or none", async () => {
1849
- for (const principal of options.principals) {
1850
- const version = await source.version?.({ id: principal.id });
1851
- expect(version === void 0 || typeof version === "number" && Number.isFinite(version)).toBe(true);
1852
- }
1853
- });
1833
+ };
1854
1834
  }
1855
- function holderKey(holder) {
1856
- return "group" in holder ? `${holder.group.resource}:${holder.group.id}#${holder.group.relation}` : holder.principal.id;
1835
+ function tenantApprover(tenant) {
1836
+ return {
1837
+ principal: {
1838
+ id: "u_9",
1839
+ roles: [],
1840
+ memberships: [{
1841
+ tenant,
1842
+ roles: ["admin"]
1843
+ }]
1844
+ },
1845
+ context: {}
1846
+ };
1857
1847
  }
1858
- /**
1859
- * A `RelationSource` answers facts only: well-formed chains nearest first and
1860
- * no longer than `depth`, holders with principal ids or group references and
1861
- * numeric instants, a `truncated` chain only when more ancestors exist, and
1862
- * empty answers (never a throw) for an object it does not know. `expect` pins
1863
- * the chains, holders and links for `objects`, keyed `resource:id`,
1864
- * `resource:id#relation` and `resource:id>link`; a group holder is written
1865
- * `resource:id#relation`.
1866
- */
1867
- function testRelationSource(source, options) {
1868
- it("returns chains nearest first, within depth, and truncated only when more exist", async () => {
1869
- for (const object of options.objects) {
1870
- const full = await source.ancestors({
1871
- resource: object.resource,
1872
- id: object.id,
1873
- through: "parent",
1874
- depth: 32
1875
- });
1876
- expect(Array.isArray(full.ancestors)).toBe(true);
1877
- for (const ancestor of full.ancestors) {
1878
- expect(typeof ancestor.id).toBe("string");
1879
- expect(ancestor.id).not.toBe("");
1880
- }
1881
- expect(full.ancestors.length).toBeLessThanOrEqual(32);
1882
- const expected = options.expect?.ancestors?.[`${object.resource}:${object.id}`];
1883
- if (expected !== void 0) expect(full.ancestors.map((ancestor) => ancestor.id)).toEqual(expected);
1884
- if (full.ancestors.length > 1) {
1885
- const short = await source.ancestors({
1886
- resource: object.resource,
1887
- id: object.id,
1888
- through: "parent",
1889
- depth: 1
1890
- });
1891
- expect(short.ancestors.map((ancestor) => ancestor.id)).toEqual([full.ancestors[0]?.id]);
1892
- expect(short.truncated).toBe(true);
1893
- }
1894
- const none = await source.ancestors({
1895
- resource: object.resource,
1896
- id: object.id,
1897
- through: "parent",
1898
- depth: 0
1899
- });
1900
- expect(none.ancestors).toEqual([]);
1901
- }
1848
+ function testApprovalStore(store, options = {}) {
1849
+ it("keeps the existing record when the same call asks again", async () => {
1850
+ await store.create(sampleApproval("dup-token"));
1851
+ await store.resolve("dup-token", {
1852
+ status: "approved",
1853
+ by: approver
1854
+ });
1855
+ await store.create(sampleApproval("dup-token"));
1856
+ expect((await store.get("dup-token"))?.status).toBe("approved");
1902
1857
  });
1903
- it("returns well-formed holders", async () => {
1904
- for (const object of options.objects) {
1905
- if (object.relation === void 0) continue;
1906
- const holders = await source.related({
1907
- resource: object.resource,
1908
- id: object.id,
1909
- relation: object.relation
1910
- });
1911
- expect(Array.isArray(holders)).toBe(true);
1912
- for (const holder of holders) {
1913
- if ("group" in holder) for (const part of [
1914
- holder.group.resource,
1915
- holder.group.id,
1916
- holder.group.relation
1917
- ]) {
1918
- expect(typeof part).toBe("string");
1919
- expect(part).not.toBe("");
1920
- }
1921
- else expect(typeof holder.principal.id).toBe("string");
1922
- for (const instant of [holder.startsAt, holder.expiresAt]) expect(instant === void 0 || typeof instant === "number" && Number.isFinite(instant)).toBe(true);
1923
- }
1924
- const expected = options.expect?.holders?.[`${object.resource}:${object.id}#${object.relation}`];
1925
- if (expected !== void 0) expect(holders.map(holderKey).toSorted()).toEqual([...expected].toSorted());
1926
- }
1858
+ it("consumes an approved request exactly once", async () => {
1859
+ await store.create(sampleApproval("once-token"));
1860
+ expect(await store.consume("once-token")).toBeNull();
1861
+ await store.resolve("once-token", {
1862
+ status: "approved",
1863
+ by: approver
1864
+ });
1865
+ const [first, second] = await Promise.all([store.consume("once-token"), store.consume("once-token")]);
1866
+ expect([first, second].filter((item) => item !== null)).toHaveLength(1);
1867
+ expect((first ?? second)?.consumedAt).toEqual(expect.any(String));
1868
+ expect(await store.consume("once-token")).toBeNull();
1869
+ expect((await store.get("once-token"))?.consumedAt).toEqual(expect.any(String));
1927
1870
  });
1928
- it("follows each link to exactly one instance", async () => {
1929
- for (const [key, id] of Object.entries(options.expect?.links ?? {})) {
1930
- const [object, through] = key.split(">");
1931
- const [resource, objectId] = (object ?? "").split(":");
1932
- if (resource === void 0 || objectId === void 0 || through === void 0) throw new Error(`expect.links key "${key}" is not resource:id>link.`);
1933
- const linked = await source.ancestors({
1934
- resource,
1935
- id: objectId,
1936
- through,
1937
- depth: 1
1938
- });
1939
- expect(linked.ancestors.map((ancestor) => ancestor.id)).toEqual([id]);
1940
- }
1871
+ it("refuses an approver from another tenant", async () => {
1872
+ await store.create(tenantApproval("tenant-token", "o_1"));
1873
+ await expect(Promise.resolve().then(() => store.resolve("tenant-token", {
1874
+ status: "approved",
1875
+ by: tenantApprover("o_2")
1876
+ }))).rejects.toThrow(/tenant|eligible/u);
1877
+ expect((await store.get("tenant-token"))?.status).toBe("pending");
1941
1878
  });
1942
- it("reads each object's row as an object, or null when there is none", async () => {
1943
- if (source.row === void 0) return;
1944
- const read = (query) => source.row?.(query);
1945
- for (const object of options.objects) {
1946
- const row = await read({
1947
- resource: object.resource,
1948
- id: object.id
1949
- });
1950
- expect(row === null || row === void 0 || typeof row === "object" && !Array.isArray(row)).toBe(true);
1951
- }
1952
- const missing = await read({
1953
- resource: options.objects[0]?.resource ?? "",
1954
- id: "permdock-missing-object"
1879
+ it.skipIf(options.reopen === void 0)("resumes an approval after a restart", async () => {
1880
+ await store.create(sampleApproval("restart-token"));
1881
+ await store.resolve("restart-token", {
1882
+ status: "approved",
1883
+ by: approver
1955
1884
  });
1956
- expect(missing ?? null).toBeNull();
1957
- });
1958
- it("answers an unknown object with nothing instead of throwing", async () => {
1959
- const first = options.objects[0];
1960
- if (first === void 0) return;
1961
- const missing = {
1962
- resource: first.resource,
1963
- id: "permdock-missing-object"
1964
- };
1965
- expect((await source.ancestors({
1966
- ...missing,
1967
- through: "parent",
1968
- depth: 16
1969
- })).ancestors).toEqual([]);
1970
- if (first.relation !== void 0) expect(await source.related({
1971
- ...missing,
1972
- relation: first.relation
1973
- })).toEqual([]);
1885
+ const reopened = await options.reopen();
1886
+ expect((await reopened.get("restart-token"))?.status).toBe("approved");
1887
+ expect(await reopened.consume("restart-token")).not.toBeNull();
1888
+ expect(await store.consume("restart-token")).toBeNull();
1974
1889
  });
1975
- }
1976
- function testEntitlementSource(source, options) {
1977
- it("returns plan names for a tenant and none without one", async () => {
1978
- const found = await source.entitlementsFor(options.principal, { tenant: options.tenant });
1979
- expect(Array.isArray(found)).toBe(true);
1980
- for (const name of found) expect(typeof name).toBe("string");
1981
- if (options.expect !== void 0) expect([...found].toSorted()).toEqual([...options.expect].toSorted());
1982
- expect(await source.entitlementsFor(options.principal, {})).toEqual([]);
1890
+ it("creates, gets, lists, resolves and expires", async () => {
1891
+ const request = sampleApproval("opaque-token");
1892
+ await store.create(request);
1893
+ const loaded = await store.get("opaque-token");
1894
+ expect(loaded).toEqual(request);
1895
+ expect(JSON.parse(JSON.stringify(loaded))).toEqual(request);
1896
+ const listed = await store.list({ status: "pending" });
1897
+ expect(listed.items.some((item) => item.token === "opaque-token")).toBe(true);
1898
+ const resolved = await store.resolve("opaque-token", {
1899
+ status: "approved",
1900
+ by: approver
1901
+ });
1902
+ expect(resolved.status).toBe("approved");
1903
+ await expect(Promise.resolve().then(() => store.resolve("opaque-token", {
1904
+ status: "rejected",
1905
+ by: approver
1906
+ }))).rejects.toThrow(/not pending/);
1907
+ await store.create(sampleApproval("stale-token"));
1908
+ const expired = await store.expire(new Date(Date.now() + 72e5));
1909
+ expect(expired).toBeGreaterThanOrEqual(1);
1983
1910
  });
1984
- }
1985
- function testRoleSource(source, options) {
1986
- it("only resolves declared role names", async () => {
1987
- const declared = options.declared.map((item) => typeof item === "string" ? item : item.key);
1988
- const roles = await source.rolesFor(options.tenant);
1989
- for (const role of roles) {
1990
- expect(role.tenant).toBe(options.tenant);
1991
- for (const included of role.includes ?? []) expect(declared).toContain(included);
1992
- if (options.policy !== void 0) expect(validateCustomRole(options.policy, role).dropped).toEqual([]);
1993
- }
1994
- if (source.assignable !== void 0) {
1995
- const assignable = await source.assignable(options.tenant);
1996
- for (const name of assignable) expect(declared).toContain(name);
1997
- }
1911
+ it("pages list results with limit and an opaque cursor", async () => {
1912
+ const base = Date.parse("2026-01-01T00:00:00.000Z");
1913
+ await Promise.all([
1914
+ "page-a",
1915
+ "page-b",
1916
+ "page-c"
1917
+ ].map(async (token, index) => store.create({
1918
+ ...sampleApproval(token),
1919
+ subject: { principal: {
1920
+ id: "u_pager",
1921
+ roles: ["member"]
1922
+ } },
1923
+ createdAt: new Date(base + index * 1e3).toISOString()
1924
+ })));
1925
+ const first = await store.list({
1926
+ principalId: "u_pager",
1927
+ limit: 2
1928
+ });
1929
+ expect(first.items.map((item) => item.token)).toEqual(["page-a", "page-b"]);
1930
+ expect(typeof first.next).toBe("string");
1931
+ const second = await store.list({
1932
+ principalId: "u_pager",
1933
+ limit: 2,
1934
+ cursor: first.next
1935
+ });
1936
+ expect(second.items.map((item) => item.token)).toEqual(["page-c"]);
1937
+ expect(second.next).toBeUndefined();
1938
+ expect((await store.list({
1939
+ principalId: "u_pager",
1940
+ cursor: "not a cursor"
1941
+ })).items).toEqual([]);
1998
1942
  });
1999
- const every = options.every;
2000
- if (every !== void 0) it("returns every custom role of the tenant, held or not", async () => {
2001
- const roles = await source.rolesFor(options.tenant, { held: [] });
2002
- expect([...new Set(roles.map((role) => role.name))].toSorted()).toEqual([...new Set(every)].toSorted());
1943
+ it("refuses an approver who does not match request.approvers", async () => {
1944
+ const request = {
1945
+ ...sampleApproval("gated-token"),
1946
+ approvers: { by: {
1947
+ kind: "role",
1948
+ role: "admin",
1949
+ scope: "global"
1950
+ } }
1951
+ };
1952
+ await store.create(request);
1953
+ const member = {
1954
+ principal: {
1955
+ id: "u_2",
1956
+ roles: ["member"]
1957
+ },
1958
+ context: {}
1959
+ };
1960
+ await expect(Promise.resolve().then(() => store.resolve("gated-token", {
1961
+ status: "approved",
1962
+ by: member
1963
+ }))).rejects.toThrow(/eligible|not pending|not found/);
2003
1964
  });
2004
- }
2005
- function testLimitStore(store) {
2006
- it("counts down synchronously and fails closed when exhausted", async () => {
2007
- const input = {
2008
- key: "report.export",
2009
- subjectId: "u_1",
2010
- count: 2,
2011
- per: "hour",
2012
- now: 17e8
1965
+ it("refuses the principal as approver unless the grant sets distinct: false", async () => {
1966
+ const principal = {
1967
+ principal: {
1968
+ id: "u_1",
1969
+ roles: ["admin"]
1970
+ },
1971
+ context: {}
2013
1972
  };
2014
- const first = await store.consume(input);
2015
- expect(first.remaining).toBeGreaterThanOrEqual(0);
2016
- const peeked = store.remaining(input);
2017
- if (peeked !== void 0) {
2018
- expect(typeof peeked.remaining).toBe("number");
2019
- expect(peeked !== null && typeof peeked === "object" && "then" in peeked && typeof peeked.then === "function").toBe(false);
1973
+ const shapes = [
1974
+ ["self-human", void 0],
1975
+ ["self-by", { by: admin }],
1976
+ ["self-distinct", {
1977
+ by: admin,
1978
+ distinct: true
1979
+ }]
1980
+ ];
1981
+ for (const [token, approvers] of shapes) {
1982
+ await store.create(approvers === void 0 ? sampleApproval(token) : {
1983
+ ...sampleApproval(token),
1984
+ approvers
1985
+ });
1986
+ await expect(Promise.resolve().then(() => store.resolve(token, {
1987
+ status: "approved",
1988
+ by: principal
1989
+ }))).rejects.toThrow(/principal/u);
1990
+ expect((await store.get(token))?.status).toBe("pending");
2020
1991
  }
2021
- await store.consume(input);
2022
- const exhausted = await store.consume(input);
2023
- expect(exhausted.remaining).toBeLessThan(0);
1992
+ await store.create({
1993
+ ...sampleApproval("self-optout"),
1994
+ approvers: {
1995
+ by: admin,
1996
+ distinct: false
1997
+ }
1998
+ });
1999
+ const resolved = await store.resolve("self-optout", {
2000
+ status: "approved",
2001
+ by: principal
2002
+ });
2003
+ expect(resolved.resolvedBy).toBe("u_1");
2024
2004
  });
2025
- }
2026
- function testDecisionSink(sink) {
2027
- it("accepts batches and never propagates write errors", async () => {
2028
- await expect(Promise.resolve(sink.write([{
2029
- type: "membership",
2030
- at: (/* @__PURE__ */ new Date()).toISOString(),
2031
- source: "app",
2032
- operation: "changed",
2033
- principal: { id: "u_1" },
2034
- roles: {
2035
- added: ["admin"],
2036
- removed: ["member"]
2005
+ it("stays pending until the quorum is met and counts an approver once", async () => {
2006
+ await store.create({
2007
+ ...sampleApproval("quorum-token"),
2008
+ approvers: {
2009
+ by: admin,
2010
+ quorum: 2
2037
2011
  }
2038
- }]))).resolves.toBeUndefined();
2039
- await expect(Promise.resolve(sink.write([]))).resolves.toBeUndefined();
2040
- if (sink.flush !== void 0) {
2041
- await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
2042
- await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
2043
- }
2012
+ });
2013
+ const first = await store.resolve("quorum-token", {
2014
+ status: "approved",
2015
+ by: approver
2016
+ });
2017
+ expect(first.status).toBe("pending");
2018
+ expect(first.approvals).toEqual([{
2019
+ by: "u_9",
2020
+ at: expect.any(String)
2021
+ }]);
2022
+ expect(await store.consume("quorum-token")).toBeNull();
2023
+ await expect(Promise.resolve().then(() => store.resolve("quorum-token", {
2024
+ status: "approved",
2025
+ by: approver
2026
+ }))).rejects.toThrow(/already approved/u);
2027
+ const second = await store.resolve("quorum-token", {
2028
+ status: "approved",
2029
+ by: secondApprover
2030
+ });
2031
+ expect(second.status).toBe("approved");
2032
+ expect(second.approvals?.map((item) => item.by)).toEqual(["u_9", "u_10"]);
2033
+ expect(second.resolvedBy).toBe("u_10");
2034
+ expect(await store.consume("quorum-token")).not.toBeNull();
2044
2035
  });
2045
- }
2046
- function testSnapshotSource(source) {
2047
- it("round-trips a snapshot", async () => {
2048
- const snapshot = await source.get();
2049
- expect(snapshot === null || snapshot === void 0).toBe(false);
2050
- if (typeof snapshot === "object" && snapshot !== null && "v" in snapshot) expect(snapshot.v).toBe(1);
2051
- if (source.subscribe !== void 0) source.subscribe(() => void 0)();
2052
- });
2053
- }
2054
- function testPolicySource(source, options = {}) {
2055
- const readCurrent = () => {
2056
- const document = source.current();
2057
- expect(document !== null && typeof document === "object" && "then" in document && typeof document.then === "function").toBe(false);
2058
- return document;
2059
- };
2060
- it("returns null or a v1 policy document synchronously", () => {
2061
- const document = readCurrent();
2062
- if (document !== null) expect(parsePolicyDocument(document)).toEqual(document);
2063
- });
2064
- it("refreshes without rejecting and keeps current() synchronous", async () => {
2065
- await expect(Promise.resolve(source.refresh())).resolves.toBeUndefined();
2066
- const document = readCurrent();
2067
- if (document !== null) expect(document.v).toBe(1);
2068
- });
2069
- if (options.policy !== void 0) {
2070
- const policy = options.policy;
2071
- it("merges only grants on hostable permissions", () => {
2072
- const document = readCurrent();
2073
- const merged = mergeHostedGrants(policy, document);
2074
- for (const grant of merged.policy.grants) if (grant.hosted !== void 0) expect(policy.hostable).toContain(grant.permission.key);
2075
- for (const dropped of merged.dropped) expect(dropped.kind).toBe("hosted-grant-dropped");
2036
+ it("resolves a vouched verdict at once, records its rule, and still refuses the requester", async () => {
2037
+ await store.create({
2038
+ ...sampleApproval("vouched-token"),
2039
+ approvers: {
2040
+ by: admin,
2041
+ quorum: 2
2042
+ }
2076
2043
  });
2077
- }
2078
- }
2079
- function sampleApproval(token) {
2080
- return {
2081
- v: 1,
2082
- token,
2083
- permission: "post.delete",
2084
- scope: "post:delete",
2085
- resource: {
2086
- type: "post",
2087
- id: "42"
2088
- },
2089
- subject: {
2090
- principal: {
2091
- id: "u_1",
2092
- roles: ["member"]
2044
+ await expect(Promise.resolve().then(() => store.resolve("vouched-token", {
2045
+ status: "approved",
2046
+ by: {
2047
+ principal: {
2048
+ id: "u_1",
2049
+ roles: []
2050
+ },
2051
+ context: {}
2093
2052
  },
2094
- actor: {
2095
- id: "agent-1",
2096
- kind: "eve"
2097
- }
2098
- },
2099
- detail: "post.delete requires human approval.",
2100
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2101
- expiresAt: new Date(Date.now() + 36e5).toISOString(),
2102
- status: "pending"
2103
- };
2104
- }
2105
- const approver = {
2106
- principal: {
2107
- id: "u_9",
2108
- roles: ["admin"]
2109
- },
2110
- context: {}
2111
- };
2112
- const secondApprover = {
2113
- principal: {
2114
- id: "u_10",
2115
- roles: ["admin"]
2116
- },
2117
- context: {}
2118
- };
2119
- const admin = {
2120
- kind: "role",
2121
- role: "admin",
2122
- scope: "global"
2123
- };
2124
- function testReplayStore(store) {
2125
- it("records a jti after remember and reports it as seen", async () => {
2126
- const jti = "jti-1";
2127
- expect(await store.seen(jti)).toBe(false);
2128
- await store.remember(jti);
2129
- expect(await store.seen(jti)).toBe(true);
2130
- expect(await store.seen("jti-2")).toBe(false);
2131
- });
2132
- it("forgets a jti whose expiresAt has passed", async () => {
2133
- await store.remember("ttl-jti", Math.floor(Date.now() / 1e3) - 1);
2134
- expect(await store.seen("ttl-jti")).toBe(false);
2135
- });
2136
- it("claims a key once under concurrency and releases it for a retry", async (context) => {
2137
- if (store.claim === void 0 || store.release === void 0) {
2138
- context.skip();
2139
- return;
2140
- }
2141
- const expiresAt = Math.floor(Date.now() / 1e3) + 60;
2142
- const results = await Promise.all([store.claim("claim-key", expiresAt), store.claim("claim-key", expiresAt)]);
2143
- expect(results.toSorted()).toEqual([false, true]);
2144
- expect(await store.seen("claim-key")).toBe(true);
2145
- await store.release("claim-key");
2146
- expect(await store.claim("claim-key", expiresAt)).toBe(true);
2053
+ vouched: "app-rule"
2054
+ }))).rejects.toThrow(/principal/u);
2055
+ const resolved = await store.resolve("vouched-token", {
2056
+ status: "approved",
2057
+ by: {
2058
+ principal: {
2059
+ id: "u_outsider",
2060
+ roles: []
2061
+ },
2062
+ context: {}
2063
+ },
2064
+ vouched: "app-rule"
2065
+ });
2066
+ expect(resolved.status).toBe("approved");
2067
+ expect(resolved.vouched).toBe("app-rule");
2068
+ expect(resolved.approvals).toEqual([{
2069
+ by: "u_outsider",
2070
+ at: expect.any(String),
2071
+ vouched: "app-rule"
2072
+ }]);
2147
2073
  });
2148
- }
2149
- function testRevocationFeed(feed) {
2150
- it("delivers each event to every subscriber until it unsubscribes", async () => {
2151
- const first = [];
2152
- const second = [];
2153
- const stopFirst = feed.subscribe((event) => {
2154
- first.push(event);
2074
+ it("lets one rejection end a quorum request", async () => {
2075
+ await store.create({
2076
+ ...sampleApproval("veto-token"),
2077
+ approvers: {
2078
+ by: admin,
2079
+ quorum: 2
2080
+ }
2155
2081
  });
2156
- const stopSecond = feed.subscribe((event) => {
2157
- second.push(event);
2082
+ const rejected = await store.resolve("veto-token", {
2083
+ status: "rejected",
2084
+ by: approver
2158
2085
  });
2159
- const revoked = {
2160
- principal: "u-feed",
2161
- session: "s-feed",
2162
- kind: "session-revoked"
2086
+ expect(rejected.status).toBe("rejected");
2087
+ expect(rejected.approvals).toBeUndefined();
2088
+ });
2089
+ it("admits the escalation grantee only once the request has waited", async () => {
2090
+ const auditor = {
2091
+ principal: {
2092
+ id: "u_11",
2093
+ roles: ["auditor"]
2094
+ },
2095
+ context: {}
2163
2096
  };
2164
- await feed.revoke(revoked);
2165
- await vi.waitFor(() => {
2166
- expect(first).toEqual([revoked]);
2167
- expect(second).toEqual([revoked]);
2168
- });
2169
- stopFirst();
2170
- const changed = {
2171
- principal: "u-feed",
2172
- tenant: "t-feed",
2173
- kind: "changed"
2097
+ const escalation = {
2098
+ after: "1h",
2099
+ to: {
2100
+ kind: "role",
2101
+ role: "auditor",
2102
+ scope: "global"
2103
+ }
2174
2104
  };
2175
- await feed.revoke(changed);
2176
- await vi.waitFor(() => {
2177
- expect(second).toEqual([revoked, changed]);
2105
+ await store.create({
2106
+ ...sampleApproval("fresh-escalation"),
2107
+ approvers: {
2108
+ by: admin,
2109
+ escalation
2110
+ }
2178
2111
  });
2179
- expect(first).toEqual([revoked]);
2180
- stopSecond();
2112
+ await expect(Promise.resolve().then(() => store.resolve("fresh-escalation", {
2113
+ status: "approved",
2114
+ by: auditor
2115
+ }))).rejects.toThrow(/eligible/u);
2116
+ await store.create({
2117
+ ...sampleApproval("waited-escalation"),
2118
+ createdAt: (/* @__PURE__ */ new Date(Date.now() - 72e5)).toISOString(),
2119
+ approvers: {
2120
+ by: admin,
2121
+ escalation
2122
+ }
2123
+ });
2124
+ const resolved = await store.resolve("waited-escalation", {
2125
+ status: "approved",
2126
+ by: auditor
2127
+ });
2128
+ expect(resolved.status).toBe("approved");
2129
+ expect(resolved.resolvedBy).toBe("u_11");
2181
2130
  });
2182
- it("keeps delivering when a listener throws", async () => {
2183
- const seen = [];
2184
- const stopThrowing = feed.subscribe(() => {
2185
- throw new Error("listener failed");
2131
+ it("completes sequential stages in order and records each stage", async () => {
2132
+ await store.create({
2133
+ ...sampleApproval("stages-token"),
2134
+ approvers: {
2135
+ mode: "sequential",
2136
+ stages: [{ by: {
2137
+ kind: "user",
2138
+ id: "u_10"
2139
+ } }, { by: admin }]
2140
+ }
2186
2141
  });
2187
- const stop = feed.subscribe((event) => {
2188
- seen.push(event);
2142
+ await expect(Promise.resolve().then(() => store.resolve("stages-token", {
2143
+ status: "approved",
2144
+ by: approver
2145
+ }))).rejects.toThrow(/eligible/u);
2146
+ const first = await store.resolve("stages-token", {
2147
+ status: "approved",
2148
+ by: secondApprover
2189
2149
  });
2190
- const event = {
2191
- principal: "u-throw",
2192
- kind: "changed"
2193
- };
2194
- await feed.revoke(event);
2195
- await vi.waitFor(() => {
2196
- expect(seen).toEqual([event]);
2150
+ expect(first.status).toBe("pending");
2151
+ expect(first.approvals).toEqual([{
2152
+ by: "u_10",
2153
+ at: expect.any(String),
2154
+ stage: 0
2155
+ }]);
2156
+ const second = await store.resolve("stages-token", {
2157
+ status: "approved",
2158
+ by: approver
2197
2159
  });
2198
- stopThrowing();
2199
- stop();
2160
+ expect(second.status).toBe("approved");
2161
+ expect(second.approvals?.map((item) => item.stage)).toEqual([0, 1]);
2200
2162
  });
2201
- it("rejects an event without a principal or with an unknown kind", async () => {
2202
- const seen = [];
2203
- const stop = feed.subscribe((event) => {
2204
- seen.push(event);
2205
- });
2206
- for (const event of [{
2207
- principal: "",
2208
- kind: "changed"
2209
- }, {
2210
- principal: "u-bad",
2211
- kind: "granted"
2212
- }]) await expect((async () => {
2213
- await feed.revoke(event);
2214
- })()).rejects.toThrow(TypeError);
2215
- expect(seen).toEqual([]);
2216
- stop();
2217
- });
2218
- }
2219
- function testDirectoryStore(store, options) {
2220
- const [home, other] = options.tenants;
2221
- it("round-trips users and groups, isolates tenants, and drops inactive memberships", async () => {
2222
- const created = await store.putUser(home, {
2223
- id: "",
2224
- userName: "ada",
2225
- externalId: "00u1",
2226
- active: true,
2227
- meta: {
2228
- created: "",
2229
- lastModified: ""
2230
- }
2231
- });
2232
- expect(created.id).not.toBe("");
2233
- expect(await store.getUser(home, created.id)).toMatchObject({
2234
- userName: "ada",
2235
- externalId: "00u1"
2236
- });
2237
- expect(await store.getUser(other, created.id)).toBeNull();
2238
- await expect(store.putUser(home, {
2239
- id: "",
2240
- userName: "ada",
2241
- active: true,
2242
- meta: {
2243
- created: "",
2244
- lastModified: ""
2245
- }
2246
- })).rejects.toThrow(/userName/);
2247
- const group = await store.putGroup(home, {
2248
- id: "g_editors",
2249
- displayName: "Editors",
2250
- members: [{ value: created.id }],
2251
- roles: ["editor"],
2252
- meta: {
2253
- created: "",
2254
- lastModified: ""
2255
- }
2256
- });
2257
- expect(await store.groupsFor(home, created.id)).toEqual([expect.objectContaining({
2258
- id: group.id,
2259
- displayName: "Editors"
2260
- })]);
2261
- expect(await store.groupsFor(other, created.id)).toEqual([]);
2262
- const patched = await store.patchUser(home, created.id, [{
2263
- op: "replace",
2264
- path: "active",
2265
- value: false
2266
- }]);
2267
- expect(patched.active).toBe(false);
2268
- const source = directoryMembershipSource(store, { assignable: ["editor"] });
2269
- expect(await source.membershipsFor({ id: "00u1" }, { tenant: home })).toEqual([]);
2270
- await store.patchUser(home, created.id, [{
2271
- op: "replace",
2272
- path: "active",
2273
- value: true
2274
- }]);
2275
- expect(await source.membershipsFor({ id: "00u1" }, { tenant: home })).toEqual([{
2276
- tenant: home,
2277
- roles: ["editor"],
2278
- via: `group:${group.id}`,
2279
- managedBy: "idp"
2280
- }]);
2281
- await store.patchGroup(home, group.id, [{
2282
- op: "remove",
2283
- path: "members",
2284
- value: [{ value: created.id }]
2285
- }]);
2286
- expect(await store.groupsFor(home, created.id)).toEqual([]);
2287
- });
2288
- it("ends sessions when scimHandler deactivates a user in this store", async () => {
2289
- const feed = memoryRevocationFeed();
2290
- const seen = [];
2291
- feed.subscribe((event) => {
2292
- seen.push(event);
2293
- });
2294
- const kinds = [];
2295
- const token = "conformance-scim-token";
2296
- const handle = scimHandler({
2297
- store,
2298
- tenant: home,
2299
- token: {
2300
- hash: "sha256",
2301
- lookup: () => sha256Hex(token)
2302
- },
2303
- revocations: feed,
2304
- onChange: (change) => {
2305
- kinds.push(change.kind);
2306
- }
2307
- });
2308
- const call = (path, method, body) => handle(new Request(`https://app.example.com/scim/v2${path}`, {
2309
- method,
2310
- headers: {
2311
- authorization: `Bearer ${token}`,
2312
- "content-type": "application/scim+json"
2313
- },
2314
- body: JSON.stringify(body)
2315
- }));
2316
- const created = await call("/Users", "POST", {
2317
- schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
2318
- userName: "grace",
2319
- active: true
2320
- });
2321
- expect(created.status).toBe(201);
2322
- const id = String((await created.json()).id);
2323
- seen.length = 0;
2324
- const patched = await call(`/Users/${id}`, "PATCH", {
2325
- schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
2326
- Operations: [{
2327
- op: "replace",
2328
- path: "active",
2329
- value: false
2330
- }]
2331
- });
2332
- expect(patched.status).toBe(200);
2333
- expect(seen).toContainEqual({
2334
- principal: "grace",
2335
- tenant: home,
2336
- kind: "session-revoked"
2337
- });
2338
- expect(seen.every((event) => event.kind === "session-revoked")).toBe(true);
2339
- expect(kinds).toEqual(["changed", "session-revoked"]);
2340
- });
2341
- }
2342
- function tenantApproval(token, tenant) {
2343
- const base = sampleApproval(token);
2344
- return {
2345
- ...base,
2346
- subject: {
2347
- ...base.subject,
2348
- principal: {
2349
- id: "u_1",
2350
- roles: ["member"],
2351
- tenant
2352
- }
2353
- }
2354
- };
2355
- }
2356
- function tenantApprover(tenant) {
2357
- return {
2358
- principal: {
2359
- id: "u_9",
2360
- roles: [],
2361
- memberships: [{
2362
- tenant,
2363
- roles: ["admin"]
2364
- }]
2365
- },
2366
- context: {}
2367
- };
2368
- }
2369
- function testApprovalStore(store, options = {}) {
2370
- it("keeps the existing record when the same call asks again", async () => {
2371
- await store.create(sampleApproval("dup-token"));
2372
- await store.resolve("dup-token", {
2373
- status: "approved",
2374
- by: approver
2375
- });
2376
- await store.create(sampleApproval("dup-token"));
2377
- expect((await store.get("dup-token"))?.status).toBe("approved");
2378
- });
2379
- it("consumes an approved request exactly once", async () => {
2380
- await store.create(sampleApproval("once-token"));
2381
- expect(await store.consume("once-token")).toBeNull();
2382
- await store.resolve("once-token", {
2383
- status: "approved",
2384
- by: approver
2163
+ it("matches a relation approver only through the verdict's relations", async () => {
2164
+ const manager = {
2165
+ kind: "relation",
2166
+ resource: "post",
2167
+ relation: "manager"
2168
+ };
2169
+ await store.create({
2170
+ ...sampleApproval("relation-token"),
2171
+ approvers: { by: manager }
2385
2172
  });
2386
- const [first, second] = await Promise.all([store.consume("once-token"), store.consume("once-token")]);
2387
- expect([first, second].filter((item) => item !== null)).toHaveLength(1);
2388
- expect((first ?? second)?.consumedAt).toEqual(expect.any(String));
2389
- expect(await store.consume("once-token")).toBeNull();
2390
- expect((await store.get("once-token"))?.consumedAt).toEqual(expect.any(String));
2391
- });
2392
- it("refuses an approver from another tenant", async () => {
2393
- await store.create(tenantApproval("tenant-token", "o_1"));
2394
- await expect(Promise.resolve().then(() => store.resolve("tenant-token", {
2395
- status: "approved",
2396
- by: tenantApprover("o_2")
2397
- }))).rejects.toThrow(/tenant|eligible/u);
2398
- expect((await store.get("tenant-token"))?.status).toBe("pending");
2399
- });
2400
- it.skipIf(options.reopen === void 0)("resumes an approval after a restart", async () => {
2401
- await store.create(sampleApproval("restart-token"));
2402
- await store.resolve("restart-token", {
2173
+ await expect(Promise.resolve().then(() => store.resolve("relation-token", {
2403
2174
  status: "approved",
2404
2175
  by: approver
2405
- });
2406
- const reopened = await options.reopen();
2407
- expect((await reopened.get("restart-token"))?.status).toBe("approved");
2408
- expect(await reopened.consume("restart-token")).not.toBeNull();
2409
- expect(await store.consume("restart-token")).toBeNull();
2410
- });
2411
- it("creates, gets, lists, resolves and expires", async () => {
2412
- const request = sampleApproval("opaque-token");
2413
- await store.create(request);
2414
- const loaded = await store.get("opaque-token");
2415
- expect(loaded).toEqual(request);
2416
- expect(JSON.parse(JSON.stringify(loaded))).toEqual(request);
2417
- const listed = await store.list({ status: "pending" });
2418
- expect(listed.items.some((item) => item.token === "opaque-token")).toBe(true);
2419
- const resolved = await store.resolve("opaque-token", {
2176
+ }))).rejects.toThrow(/eligible/u);
2177
+ const resolved = await store.resolve("relation-token", {
2420
2178
  status: "approved",
2421
- by: approver
2179
+ by: approver,
2180
+ relations: [JSON.stringify([
2181
+ "post",
2182
+ "manager",
2183
+ null,
2184
+ null
2185
+ ])]
2422
2186
  });
2423
2187
  expect(resolved.status).toBe("approved");
2424
- await expect(Promise.resolve().then(() => store.resolve("opaque-token", {
2425
- status: "rejected",
2426
- by: approver
2427
- }))).rejects.toThrow(/not pending/);
2428
- await store.create(sampleApproval("stale-token"));
2429
- const expired = await store.expire(new Date(Date.now() + 72e5));
2430
- expect(expired).toBeGreaterThanOrEqual(1);
2431
2188
  });
2432
- it("pages list results with limit and an opaque cursor", async () => {
2433
- const base = Date.parse("2026-01-01T00:00:00.000Z");
2434
- await Promise.all([
2435
- "page-a",
2436
- "page-b",
2437
- "page-c"
2438
- ].map(async (token, index) => store.create({
2439
- ...sampleApproval(token),
2440
- subject: { principal: {
2441
- id: "u_pager",
2442
- roles: ["member"]
2443
- } },
2444
- createdAt: new Date(base + index * 1e3).toISOString()
2445
- })));
2446
- const first = await store.list({
2447
- principalId: "u_pager",
2448
- limit: 2
2449
- });
2450
- expect(first.items.map((item) => item.token)).toEqual(["page-a", "page-b"]);
2451
- expect(typeof first.next).toBe("string");
2452
- const second = await store.list({
2453
- principalId: "u_pager",
2454
- limit: 2,
2455
- cursor: first.next
2189
+ }
2190
+ /**
2191
+ * Checks an `ApprovalPolicySource`: entries are plain JSON, belong to the
2192
+ * queried tenant or to none, and every entry loads against `policy` (an
2193
+ * entry that does not would deny every call).
2194
+ */
2195
+ function testApprovalPolicySource(source, options) {
2196
+ it("answers per tenant with plain entries that load against the policy", async () => {
2197
+ const entries = await source.approvalPoliciesFor({ tenants: [options.tenant] });
2198
+ expect(Array.isArray(entries)).toBe(true);
2199
+ for (const entry of entries) {
2200
+ expect(JSON.parse(JSON.stringify(entry))).toEqual(entry);
2201
+ expect([void 0, options.tenant]).toContain(entry.tenant);
2202
+ }
2203
+ const loaded = await approvalPoliciesFor(options.policy, source, [options.tenant], []);
2204
+ expect(loaded).not.toBe("failed");
2205
+ const other = await source.approvalPoliciesFor({ tenants: [options.unknown ?? "__permdock_unknown__"] });
2206
+ expect(other.filter((entry) => entry.tenant !== void 0)).toEqual([]);
2207
+ });
2208
+ }
2209
+ //#endregion
2210
+ //#region src/testing/conformance/where.ts
2211
+ const WHERE_ROWS = [
2212
+ {
2213
+ id: "r1",
2214
+ authorId: "u1",
2215
+ score: 5,
2216
+ title: "Plan 100%",
2217
+ archived: false,
2218
+ note: null
2219
+ },
2220
+ {
2221
+ id: "r2",
2222
+ authorId: "u2",
2223
+ score: 10,
2224
+ title: "plan_b",
2225
+ archived: true,
2226
+ note: "kept"
2227
+ },
2228
+ {
2229
+ id: "r3",
2230
+ authorId: null,
2231
+ score: null,
2232
+ title: null,
2233
+ archived: null,
2234
+ note: null
2235
+ }
2236
+ ];
2237
+ const WHERE_CASES = [
2238
+ ["eq", {
2239
+ op: "eq",
2240
+ field: "authorId",
2241
+ value: "u1"
2242
+ }],
2243
+ ["ne", {
2244
+ op: "ne",
2245
+ field: "authorId",
2246
+ value: "u1"
2247
+ }],
2248
+ ["eq on a boolean", {
2249
+ op: "eq",
2250
+ field: "archived",
2251
+ value: false
2252
+ }],
2253
+ ["gt", {
2254
+ op: "gt",
2255
+ field: "score",
2256
+ value: 5
2257
+ }],
2258
+ ["gte", {
2259
+ op: "gte",
2260
+ field: "score",
2261
+ value: 5
2262
+ }],
2263
+ ["lt", {
2264
+ op: "lt",
2265
+ field: "score",
2266
+ value: 10
2267
+ }],
2268
+ ["lte", {
2269
+ op: "lte",
2270
+ field: "score",
2271
+ value: 10
2272
+ }],
2273
+ ["contains a literal %", {
2274
+ op: "contains",
2275
+ field: "title",
2276
+ value: "100%"
2277
+ }],
2278
+ ["contains a literal _", {
2279
+ op: "contains",
2280
+ field: "title",
2281
+ value: "n_b"
2282
+ }],
2283
+ ["in", {
2284
+ op: "in",
2285
+ field: "authorId",
2286
+ value: ["u1", "u2"]
2287
+ }],
2288
+ ["notIn", {
2289
+ op: "notIn",
2290
+ field: "authorId",
2291
+ value: ["u1"]
2292
+ }],
2293
+ ["isNull", {
2294
+ op: "isNull",
2295
+ field: "note",
2296
+ value: true
2297
+ }],
2298
+ ["is not null", {
2299
+ op: "isNull",
2300
+ field: "note",
2301
+ value: false
2302
+ }],
2303
+ ["and", {
2304
+ op: "and",
2305
+ conditions: [{
2306
+ op: "eq",
2307
+ field: "authorId",
2308
+ value: "u1"
2309
+ }, {
2310
+ op: "lt",
2311
+ field: "score",
2312
+ value: 10
2313
+ }]
2314
+ }],
2315
+ ["or", {
2316
+ op: "or",
2317
+ conditions: [{
2318
+ op: "eq",
2319
+ field: "authorId",
2320
+ value: "u1"
2321
+ }, {
2322
+ op: "eq",
2323
+ field: "archived",
2324
+ value: true
2325
+ }]
2326
+ }],
2327
+ ["not over a NULL field", {
2328
+ op: "not",
2329
+ condition: {
2330
+ op: "eq",
2331
+ field: "authorId",
2332
+ value: "u1"
2333
+ }
2334
+ }],
2335
+ ["not over and", {
2336
+ op: "not",
2337
+ condition: {
2338
+ op: "and",
2339
+ conditions: [{
2340
+ op: "eq",
2341
+ field: "authorId",
2342
+ value: "u2"
2343
+ }, {
2344
+ op: "gt",
2345
+ field: "score",
2346
+ value: 5
2347
+ }]
2348
+ }
2349
+ }],
2350
+ ["and of nothing", {
2351
+ op: "and",
2352
+ conditions: []
2353
+ }],
2354
+ ["not of the empty allow set", {
2355
+ op: "not",
2356
+ condition: {
2357
+ op: "or",
2358
+ conditions: []
2359
+ }
2360
+ }]
2361
+ ];
2362
+ const WHERE_SUBJECT = {
2363
+ principal: null,
2364
+ context: {}
2365
+ };
2366
+ /**
2367
+ * `matches` answers whether the compiled output selects `row`, for example
2368
+ * by running it against a table holding only that row. With it, every case
2369
+ * must select the rows the in-memory evaluator keeps, including rows whose
2370
+ * fields are NULL.
2371
+ */
2372
+ function testWhereCompiler(compiler, options) {
2373
+ const { matches } = options;
2374
+ it.skipIf(matches === void 0).each(WHERE_CASES)("selects the rows the evaluator keeps: %s", async (_name, condition) => {
2375
+ if (matches === void 0) return;
2376
+ const compiled = compiler(condition, options.target);
2377
+ const selected = [];
2378
+ for (const row of WHERE_ROWS) if (await matches(compiled, row)) selected.push(row["id"]);
2379
+ expect(selected).toEqual(WHERE_ROWS.filter((row) => evaluateCondition(condition, row, WHERE_SUBJECT)).map((row) => row["id"]));
2380
+ });
2381
+ it("fails closed on an empty allow set", () => {
2382
+ const compiled = compiler({
2383
+ op: "or",
2384
+ conditions: []
2385
+ }, options.target);
2386
+ const closed = options.isFailClosed === void 0 ? compiled === false || compiled === void 0 || compiled === null : options.isFailClosed(compiled);
2387
+ expect(closed).toBe(true);
2388
+ });
2389
+ it("compiles a sqlFunction through its twin", () => {
2390
+ const twin = {
2391
+ op: "eq",
2392
+ field: "authorId",
2393
+ value: "u1"
2394
+ };
2395
+ expect(() => compiler({
2396
+ op: "sqlFunction",
2397
+ name: "job_permitted",
2398
+ args: [{ field: "id" }],
2399
+ twin
2400
+ }, options.target)).not.toThrow();
2401
+ });
2402
+ }
2403
+ //#endregion
2404
+ //#region src/testing/jwt-fixtures.ts
2405
+ const jwtFixtureJwks = { keys: [{
2406
+ crv: "Ed25519",
2407
+ x: "79ab4WR6Eb9LkefWpmh5ZlvjXg7wqVGNMwIEHQqduIQ",
2408
+ kty: "OKP",
2409
+ kid: "2026-09",
2410
+ alg: "Ed25519",
2411
+ use: "sig"
2412
+ }] };
2413
+ const jwtFixtureIssuer = "https://login.example.com";
2414
+ const jwtFixtureAudience = "https://api.example.com";
2415
+ const jwtFixtureTokens = {
2416
+ valid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJjbGllbnRfaWQiOiJhcHAiLCJyb2xlcyI6WyJtZW1iZXIiXSwianRpIjoianRpLXZhbGlkIiwiaXNzIjoiaHR0cHM6Ly9sb2dpbi5leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMDAwMDAwMDB9.ENFMmLSyVde-2s4Gdz4iP7srZvU0Xuy_ZLz-OJGHD_-W1lEayEBeSPTnab0L_TBdj8p5BmkFrT-7VE7o20GbAw",
2417
+ expired: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjEwMDAwMDAwMDAsImV4cCI6MTEwMDAwMDAwMH0.9X3d6kH3_2nmrbqMxuoxzFW-wsWJVCe-lR-aQ-MmsGGXlEUBMgQ-4MtAq1UR9lLE0h2b5JrE_GfjzuC04h5xCg",
2418
+ wrongAud: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9vdGhlci5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.PgWdzi56J31OwoMqH4ysHzVKSKUDhi-AtHFUp1ewFzi-qGE-tknzrNKnKhDOncVzj0okb_flyy3vQgfzJwG5AQ",
2419
+ wrongIss: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2V2aWwuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwfQ.tIcLHLa9JLtKz1gqjRGdr5DFbUzRDs5KsoPsJCxspvbBcsFfRFG2G0XJnd1h4QHyZ_5B4OhY3W_jIIsnOPD9Bg",
2420
+ unknownKid: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoibm9wZSIsInR5cCI6ImF0K2p3dCJ9.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0.walgG5GvpCVBnkMtkdw-epi5c93JqfvAM_N4EWR9Tqhiw_cnzITnlQyoVfihECmV1fqtFrhwM1RCIRJvd38zAQ",
2421
+ none: "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1XzEiLCJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20iLCJleHAiOjIwMDAwMDAwMDAsImlhdCI6MTcwMDAwMDAwMH0.",
2422
+ snapshot: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLXNuYXBzaG90K2p3dCJ9.eyJzbmFwc2hvdCI6eyJ2IjoyLCJpc3N1ZWRBdCI6MTcwMDAwMDAwMCwic3ViamVjdCI6eyJwcmluY2lwYWwiOnsiaWQiOiJ1XzEiLCJyb2xlcyI6WyJtZW1iZXIiXX0sImNvbnRleHQiOnt9fSwicm9sZXMiOlsibWVtYmVyIl0sImdyYW50cyI6W10sInRlbmFudHMiOltdfSwic3ViIjoidV8xIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJzbmFwX2ZpeHR1cmUifQ.4itaYctluoCwU-syytUxArtS_FRyNAti2SZxuSH-nKIMr7-Sb3h1Q3xugn_vPR1eupV4fVNIe6dH27_EiyDvCA",
2423
+ approval: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLWFwcHJvdmFsK2p3dCJ9.eyJhcHByb3ZhbCI6eyJ0b2tlbiI6InBkMS5hYmMiLCJwZXJtaXNzaW9uIjoicG9zdC5kZWxldGUiLCJyZXNvdXJjZSI6eyJ0eXBlIjoicG9zdCIsImlkIjoiNDIifSwic3RhdHVzIjoiYXBwcm92ZWQifSwic3ViIjoidV8xIiwiaXNzIjoiaHR0cHM6Ly9hcHAuZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJhcHJfZml4dHVyZSJ9.UHsoSzQCo68G7ee-EFqdzMLvmqaKlFIMdQKFuqZEM4yUQnJsJJTXbld_aCXcyfN-IdjF9cj2F5EHzsV4nLKbBg",
2424
+ decisions: "eyJhbGciOiJFZDI1NTE5Iiwia2lkIjoiMjAyNi0wOSIsInR5cCI6InBlcm1kb2NrLWRlY2lzaW9ucytqd3QifQ.eyJldmVudHMiOltdLCJpc3MiOiJodHRwczovL2FwcC5leGFtcGxlLmNvbSIsImlhdCI6MTcwMDAwMDAwMCwiZXhwIjoyMDAwMDAwMDAwLCJqdGkiOiJkZWNfZml4dHVyZSJ9.UZhJd72yFGxHOdjYbzcGYUPgYjvLwOPCtHKxEbLu56SdKSKUDCREXmEBI9WaCPKWjlTBnnIKMYwjG8I8RATJCg"
2425
+ };
2426
+ //#endregion
2427
+ //#region src/testing/conformance/credentials.ts
2428
+ function testReplayStore(store) {
2429
+ it("records a jti after remember and reports it as seen", async () => {
2430
+ const jti = "jti-1";
2431
+ expect(await store.seen(jti)).toBe(false);
2432
+ await store.remember(jti);
2433
+ expect(await store.seen(jti)).toBe(true);
2434
+ expect(await store.seen("jti-2")).toBe(false);
2435
+ });
2436
+ it("forgets a jti whose expiresAt has passed", async () => {
2437
+ await store.remember("ttl-jti", Math.floor(Date.now() / 1e3) - 1);
2438
+ expect(await store.seen("ttl-jti")).toBe(false);
2439
+ });
2440
+ it("claims a key once under concurrency and releases it for a retry", async (context) => {
2441
+ if (store.claim === void 0 || store.release === void 0) {
2442
+ context.skip();
2443
+ return;
2444
+ }
2445
+ const expiresAt = Math.floor(Date.now() / 1e3) + 60;
2446
+ const results = await Promise.all([store.claim("claim-key", expiresAt), store.claim("claim-key", expiresAt)]);
2447
+ expect(results.toSorted()).toEqual([false, true]);
2448
+ expect(await store.seen("claim-key")).toBe(true);
2449
+ await store.release("claim-key");
2450
+ expect(await store.claim("claim-key", expiresAt)).toBe(true);
2451
+ });
2452
+ }
2453
+ function testRevocationFeed(feed) {
2454
+ it("delivers each event to every subscriber until it unsubscribes", async () => {
2455
+ const first = [];
2456
+ const second = [];
2457
+ const stopFirst = feed.subscribe((event) => {
2458
+ first.push(event);
2456
2459
  });
2457
- expect(second.items.map((item) => item.token)).toEqual(["page-c"]);
2458
- expect(second.next).toBeUndefined();
2459
- expect((await store.list({
2460
- principalId: "u_pager",
2461
- cursor: "not a cursor"
2462
- })).items).toEqual([]);
2460
+ const stopSecond = feed.subscribe((event) => {
2461
+ second.push(event);
2462
+ });
2463
+ const revoked = {
2464
+ principal: "u-feed",
2465
+ session: "s-feed",
2466
+ kind: "session-revoked"
2467
+ };
2468
+ await feed.revoke(revoked);
2469
+ await vi.waitFor(() => {
2470
+ expect(first).toEqual([revoked]);
2471
+ expect(second).toEqual([revoked]);
2472
+ });
2473
+ stopFirst();
2474
+ const changed = {
2475
+ principal: "u-feed",
2476
+ tenant: "t-feed",
2477
+ kind: "changed"
2478
+ };
2479
+ await feed.revoke(changed);
2480
+ await vi.waitFor(() => {
2481
+ expect(second).toEqual([revoked, changed]);
2482
+ });
2483
+ expect(first).toEqual([revoked]);
2484
+ stopSecond();
2463
2485
  });
2464
- it("refuses an approver who does not match request.approvers", async () => {
2465
- const request = {
2466
- ...sampleApproval("gated-token"),
2467
- approvers: { by: {
2468
- kind: "role",
2469
- role: "admin",
2470
- scope: "global"
2471
- } }
2486
+ it("keeps delivering when a listener throws", async () => {
2487
+ const seen = [];
2488
+ const stopThrowing = feed.subscribe(() => {
2489
+ throw new Error("listener failed");
2490
+ });
2491
+ const stop = feed.subscribe((event) => {
2492
+ seen.push(event);
2493
+ });
2494
+ const event = {
2495
+ principal: "u-throw",
2496
+ kind: "changed"
2472
2497
  };
2473
- await store.create(request);
2474
- const member = {
2475
- principal: {
2476
- id: "u_2",
2477
- roles: ["member"]
2498
+ await feed.revoke(event);
2499
+ await vi.waitFor(() => {
2500
+ expect(seen).toEqual([event]);
2501
+ });
2502
+ stopThrowing();
2503
+ stop();
2504
+ });
2505
+ it("rejects an event without a principal or with an unknown kind", async () => {
2506
+ const seen = [];
2507
+ const stop = feed.subscribe((event) => {
2508
+ seen.push(event);
2509
+ });
2510
+ for (const event of [{
2511
+ principal: "",
2512
+ kind: "changed"
2513
+ }, {
2514
+ principal: "u-bad",
2515
+ kind: "granted"
2516
+ }]) await expect((async () => {
2517
+ await feed.revoke(event);
2518
+ })()).rejects.toThrow(TypeError);
2519
+ expect(seen).toEqual([]);
2520
+ stop();
2521
+ });
2522
+ }
2523
+ function decodeHeader(token) {
2524
+ const [encoded] = token.split(".");
2525
+ if (encoded === void 0) return {};
2526
+ const padded = encoded.replaceAll("-", "+").replaceAll("_", "/");
2527
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - padded.length % 4);
2528
+ return JSON.parse(atob(`${padded}${pad}`));
2529
+ }
2530
+ function testTokenVerifier(verifier, options) {
2531
+ const audience = options?.audience ?? "https://api.example.com";
2532
+ const issuer = options?.issuer ?? "https://login.example.com";
2533
+ it("never throws and maps the JWT behaviour table", async () => {
2534
+ const valid = await verifier.verify(jwtFixtureTokens.valid, {
2535
+ audience,
2536
+ issuer
2537
+ });
2538
+ expect(valid.ok).toBe(true);
2539
+ if (valid.ok) {
2540
+ expect(valid.claims.sub).toBe("u_1");
2541
+ expect(valid.header.alg).toBe("Ed25519");
2542
+ }
2543
+ const rows = [
2544
+ {
2545
+ token: jwtFixtureTokens.none,
2546
+ cause: "alg-none"
2478
2547
  },
2479
- context: {}
2480
- };
2481
- await expect(Promise.resolve().then(() => store.resolve("gated-token", {
2482
- status: "approved",
2483
- by: member
2484
- }))).rejects.toThrow(/eligible|not pending|not found/);
2548
+ {
2549
+ token: jwtFixtureTokens.expired,
2550
+ cause: "expired"
2551
+ },
2552
+ {
2553
+ token: jwtFixtureTokens.wrongAud,
2554
+ cause: "wrong-audience"
2555
+ },
2556
+ {
2557
+ token: jwtFixtureTokens.wrongIss,
2558
+ cause: "wrong-issuer"
2559
+ },
2560
+ {
2561
+ token: jwtFixtureTokens.unknownKid,
2562
+ cause: "unknown-kid"
2563
+ },
2564
+ {
2565
+ token: "not-a-jwt",
2566
+ cause: "malformed"
2567
+ },
2568
+ {
2569
+ token: "a.b.c.d.e",
2570
+ cause: "encrypted-token"
2571
+ }
2572
+ ];
2573
+ for (const row of rows) {
2574
+ let result;
2575
+ try {
2576
+ result = await verifier.verify(row.token, {
2577
+ audience,
2578
+ issuer
2579
+ });
2580
+ } catch {
2581
+ throw new Error("TokenVerifier must not throw");
2582
+ }
2583
+ expect(result.ok).toBe(false);
2584
+ if (!result.ok) expect(result.cause).toBe(row.cause);
2585
+ }
2586
+ });
2587
+ }
2588
+ function flipLast(value) {
2589
+ const last = value.at(-1);
2590
+ return `${value.slice(0, -1)}${last === "A" ? "B" : "A"}`;
2591
+ }
2592
+ /**
2593
+ * `key` is a live key the verifier knows, or a function issuing one (called
2594
+ * once). `revoke`, when given, revokes it; the runner then expects the key
2595
+ * to stop verifying.
2596
+ */
2597
+ function testCredentialVerifier(verifier, options) {
2598
+ let issued;
2599
+ const live = () => {
2600
+ const source = options.key;
2601
+ issued ??= Promise.resolve(typeof source === "string" ? source : source());
2602
+ return issued;
2603
+ };
2604
+ it("verifies the live key to a v1 credential with the key id", async () => {
2605
+ const key = await live();
2606
+ const parts = parseApiKey(key);
2607
+ expect(parts).toBeDefined();
2608
+ const credential = parseCredential(await verifier.verify(key));
2609
+ expect(credential).toBeDefined();
2610
+ expect(credential?.id).toBe(parts?.id);
2611
+ expect(parseCredential(await verifier.verify(key))).toEqual(credential);
2485
2612
  });
2486
- it("refuses the principal as approver unless the grant sets distinct: false", async () => {
2487
- const principal = {
2488
- principal: {
2489
- id: "u_1",
2490
- roles: ["admin"]
2491
- },
2492
- context: {}
2493
- };
2494
- const shapes = [
2495
- ["self-human", void 0],
2496
- ["self-by", { by: admin }],
2497
- ["self-distinct", {
2498
- by: admin,
2499
- distinct: true
2500
- }]
2613
+ it("never throws and answers null for every other key", async () => {
2614
+ const key = await live();
2615
+ const parts = parseApiKey(key);
2616
+ const others = [
2617
+ "",
2618
+ "garbage",
2619
+ "pdk_",
2620
+ `${key}x`,
2621
+ flipLast(key),
2622
+ `pdk_${parts?.id ?? "x"}-other_${parts?.secret ?? ""}`,
2623
+ key.replace(/^pdk_/u, "sk_")
2501
2624
  ];
2502
- for (const [token, approvers] of shapes) {
2503
- await store.create(approvers === void 0 ? sampleApproval(token) : {
2504
- ...sampleApproval(token),
2505
- approvers
2506
- });
2507
- await expect(Promise.resolve().then(() => store.resolve(token, {
2508
- status: "approved",
2509
- by: principal
2510
- }))).rejects.toThrow(/principal/u);
2511
- expect((await store.get(token))?.status).toBe("pending");
2512
- }
2513
- await store.create({
2514
- ...sampleApproval("self-optout"),
2515
- approvers: {
2516
- by: admin,
2517
- distinct: false
2625
+ for (const other of others) {
2626
+ let result;
2627
+ try {
2628
+ result = await verifier.verify(other);
2629
+ } catch {
2630
+ throw new Error("CredentialVerifier must not throw");
2518
2631
  }
2519
- });
2520
- const resolved = await store.resolve("self-optout", {
2521
- status: "approved",
2522
- by: principal
2523
- });
2524
- expect(resolved.resolvedBy).toBe("u_1");
2632
+ expect(result).toBeNull();
2633
+ }
2525
2634
  });
2526
- it("stays pending until the quorum is met and counts an approver once", async () => {
2527
- await store.create({
2528
- ...sampleApproval("quorum-token"),
2529
- approvers: {
2530
- by: admin,
2531
- quorum: 2
2532
- }
2533
- });
2534
- const first = await store.resolve("quorum-token", {
2535
- status: "approved",
2536
- by: approver
2537
- });
2538
- expect(first.status).toBe("pending");
2539
- expect(first.approvals).toEqual([{
2540
- by: "u_9",
2541
- at: expect.any(String)
2542
- }]);
2543
- expect(await store.consume("quorum-token")).toBeNull();
2544
- await expect(Promise.resolve().then(() => store.resolve("quorum-token", {
2545
- status: "approved",
2546
- by: approver
2547
- }))).rejects.toThrow(/already approved/u);
2548
- const second = await store.resolve("quorum-token", {
2549
- status: "approved",
2550
- by: secondApprover
2551
- });
2552
- expect(second.status).toBe("approved");
2553
- expect(second.approvals?.map((item) => item.by)).toEqual(["u_9", "u_10"]);
2554
- expect(second.resolvedBy).toBe("u_10");
2555
- expect(await store.consume("quorum-token")).not.toBeNull();
2635
+ if (verifier.touch !== void 0) it("accepts a touch without changing what the key verifies to", async () => {
2636
+ const key = await live();
2637
+ const before = parseCredential(await verifier.verify(key));
2638
+ const id = parseApiKey(key)?.id ?? "";
2639
+ await verifier.touch?.(id, Math.floor(Date.now() / 1e3));
2640
+ await verifier.touch?.("unknown-id", Math.floor(Date.now() / 1e3));
2641
+ expect(parseCredential(await verifier.verify(key))).toEqual(before);
2556
2642
  });
2557
- it("resolves a vouched verdict at once, records its rule, and still refuses the requester", async () => {
2558
- await store.create({
2559
- ...sampleApproval("vouched-token"),
2560
- approvers: {
2561
- by: admin,
2562
- quorum: 2
2563
- }
2564
- });
2565
- await expect(Promise.resolve().then(() => store.resolve("vouched-token", {
2566
- status: "approved",
2567
- by: {
2568
- principal: {
2569
- id: "u_1",
2570
- roles: []
2571
- },
2572
- context: {}
2573
- },
2574
- vouched: "app-rule"
2575
- }))).rejects.toThrow(/principal/u);
2576
- const resolved = await store.resolve("vouched-token", {
2577
- status: "approved",
2578
- by: {
2579
- principal: {
2580
- id: "u_outsider",
2581
- roles: []
2582
- },
2583
- context: {}
2584
- },
2585
- vouched: "app-rule"
2643
+ if (options.revoke !== void 0) {
2644
+ const revoke = options.revoke;
2645
+ it("stops verifying a revoked key", async () => {
2646
+ const key = await live();
2647
+ await revoke();
2648
+ expect(await verifier.verify(key)).toBeNull();
2586
2649
  });
2587
- expect(resolved.status).toBe("approved");
2588
- expect(resolved.vouched).toBe("app-rule");
2589
- expect(resolved.approvals).toEqual([{
2590
- by: "u_outsider",
2591
- at: expect.any(String),
2592
- vouched: "app-rule"
2593
- }]);
2594
- });
2595
- it("lets one rejection end a quorum request", async () => {
2596
- await store.create({
2597
- ...sampleApproval("veto-token"),
2598
- approvers: {
2599
- by: admin,
2600
- quorum: 2
2601
- }
2650
+ }
2651
+ }
2652
+ function testTokenSigner(signer, options) {
2653
+ it("emits compact JWS with only alg, kid and typ", async () => {
2654
+ const token = await signer.sign({
2655
+ snapshot: { v: 1 },
2656
+ sub: "u_1"
2657
+ }, {
2658
+ typ: "permdock-snapshot+jwt",
2659
+ audience: "https://app.example.com"
2602
2660
  });
2603
- const rejected = await store.resolve("veto-token", {
2604
- status: "rejected",
2605
- by: approver
2661
+ const header = decodeHeader(token);
2662
+ expect(Object.keys(header).toSorted()).toEqual([
2663
+ "alg",
2664
+ "kid",
2665
+ "typ"
2666
+ ]);
2667
+ expect(header["typ"]).toBe("permdock-snapshot+jwt");
2668
+ expect(header["alg"]).not.toBe("none");
2669
+ const verified = await options.verifier.verify(token, {
2670
+ typ: "permdock-snapshot+jwt",
2671
+ audience: "https://app.example.com"
2606
2672
  });
2607
- expect(rejected.status).toBe("rejected");
2608
- expect(rejected.approvals).toBeUndefined();
2673
+ expect(verified.ok).toBe(true);
2674
+ if (signer.jwks !== void 0) {
2675
+ const jwks = await signer.jwks();
2676
+ expect(jwks.keys.length).toBeGreaterThan(0);
2677
+ expect(jwks.keys[0]).not.toHaveProperty("d");
2678
+ }
2609
2679
  });
2610
- it("admits the escalation grantee only once the request has waited", async () => {
2611
- const auditor = {
2612
- principal: {
2613
- id: "u_11",
2614
- roles: ["auditor"]
2615
- },
2616
- context: {}
2617
- };
2618
- const escalation = {
2619
- after: "1h",
2620
- to: {
2621
- kind: "role",
2622
- role: "auditor",
2623
- scope: "global"
2624
- }
2680
+ }
2681
+ //#endregion
2682
+ //#region src/testing/conformance/runtime-stores.ts
2683
+ function testLimitStore(store) {
2684
+ it("counts down synchronously and fails closed when exhausted", async () => {
2685
+ const input = {
2686
+ key: "report.export",
2687
+ subjectId: "u_1",
2688
+ count: 2,
2689
+ per: "hour",
2690
+ now: 17e8
2625
2691
  };
2626
- await store.create({
2627
- ...sampleApproval("fresh-escalation"),
2628
- approvers: {
2629
- by: admin,
2630
- escalation
2692
+ const first = await store.consume(input);
2693
+ expect(first.remaining).toBeGreaterThanOrEqual(0);
2694
+ const peeked = store.remaining(input);
2695
+ if (peeked !== void 0) {
2696
+ expect(typeof peeked.remaining).toBe("number");
2697
+ expect(peeked !== null && typeof peeked === "object" && "then" in peeked && typeof peeked.then === "function").toBe(false);
2698
+ }
2699
+ await store.consume(input);
2700
+ const exhausted = await store.consume(input);
2701
+ expect(exhausted.remaining).toBeLessThan(0);
2702
+ });
2703
+ }
2704
+ function testDecisionSink(sink) {
2705
+ it("accepts batches and never propagates write errors", async () => {
2706
+ await expect(Promise.resolve(sink.write([{
2707
+ type: "membership",
2708
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2709
+ source: "app",
2710
+ operation: "changed",
2711
+ principal: { id: "u_1" },
2712
+ roles: {
2713
+ added: ["admin"],
2714
+ removed: ["member"]
2631
2715
  }
2716
+ }]))).resolves.toBeUndefined();
2717
+ await expect(Promise.resolve(sink.write([]))).resolves.toBeUndefined();
2718
+ if (sink.flush !== void 0) {
2719
+ await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
2720
+ await expect(Promise.resolve(sink.flush())).resolves.toBeUndefined();
2721
+ }
2722
+ });
2723
+ }
2724
+ function testSnapshotSource(source) {
2725
+ it("round-trips a snapshot", async () => {
2726
+ const snapshot = await source.get();
2727
+ expect(snapshot === null || snapshot === void 0).toBe(false);
2728
+ if (typeof snapshot === "object" && snapshot !== null && "v" in snapshot) expect(snapshot.v).toBe(1);
2729
+ if (source.subscribe !== void 0) source.subscribe(() => void 0)();
2730
+ });
2731
+ }
2732
+ function testPolicySource(source, options = {}) {
2733
+ const readCurrent = () => {
2734
+ const document = source.current();
2735
+ expect(document !== null && typeof document === "object" && "then" in document && typeof document.then === "function").toBe(false);
2736
+ return document;
2737
+ };
2738
+ it("returns null or a v1 policy document synchronously", () => {
2739
+ const document = readCurrent();
2740
+ if (document !== null) expect(parsePolicyDocument(document)).toEqual(document);
2741
+ });
2742
+ it("refreshes without rejecting and keeps current() synchronous", async () => {
2743
+ await expect(Promise.resolve(source.refresh())).resolves.toBeUndefined();
2744
+ const document = readCurrent();
2745
+ if (document !== null) expect(document.v).toBe(1);
2746
+ });
2747
+ if (options.policy !== void 0) {
2748
+ const policy = options.policy;
2749
+ it("merges only grants on hostable permissions", () => {
2750
+ const document = readCurrent();
2751
+ const merged = mergeHostedGrants(policy, document);
2752
+ for (const grant of merged.policy.grants) if (grant.hosted !== void 0) expect(policy.hostable).toContain(grant.permission.key);
2753
+ for (const dropped of merged.dropped) expect(dropped.kind).toBe("hosted-grant-dropped");
2632
2754
  });
2633
- await expect(Promise.resolve().then(() => store.resolve("fresh-escalation", {
2634
- status: "approved",
2635
- by: auditor
2636
- }))).rejects.toThrow(/eligible/u);
2637
- await store.create({
2638
- ...sampleApproval("waited-escalation"),
2639
- createdAt: (/* @__PURE__ */ new Date(Date.now() - 72e5)).toISOString(),
2640
- approvers: {
2641
- by: admin,
2642
- escalation
2755
+ }
2756
+ }
2757
+ //#endregion
2758
+ //#region src/testing/conformance/relations.ts
2759
+ function holderKey(holder) {
2760
+ return "group" in holder ? `${holder.group.resource}:${holder.group.id}#${holder.group.relation}` : holder.principal.id;
2761
+ }
2762
+ /**
2763
+ * A `RelationSource` answers facts only: well-formed chains nearest first and
2764
+ * no longer than `depth`, holders with principal ids or group references and
2765
+ * numeric instants, a `truncated` chain only when more ancestors exist, and
2766
+ * empty answers (never a throw) for an object it does not know. `expect` pins
2767
+ * the chains, holders and links for `objects`, keyed `resource:id`,
2768
+ * `resource:id#relation` and `resource:id>link`; a group holder is written
2769
+ * `resource:id#relation`.
2770
+ */
2771
+ function testRelationSource(source, options) {
2772
+ it("returns chains nearest first, within depth, and truncated only when more exist", async () => {
2773
+ for (const object of options.objects) {
2774
+ const full = await source.ancestors({
2775
+ resource: object.resource,
2776
+ id: object.id,
2777
+ through: "parent",
2778
+ depth: 32
2779
+ });
2780
+ expect(Array.isArray(full.ancestors)).toBe(true);
2781
+ for (const ancestor of full.ancestors) {
2782
+ expect(typeof ancestor.id).toBe("string");
2783
+ expect(ancestor.id).not.toBe("");
2643
2784
  }
2644
- });
2645
- const resolved = await store.resolve("waited-escalation", {
2646
- status: "approved",
2647
- by: auditor
2648
- });
2649
- expect(resolved.status).toBe("approved");
2650
- expect(resolved.resolvedBy).toBe("u_11");
2785
+ expect(full.ancestors.length).toBeLessThanOrEqual(32);
2786
+ const expected = options.expect?.ancestors?.[`${object.resource}:${object.id}`];
2787
+ if (expected !== void 0) expect(full.ancestors.map((ancestor) => ancestor.id)).toEqual(expected);
2788
+ if (full.ancestors.length > 1) {
2789
+ const short = await source.ancestors({
2790
+ resource: object.resource,
2791
+ id: object.id,
2792
+ through: "parent",
2793
+ depth: 1
2794
+ });
2795
+ expect(short.ancestors.map((ancestor) => ancestor.id)).toEqual([full.ancestors[0]?.id]);
2796
+ expect(short.truncated).toBe(true);
2797
+ }
2798
+ const none = await source.ancestors({
2799
+ resource: object.resource,
2800
+ id: object.id,
2801
+ through: "parent",
2802
+ depth: 0
2803
+ });
2804
+ expect(none.ancestors).toEqual([]);
2805
+ }
2651
2806
  });
2652
- it("completes sequential stages in order and records each stage", async () => {
2653
- await store.create({
2654
- ...sampleApproval("stages-token"),
2655
- approvers: {
2656
- mode: "sequential",
2657
- stages: [{ by: {
2658
- kind: "user",
2659
- id: "u_10"
2660
- } }, { by: admin }]
2807
+ it("returns well-formed holders", async () => {
2808
+ for (const object of options.objects) {
2809
+ if (object.relation === void 0) continue;
2810
+ const holders = await source.related({
2811
+ resource: object.resource,
2812
+ id: object.id,
2813
+ relation: object.relation
2814
+ });
2815
+ expect(Array.isArray(holders)).toBe(true);
2816
+ for (const holder of holders) {
2817
+ if ("group" in holder) for (const part of [
2818
+ holder.group.resource,
2819
+ holder.group.id,
2820
+ holder.group.relation
2821
+ ]) {
2822
+ expect(typeof part).toBe("string");
2823
+ expect(part).not.toBe("");
2824
+ }
2825
+ else expect(typeof holder.principal.id).toBe("string");
2826
+ for (const instant of [holder.startsAt, holder.expiresAt]) expect(instant === void 0 || typeof instant === "number" && Number.isFinite(instant)).toBe(true);
2661
2827
  }
2828
+ const expected = options.expect?.holders?.[`${object.resource}:${object.id}#${object.relation}`];
2829
+ if (expected !== void 0) expect(holders.map(holderKey).toSorted()).toEqual([...expected].toSorted());
2830
+ }
2831
+ });
2832
+ it("follows each link to exactly one instance", async () => {
2833
+ for (const [key, id] of Object.entries(options.expect?.links ?? {})) {
2834
+ const [object, through] = key.split(">");
2835
+ const [resource, objectId] = (object ?? "").split(":");
2836
+ if (resource === void 0 || objectId === void 0 || through === void 0) throw new Error(`expect.links key "${key}" is not resource:id>link.`);
2837
+ const linked = await source.ancestors({
2838
+ resource,
2839
+ id: objectId,
2840
+ through,
2841
+ depth: 1
2842
+ });
2843
+ expect(linked.ancestors.map((ancestor) => ancestor.id)).toEqual([id]);
2844
+ }
2845
+ });
2846
+ it("reads each object's row as an object, or null when there is none", async () => {
2847
+ if (source.row === void 0) return;
2848
+ const read = (query) => source.row?.(query);
2849
+ for (const object of options.objects) {
2850
+ const row = await read({
2851
+ resource: object.resource,
2852
+ id: object.id
2853
+ });
2854
+ expect(row === null || row === void 0 || typeof row === "object" && !Array.isArray(row)).toBe(true);
2855
+ }
2856
+ const missing = await read({
2857
+ resource: options.objects[0]?.resource ?? "",
2858
+ id: "permdock-missing-object"
2662
2859
  });
2663
- await expect(Promise.resolve().then(() => store.resolve("stages-token", {
2664
- status: "approved",
2665
- by: approver
2666
- }))).rejects.toThrow(/eligible/u);
2667
- const first = await store.resolve("stages-token", {
2668
- status: "approved",
2669
- by: secondApprover
2670
- });
2671
- expect(first.status).toBe("pending");
2672
- expect(first.approvals).toEqual([{
2673
- by: "u_10",
2674
- at: expect.any(String),
2675
- stage: 0
2676
- }]);
2677
- const second = await store.resolve("stages-token", {
2678
- status: "approved",
2679
- by: approver
2680
- });
2681
- expect(second.status).toBe("approved");
2682
- expect(second.approvals?.map((item) => item.stage)).toEqual([0, 1]);
2860
+ expect(missing ?? null).toBeNull();
2683
2861
  });
2684
- it("matches a relation approver only through the verdict's relations", async () => {
2685
- const manager = {
2686
- kind: "relation",
2687
- resource: "post",
2688
- relation: "manager"
2862
+ it("answers an unknown object with nothing instead of throwing", async () => {
2863
+ const first = options.objects[0];
2864
+ if (first === void 0) return;
2865
+ const missing = {
2866
+ resource: first.resource,
2867
+ id: "permdock-missing-object"
2689
2868
  };
2690
- await store.create({
2691
- ...sampleApproval("relation-token"),
2692
- approvers: { by: manager }
2693
- });
2694
- await expect(Promise.resolve().then(() => store.resolve("relation-token", {
2695
- status: "approved",
2696
- by: approver
2697
- }))).rejects.toThrow(/eligible/u);
2698
- const resolved = await store.resolve("relation-token", {
2699
- status: "approved",
2700
- by: approver,
2701
- relations: [JSON.stringify([
2702
- "post",
2703
- "manager",
2704
- null,
2705
- null
2706
- ])]
2707
- });
2708
- expect(resolved.status).toBe("approved");
2869
+ expect((await source.ancestors({
2870
+ ...missing,
2871
+ through: "parent",
2872
+ depth: 16
2873
+ })).ancestors).toEqual([]);
2874
+ if (first.relation !== void 0) expect(await source.related({
2875
+ ...missing,
2876
+ relation: first.relation
2877
+ })).toEqual([]);
2709
2878
  });
2710
2879
  }
2711
- function decodeHeader(token) {
2712
- const [encoded] = token.split(".");
2713
- if (encoded === void 0) return {};
2714
- const padded = encoded.replaceAll("-", "+").replaceAll("_", "/");
2715
- const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - padded.length % 4);
2716
- return JSON.parse(atob(`${padded}${pad}`));
2717
- }
2718
- function testTokenVerifier(verifier, options) {
2719
- const audience = options?.audience ?? "https://api.example.com";
2720
- const issuer = options?.issuer ?? "https://login.example.com";
2721
- it("never throws and maps the JWT behaviour table", async () => {
2722
- const valid = await verifier.verify(jwtFixtureTokens.valid, {
2723
- audience,
2724
- issuer
2725
- });
2726
- expect(valid.ok).toBe(true);
2727
- if (valid.ok) {
2728
- expect(valid.claims.sub).toBe("u_1");
2729
- expect(valid.header.alg).toBe("Ed25519");
2730
- }
2731
- const rows = [
2732
- {
2733
- token: jwtFixtureTokens.none,
2734
- cause: "alg-none"
2735
- },
2736
- {
2737
- token: jwtFixtureTokens.expired,
2738
- cause: "expired"
2739
- },
2740
- {
2741
- token: jwtFixtureTokens.wrongAud,
2742
- cause: "wrong-audience"
2743
- },
2744
- {
2745
- token: jwtFixtureTokens.wrongIss,
2746
- cause: "wrong-issuer"
2747
- },
2748
- {
2749
- token: jwtFixtureTokens.unknownKid,
2750
- cause: "unknown-kid"
2751
- },
2752
- {
2753
- token: "not-a-jwt",
2754
- cause: "malformed"
2755
- },
2756
- {
2757
- token: "a.b.c.d.e",
2758
- cause: "encrypted-token"
2759
- }
2760
- ];
2761
- for (const row of rows) {
2762
- let result;
2880
+ //#endregion
2881
+ //#region src/testing/conformance/identity.ts
2882
+ function testSubjectResolver(resolver, options) {
2883
+ it("never throws and fails closed to anonymous", async () => {
2884
+ let result;
2885
+ try {
2886
+ result = await resolver(options.invalid);
2887
+ } catch {
2888
+ throw new Error("SubjectResolver must not throw");
2889
+ }
2890
+ expect(result.principal).toBeNull();
2891
+ });
2892
+ }
2893
+ function testMembershipSource(source, options) {
2894
+ const query = options.tenant === void 0 ? {} : { tenant: options.tenant };
2895
+ it("returns well-formed memberships and fails closed on throw", async () => {
2896
+ for (const principal of options.principals) {
2897
+ let memberships = [];
2763
2898
  try {
2764
- result = await verifier.verify(row.token, {
2765
- audience,
2766
- issuer
2767
- });
2899
+ memberships = await source.membershipsFor(principal, query);
2768
2900
  } catch {
2769
- throw new Error("TokenVerifier must not throw");
2901
+ memberships = [];
2770
2902
  }
2771
- expect(result.ok).toBe(false);
2772
- if (!result.ok) expect(result.cause).toBe(row.cause);
2903
+ for (const membership of memberships) {
2904
+ const shapes = [
2905
+ membership.scope !== void 0,
2906
+ membership.tenant !== void 0 || membership.team !== void 0,
2907
+ membership.on !== void 0
2908
+ ].filter(Boolean);
2909
+ expect(shapes.length).toBe(1);
2910
+ expect(Array.isArray(membership.roles)).toBe(true);
2911
+ if (options.policy !== void 0) expect(normalizeMemberships([membership], scopeList(options.policy.scopes))).toHaveLength(1);
2912
+ }
2913
+ const expected = options.expect?.[principal.id];
2914
+ if (expected !== void 0) expect(memberships).toEqual(expected);
2773
2915
  }
2774
2916
  });
2775
- }
2776
- /** Settings for `tenant`, or `undefined`; a thrown source fails the runner. */
2777
- function testSettingsSource(source, options) {
2778
- it("answers per tenant with plain settings and nothing for an unknown tenant", async () => {
2779
- const settings = await source.settingsFor(options.tenant);
2780
- if (settings !== void 0) {
2781
- expect(typeof settings).toBe("object");
2782
- const credentials = settings.credentials;
2783
- if (credentials !== void 0) {
2784
- expect(credentials.maxTtl === void 0 || Number.isFinite(credentials.maxTtl) && credentials.maxTtl >= 0).toBe(true);
2785
- expect(credentials.kinds === void 0 || credentials.kinds.every((kind) => kind === "user" || kind === "service")).toBe(true);
2917
+ if (source.list !== void 0) it("lists every member of an instance it returns a membership for", async () => {
2918
+ for (const principal of options.principals) {
2919
+ let memberships = [];
2920
+ try {
2921
+ memberships = await source.membershipsFor(principal, query);
2922
+ } catch {
2923
+ continue;
2924
+ }
2925
+ for (const membership of memberships) {
2926
+ if (membership.scope === void 0 || membership.id === void 0) continue;
2927
+ const members = await source.list?.({
2928
+ scope: membership.scope,
2929
+ id: membership.id
2930
+ }) ?? [];
2931
+ expect(members.some((entry) => entry.principal.id === principal.id && entry.membership.scope === membership.scope && entry.membership.id === membership.id)).toBe(true);
2786
2932
  }
2787
- expect(JSON.parse(JSON.stringify(settings))).toEqual(settings);
2788
2933
  }
2789
- expect(await source.settingsFor(options.unknown ?? "__permdock_unknown__")).toBeUndefined();
2790
2934
  });
2791
- }
2792
- /**
2793
- * Checks an `ApprovalPolicySource`: entries are plain JSON, belong to the
2794
- * queried tenant or to none, and every entry loads against `policy` (an
2795
- * entry that does not would deny every call).
2796
- */
2797
- function testApprovalPolicySource(source, options) {
2798
- it("answers per tenant with plain entries that load against the policy", async () => {
2799
- const entries = await source.approvalPoliciesFor({ tenants: [options.tenant] });
2800
- expect(Array.isArray(entries)).toBe(true);
2801
- for (const entry of entries) {
2802
- expect(JSON.parse(JSON.stringify(entry))).toEqual(entry);
2803
- expect([void 0, options.tenant]).toContain(entry.tenant);
2935
+ if (source.version !== void 0) it("reports a finite version or none", async () => {
2936
+ for (const principal of options.principals) {
2937
+ const version = await source.version?.({ id: principal.id });
2938
+ expect(version === void 0 || typeof version === "number" && Number.isFinite(version)).toBe(true);
2804
2939
  }
2805
- const loaded = await approvalPoliciesFor(options.policy, source, [options.tenant], []);
2806
- expect(loaded).not.toBe("failed");
2807
- const other = await source.approvalPoliciesFor({ tenants: [options.unknown ?? "__permdock_unknown__"] });
2808
- expect(other.filter((entry) => entry.tenant !== void 0)).toEqual([]);
2809
2940
  });
2810
2941
  }
2811
- function flipLast(value) {
2812
- const last = value.at(-1);
2813
- return `${value.slice(0, -1)}${last === "A" ? "B" : "A"}`;
2814
- }
2815
- /**
2816
- * `key` is a live key the verifier knows, or a function issuing one (called
2817
- * once). `revoke`, when given, revokes it; the runner then expects the key
2818
- * to stop verifying.
2819
- */
2820
- function testCredentialVerifier(verifier, options) {
2821
- let issued;
2822
- const live = () => {
2823
- const source = options.key;
2824
- issued ??= Promise.resolve(typeof source === "string" ? source : source());
2825
- return issued;
2826
- };
2827
- it("verifies the live key to a v1 credential with the key id", async () => {
2828
- const key = await live();
2829
- const parts = parseApiKey(key);
2830
- expect(parts).toBeDefined();
2831
- const credential = parseCredential(await verifier.verify(key));
2832
- expect(credential).toBeDefined();
2833
- expect(credential?.id).toBe(parts?.id);
2834
- expect(parseCredential(await verifier.verify(key))).toEqual(credential);
2942
+ function testEntitlementSource(source, options) {
2943
+ it("returns plan names for a tenant and none without one", async () => {
2944
+ const found = await source.entitlementsFor(options.principal, { tenant: options.tenant });
2945
+ expect(Array.isArray(found)).toBe(true);
2946
+ for (const name of found) expect(typeof name).toBe("string");
2947
+ if (options.expect !== void 0) expect([...found].toSorted()).toEqual([...options.expect].toSorted());
2948
+ expect(await source.entitlementsFor(options.principal, {})).toEqual([]);
2835
2949
  });
2836
- it("never throws and answers null for every other key", async () => {
2837
- const key = await live();
2838
- const parts = parseApiKey(key);
2839
- const others = [
2840
- "",
2841
- "garbage",
2842
- "pdk_",
2843
- `${key}x`,
2844
- flipLast(key),
2845
- `pdk_${parts?.id ?? "x"}-other_${parts?.secret ?? ""}`,
2846
- key.replace(/^pdk_/u, "sk_")
2847
- ];
2848
- for (const other of others) {
2849
- let result;
2850
- try {
2851
- result = await verifier.verify(other);
2852
- } catch {
2853
- throw new Error("CredentialVerifier must not throw");
2854
- }
2855
- expect(result).toBeNull();
2950
+ }
2951
+ function testRoleSource(source, options) {
2952
+ it("only resolves declared role names", async () => {
2953
+ const declared = options.declared.map((item) => typeof item === "string" ? item : item.key);
2954
+ const roles = await source.rolesFor(options.tenant);
2955
+ for (const role of roles) {
2956
+ expect(role.tenant).toBe(options.tenant);
2957
+ for (const included of role.includes ?? []) expect(declared).toContain(included);
2958
+ if (options.policy !== void 0) expect(validateCustomRole(options.policy, role).dropped).toEqual([]);
2959
+ }
2960
+ if (source.assignable !== void 0) {
2961
+ const assignable = await source.assignable(options.tenant);
2962
+ for (const name of assignable) expect(declared).toContain(name);
2856
2963
  }
2857
2964
  });
2858
- if (verifier.touch !== void 0) it("accepts a touch without changing what the key verifies to", async () => {
2859
- const key = await live();
2860
- const before = parseCredential(await verifier.verify(key));
2861
- const id = parseApiKey(key)?.id ?? "";
2862
- await verifier.touch?.(id, Math.floor(Date.now() / 1e3));
2863
- await verifier.touch?.("unknown-id", Math.floor(Date.now() / 1e3));
2864
- expect(parseCredential(await verifier.verify(key))).toEqual(before);
2965
+ const every = options.every;
2966
+ if (every !== void 0) it("returns every custom role of the tenant, held or not", async () => {
2967
+ const roles = await source.rolesFor(options.tenant, { held: [] });
2968
+ expect([...new Set(roles.map((role) => role.name))].toSorted()).toEqual([...new Set(every)].toSorted());
2865
2969
  });
2866
- if (options.revoke !== void 0) {
2867
- const revoke = options.revoke;
2868
- it("stops verifying a revoked key", async () => {
2869
- const key = await live();
2870
- await revoke();
2871
- expect(await verifier.verify(key)).toBeNull();
2970
+ }
2971
+ function testDirectoryStore(store, options) {
2972
+ const [home, other] = options.tenants;
2973
+ it("round-trips users and groups, isolates tenants, and drops inactive memberships", async () => {
2974
+ const created = await store.putUser(home, {
2975
+ id: "",
2976
+ userName: "ada",
2977
+ externalId: "00u1",
2978
+ active: true,
2979
+ meta: {
2980
+ created: "",
2981
+ lastModified: ""
2982
+ }
2983
+ });
2984
+ expect(created.id).not.toBe("");
2985
+ expect(await store.getUser(home, created.id)).toMatchObject({
2986
+ userName: "ada",
2987
+ externalId: "00u1"
2988
+ });
2989
+ expect(await store.getUser(other, created.id)).toBeNull();
2990
+ await expect(store.putUser(home, {
2991
+ id: "",
2992
+ userName: "ada",
2993
+ active: true,
2994
+ meta: {
2995
+ created: "",
2996
+ lastModified: ""
2997
+ }
2998
+ })).rejects.toThrow(/userName/);
2999
+ const group = await store.putGroup(home, {
3000
+ id: "g_editors",
3001
+ displayName: "Editors",
3002
+ members: [{ value: created.id }],
3003
+ roles: ["editor"],
3004
+ meta: {
3005
+ created: "",
3006
+ lastModified: ""
3007
+ }
3008
+ });
3009
+ expect(await store.groupsFor(home, created.id)).toEqual([expect.objectContaining({
3010
+ id: group.id,
3011
+ displayName: "Editors"
3012
+ })]);
3013
+ expect(await store.groupsFor(other, created.id)).toEqual([]);
3014
+ const patched = await store.patchUser(home, created.id, [{
3015
+ op: "replace",
3016
+ path: "active",
3017
+ value: false
3018
+ }]);
3019
+ expect(patched.active).toBe(false);
3020
+ const source = directoryMembershipSource(store, { assignable: ["editor"] });
3021
+ expect(await source.membershipsFor({ id: "00u1" }, { tenant: home })).toEqual([]);
3022
+ await store.patchUser(home, created.id, [{
3023
+ op: "replace",
3024
+ path: "active",
3025
+ value: true
3026
+ }]);
3027
+ expect(await source.membershipsFor({ id: "00u1" }, { tenant: home })).toEqual([{
3028
+ tenant: home,
3029
+ roles: ["editor"],
3030
+ via: `group:${group.id}`,
3031
+ managedBy: "idp"
3032
+ }]);
3033
+ await store.patchGroup(home, group.id, [{
3034
+ op: "remove",
3035
+ path: "members",
3036
+ value: [{ value: created.id }]
3037
+ }]);
3038
+ expect(await store.groupsFor(home, created.id)).toEqual([]);
3039
+ });
3040
+ it("ends sessions when scimHandler deactivates a user in this store", async () => {
3041
+ const feed = memoryRevocationFeed();
3042
+ const seen = [];
3043
+ feed.subscribe((event) => {
3044
+ seen.push(event);
3045
+ });
3046
+ const kinds = [];
3047
+ const token = "conformance-scim-token";
3048
+ const handle = scimHandler({
3049
+ store,
3050
+ tenant: home,
3051
+ token: {
3052
+ hash: "sha256",
3053
+ lookup: () => sha256Hex(token)
3054
+ },
3055
+ revocations: feed,
3056
+ onChange: (change) => {
3057
+ kinds.push(change.kind);
3058
+ }
3059
+ });
3060
+ const call = (path, method, body) => handle(new Request(`https://app.example.com/scim/v2${path}`, {
3061
+ method,
3062
+ headers: {
3063
+ authorization: `Bearer ${token}`,
3064
+ "content-type": "application/scim+json"
3065
+ },
3066
+ body: JSON.stringify(body)
3067
+ }));
3068
+ const created = await call("/Users", "POST", {
3069
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
3070
+ userName: "grace",
3071
+ active: true
2872
3072
  });
2873
- }
2874
- }
2875
- function testTokenSigner(signer, options) {
2876
- it("emits compact JWS with only alg, kid and typ", async () => {
2877
- const token = await signer.sign({
2878
- snapshot: { v: 1 },
2879
- sub: "u_1"
2880
- }, {
2881
- typ: "permdock-snapshot+jwt",
2882
- audience: "https://app.example.com"
3073
+ expect(created.status).toBe(201);
3074
+ const id = String((await created.json()).id);
3075
+ seen.length = 0;
3076
+ const patched = await call(`/Users/${id}`, "PATCH", {
3077
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
3078
+ Operations: [{
3079
+ op: "replace",
3080
+ path: "active",
3081
+ value: false
3082
+ }]
2883
3083
  });
2884
- const header = decodeHeader(token);
2885
- expect(Object.keys(header).toSorted()).toEqual([
2886
- "alg",
2887
- "kid",
2888
- "typ"
2889
- ]);
2890
- expect(header["typ"]).toBe("permdock-snapshot+jwt");
2891
- expect(header["alg"]).not.toBe("none");
2892
- const verified = await options.verifier.verify(token, {
2893
- typ: "permdock-snapshot+jwt",
2894
- audience: "https://app.example.com"
3084
+ expect(patched.status).toBe(200);
3085
+ expect(seen).toContainEqual({
3086
+ principal: "grace",
3087
+ tenant: home,
3088
+ kind: "session-revoked"
2895
3089
  });
2896
- expect(verified.ok).toBe(true);
2897
- if (signer.jwks !== void 0) {
2898
- const jwks = await signer.jwks();
2899
- expect(jwks.keys.length).toBeGreaterThan(0);
2900
- expect(jwks.keys[0]).not.toHaveProperty("d");
2901
- }
3090
+ expect(seen.every((event) => event.kind === "session-revoked")).toBe(true);
3091
+ expect(kinds).toEqual(["changed", "session-revoked"]);
2902
3092
  });
2903
3093
  }
2904
- const WHERE_ROWS = [
2905
- {
2906
- id: "r1",
2907
- authorId: "u1",
2908
- score: 5,
2909
- title: "Plan 100%",
2910
- archived: false,
2911
- note: null
2912
- },
2913
- {
2914
- id: "r2",
2915
- authorId: "u2",
2916
- score: 10,
2917
- title: "plan_b",
2918
- archived: true,
2919
- note: "kept"
2920
- },
2921
- {
2922
- id: "r3",
2923
- authorId: null,
2924
- score: null,
2925
- title: null,
2926
- archived: null,
2927
- note: null
2928
- }
2929
- ];
2930
- const WHERE_CASES = [
2931
- ["eq", {
2932
- op: "eq",
2933
- field: "authorId",
2934
- value: "u1"
2935
- }],
2936
- ["ne", {
2937
- op: "ne",
2938
- field: "authorId",
2939
- value: "u1"
2940
- }],
2941
- ["eq on a boolean", {
2942
- op: "eq",
2943
- field: "archived",
2944
- value: false
2945
- }],
2946
- ["gt", {
2947
- op: "gt",
2948
- field: "score",
2949
- value: 5
2950
- }],
2951
- ["gte", {
2952
- op: "gte",
2953
- field: "score",
2954
- value: 5
2955
- }],
2956
- ["lt", {
2957
- op: "lt",
2958
- field: "score",
2959
- value: 10
2960
- }],
2961
- ["lte", {
2962
- op: "lte",
2963
- field: "score",
2964
- value: 10
2965
- }],
2966
- ["contains a literal %", {
2967
- op: "contains",
2968
- field: "title",
2969
- value: "100%"
2970
- }],
2971
- ["contains a literal _", {
2972
- op: "contains",
2973
- field: "title",
2974
- value: "n_b"
2975
- }],
2976
- ["in", {
2977
- op: "in",
2978
- field: "authorId",
2979
- value: ["u1", "u2"]
2980
- }],
2981
- ["notIn", {
2982
- op: "notIn",
2983
- field: "authorId",
2984
- value: ["u1"]
2985
- }],
2986
- ["isNull", {
2987
- op: "isNull",
2988
- field: "note",
2989
- value: true
2990
- }],
2991
- ["is not null", {
2992
- op: "isNull",
2993
- field: "note",
2994
- value: false
2995
- }],
2996
- ["and", {
2997
- op: "and",
2998
- conditions: [{
2999
- op: "eq",
3000
- field: "authorId",
3001
- value: "u1"
3002
- }, {
3003
- op: "lt",
3004
- field: "score",
3005
- value: 10
3006
- }]
3007
- }],
3008
- ["or", {
3009
- op: "or",
3010
- conditions: [{
3011
- op: "eq",
3012
- field: "authorId",
3013
- value: "u1"
3014
- }, {
3015
- op: "eq",
3016
- field: "archived",
3017
- value: true
3018
- }]
3019
- }],
3020
- ["not over a NULL field", {
3021
- op: "not",
3022
- condition: {
3023
- op: "eq",
3024
- field: "authorId",
3025
- value: "u1"
3026
- }
3027
- }],
3028
- ["not over and", {
3029
- op: "not",
3030
- condition: {
3031
- op: "and",
3032
- conditions: [{
3033
- op: "eq",
3034
- field: "authorId",
3035
- value: "u2"
3036
- }, {
3037
- op: "gt",
3038
- field: "score",
3039
- value: 5
3040
- }]
3041
- }
3042
- }],
3043
- ["and of nothing", {
3044
- op: "and",
3045
- conditions: []
3046
- }],
3047
- ["not of the empty allow set", {
3048
- op: "not",
3049
- condition: {
3050
- op: "or",
3051
- conditions: []
3094
+ /** Settings for `tenant`, or `undefined`; a thrown source fails the runner. */
3095
+ function testSettingsSource(source, options) {
3096
+ it("answers per tenant with plain settings and nothing for an unknown tenant", async () => {
3097
+ const settings = await source.settingsFor(options.tenant);
3098
+ if (settings !== void 0) {
3099
+ expect(typeof settings).toBe("object");
3100
+ const credentials = settings.credentials;
3101
+ if (credentials !== void 0) {
3102
+ expect(credentials.maxTtl === void 0 || Number.isFinite(credentials.maxTtl) && credentials.maxTtl >= 0).toBe(true);
3103
+ expect(credentials.kinds === void 0 || credentials.kinds.every((kind) => kind === "user" || kind === "service")).toBe(true);
3104
+ }
3105
+ expect(JSON.parse(JSON.stringify(settings))).toEqual(settings);
3052
3106
  }
3053
- }]
3054
- ];
3055
- const WHERE_SUBJECT = {
3056
- principal: null,
3057
- context: {}
3058
- };
3059
- /**
3060
- * `matches` answers whether the compiled output selects `row`, for example
3061
- * by running it against a table holding only that row. With it, every case
3062
- * must select the rows the in-memory evaluator keeps, including rows whose
3063
- * fields are NULL.
3064
- */
3065
- function testWhereCompiler(compiler, options) {
3066
- const { matches } = options;
3067
- it.skipIf(matches === void 0).each(WHERE_CASES)("selects the rows the evaluator keeps: %s", async (_name, condition) => {
3068
- if (matches === void 0) return;
3069
- const compiled = compiler(condition, options.target);
3070
- const selected = [];
3071
- for (const row of WHERE_ROWS) if (await matches(compiled, row)) selected.push(row["id"]);
3072
- expect(selected).toEqual(WHERE_ROWS.filter((row) => evaluateCondition(condition, row, WHERE_SUBJECT)).map((row) => row["id"]));
3073
- });
3074
- it("fails closed on an empty allow set", () => {
3075
- const compiled = compiler({
3076
- op: "or",
3077
- conditions: []
3078
- }, options.target);
3079
- const closed = options.isFailClosed === void 0 ? compiled === false || compiled === void 0 || compiled === null : options.isFailClosed(compiled);
3080
- expect(closed).toBe(true);
3081
- });
3082
- it("compiles a sqlFunction through its twin", () => {
3083
- const twin = {
3084
- op: "eq",
3085
- field: "authorId",
3086
- value: "u1"
3087
- };
3088
- expect(() => compiler({
3089
- op: "sqlFunction",
3090
- name: "job_permitted",
3091
- args: [{ field: "id" }],
3092
- twin
3093
- }, options.target)).not.toThrow();
3107
+ expect(await source.settingsFor(options.unknown ?? "__permdock_unknown__")).toBeUndefined();
3094
3108
  });
3095
3109
  }
3096
3110
  //#endregion