@classytic/arc 2.28.0 → 2.31.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 (239) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +7 -0
  3. package/TRADEMARK.md +22 -0
  4. package/dist/{BaseController-B65zbz9m.mjs → BaseController-De7GqHck.mjs} +25 -24
  5. package/dist/{EventTransport-QmY7A258.d.mts → EventTransport-2uVV34WU.d.mts} +1 -0
  6. package/dist/EventTransport-9mlTgqzE.mjs +104 -0
  7. package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-BcmPyq5I.mjs} +16 -35
  8. package/dist/{QueryCache-DOMT_iq7.d.mts → QueryCache-DFA-QrU9.d.mts} +2 -1
  9. package/dist/{ResourceRegistry-y9Hnypus.mjs → ResourceRegistry-qq0mn2s9.mjs} +3 -2
  10. package/dist/{actionPermissions-CAphcmUG.mjs → actionPermissions-BE5NwaQk.mjs} +1 -0
  11. package/dist/audit/index.d.mts +3 -2
  12. package/dist/audit/index.mjs +3 -2
  13. package/dist/auth/audit.d.mts +1 -0
  14. package/dist/auth/audit.mjs +1 -0
  15. package/dist/auth/index.d.mts +5 -4
  16. package/dist/auth/index.mjs +6 -5
  17. package/dist/auth/redis-session.d.mts +2 -1
  18. package/dist/auth/redis-session.mjs +1 -0
  19. package/dist/authorizationDecision-ChvUQGG1.mjs +266 -0
  20. package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-CDrCJzmB.mjs} +34 -7
  21. package/dist/cache/index.d.mts +4 -3
  22. package/dist/cache/index.mjs +4 -3
  23. package/dist/{caching-_NJeDd1_.mjs → caching-BFHA6Scf.mjs} +2 -1
  24. package/dist/cleanup/index.d.mts +4 -3
  25. package/dist/cleanup/index.mjs +9 -13
  26. package/dist/cli/commands/describe.d.mts +2 -1
  27. package/dist/cli/commands/describe.mjs +3 -2
  28. package/dist/cli/commands/docs.d.mts +1 -0
  29. package/dist/cli/commands/docs.mjs +3 -2
  30. package/dist/cli/commands/doctor.d.mts +1 -0
  31. package/dist/cli/commands/doctor.mjs +1 -0
  32. package/dist/cli/commands/generate.d.mts +2 -1
  33. package/dist/cli/commands/generate.mjs +2 -1
  34. package/dist/cli/commands/init.d.mts +2 -1
  35. package/dist/cli/commands/init.mjs +2 -1
  36. package/dist/cli/commands/introspect.d.mts +1 -0
  37. package/dist/cli/commands/introspect.mjs +2 -1
  38. package/dist/cli/index.d.mts +3 -2
  39. package/dist/cli/index.mjs +3 -2
  40. package/dist/{constants-DNre0wRg.mjs → constants-D3Dv4LAV.mjs} +1 -0
  41. package/dist/context/index.d.mts +1 -0
  42. package/dist/context/index.mjs +3 -2
  43. package/dist/core/index.d.mts +3 -2
  44. package/dist/core/index.mjs +7 -5
  45. package/dist/{core-BGa8TXJR.mjs → core-BGpIxINN.mjs} +4 -49
  46. package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-CDXEdasl.mjs} +8 -7
  47. package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-KrQDzx-k.mjs} +10 -22
  48. package/dist/{createApp-Du5G54TM.mjs → createApp-Df89BEni.mjs} +505 -209
  49. package/dist/defineAggregation-DbPPS8RI.mjs +48 -0
  50. package/dist/{defineResource-2zvVVpxg.mjs → defineResource-Bv6EtN1x.mjs} +90 -72
  51. package/dist/discovery/index.d.mts +1 -0
  52. package/dist/discovery/index.mjs +1 -0
  53. package/dist/docs/index.d.mts +3 -2
  54. package/dist/docs/index.mjs +3 -2
  55. package/dist/{elevation-C9s5hFP_.d.mts → elevation-BXCfVcNG.d.mts} +1 -0
  56. package/dist/{elevation-CIz1GjGm.mjs → elevation-D2l1mh4g.mjs} +4 -3
  57. package/dist/encryption/index.d.mts +2 -1
  58. package/dist/encryption/index.mjs +3 -2
  59. package/dist/{errorHandler-Bq2OeEQL.mjs → errorHandler-C5NeD6bT.mjs} +3 -2
  60. package/dist/{errorHandler-C6yv4ZTW.d.mts → errorHandler-CpYUJmPA.d.mts} +1 -0
  61. package/dist/{errors-DHcHVAgk.d.mts → errors-BGdVwP25.d.mts} +1 -0
  62. package/dist/{errors-DkO8Nk6-.mjs → errors-Cj42AntO.mjs} +1 -0
  63. package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-CkffEihm.mjs} +6 -106
  64. package/dist/{eventPlugin-Bg3cP2_u.d.mts → eventPlugin-GF5nnBIq.d.mts} +2 -1
  65. package/dist/events/index.d.mts +4 -3
  66. package/dist/events/index.mjs +17 -340
  67. package/dist/events/transports/redis-stream-entry.d.mts +2 -1
  68. package/dist/events/transports/redis-stream-entry.mjs +1 -0
  69. package/dist/events/transports/redis.d.mts +2 -1
  70. package/dist/events/transports/redis.mjs +1 -0
  71. package/dist/{externalPaths-BE6wFpYv.d.mts → externalPaths-CmGOxOtD.d.mts} +1 -0
  72. package/dist/factory/index.d.mts +9 -5
  73. package/dist/factory/index.mjs +7 -6
  74. package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-zh0_AYHa.d.mts} +13 -4
  75. package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CB5Cuzk9.mjs} +2 -107
  76. package/dist/{fields-D3lhgzhD.d.mts → fields-I_EeRWND.d.mts} +1 -0
  77. package/dist/{generate-BnsL9GBm.d.mts → generate-DCGuEbqI.d.mts} +1 -0
  78. package/dist/{generate-D0wZupBg.mjs → generate-EcA6ui4i.mjs} +2 -1
  79. package/dist/{headers-BvlQSDMB.mjs → headers-3zvE0rM3.mjs} +2 -1
  80. package/dist/{health-CB_m4S32.mjs → health-DjZYjM7H.mjs} +1 -0
  81. package/dist/hooks/index.d.mts +2 -1
  82. package/dist/hooks/index.mjs +2 -1
  83. package/dist/idempotency/index.d.mts +3 -2
  84. package/dist/idempotency/index.mjs +5 -4
  85. package/dist/idempotency/redis.d.mts +2 -1
  86. package/dist/idempotency/redis.mjs +1 -0
  87. package/dist/{index-CjVg-Epg.d.mts → index-Bc2ox8_U.d.mts} +6 -5
  88. package/dist/{index-CG4yqMDB.d.mts → index-C-481Zf2.d.mts} +74 -18
  89. package/dist/{index-BMnbzvtO.d.mts → index-CEJNZFtX.d.mts} +242 -24
  90. package/dist/index-CFThn1K9.d.mts +224 -0
  91. package/dist/{index-DPRH0Cme.d.mts → index-CpzxqePp.d.mts} +203 -97
  92. package/dist/index.d.mts +9 -8
  93. package/dist/index.mjs +11 -9
  94. package/dist/{init-tlRSt667.mjs → init-BEiNZBsJ.mjs} +19 -23
  95. package/dist/{init-BPEu8cs-.d.mts → init-Cscs4lab.d.mts} +1 -0
  96. package/dist/integrations/event-gateway.d.mts +3 -2
  97. package/dist/integrations/event-gateway.mjs +2 -1
  98. package/dist/integrations/index.d.mts +4 -3
  99. package/dist/integrations/index.mjs +1 -0
  100. package/dist/integrations/jobs/index.d.mts +2 -1
  101. package/dist/integrations/jobs/index.mjs +108 -32
  102. package/dist/integrations/mcp/index.d.mts +9 -9
  103. package/dist/integrations/mcp/index.mjs +2 -1
  104. package/dist/integrations/mcp/testing.d.mts +3 -2
  105. package/dist/integrations/mcp/testing.mjs +2 -2
  106. package/dist/integrations/streamline.d.mts +1 -0
  107. package/dist/integrations/streamline.mjs +3 -2
  108. package/dist/integrations/webhooks.d.mts +1 -0
  109. package/dist/integrations/webhooks.mjs +1 -0
  110. package/dist/integrations/websocket-pushref-redis.d.mts +2 -1
  111. package/dist/integrations/websocket-pushref-redis.mjs +1 -0
  112. package/dist/integrations/websocket-redis.d.mts +2 -1
  113. package/dist/integrations/websocket-redis.mjs +1 -0
  114. package/dist/integrations/websocket.d.mts +3 -2
  115. package/dist/integrations/websocket.mjs +1 -0
  116. package/dist/{interface-BGZCzOGL.d.mts → interface-BOk3YnTz.d.mts} +1 -0
  117. package/dist/{interface-Uk8ONJv7.d.mts → interface-DEWNOMza.d.mts} +1 -0
  118. package/dist/{interface-n5hpKg1d.mjs → interface-DYzM0q-u.mjs} +1 -0
  119. package/dist/{jsonBody-BlzAoGCe.mjs → jsonBody-BYbLDi5D.mjs} +2 -1
  120. package/dist/{keys-Cd316u_y.mjs → keys-C0_pa1tY.mjs} +1 -0
  121. package/dist/{loadResources-DpU8pfjV.mjs → loadResources-yawi9tAE.mjs} +2 -1
  122. package/dist/{loadResourcesFromEntry-kvGT6EcO.mjs → loadResourcesFromEntry-BAjYZonx.mjs} +2 -1
  123. package/dist/logger/index.d.mts +1 -0
  124. package/dist/logger/index.mjs +1 -0
  125. package/dist/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-B_es0hiF.mjs} +52 -60
  126. package/dist/{memory-CmnzXE5Z.mjs → memory-DR6wpa9F.mjs} +2 -1
  127. package/dist/{metrics-fdxI5mn_.mjs → metrics-CUdw_wLB.mjs} +2 -1
  128. package/dist/middleware/index.d.mts +2 -1
  129. package/dist/middleware/index.mjs +3 -2
  130. package/dist/migrations/index.d.mts +1 -3
  131. package/dist/migrations/index.mjs +2 -4
  132. package/dist/module-0pFJA6YW.mjs +78 -0
  133. package/dist/{multipartBody-D0eBvkeq.mjs → multipartBody-wOgj_fnj.mjs} +1 -0
  134. package/dist/{openapi-BLV4OMXb.mjs → openapi-B2L4js1v.mjs} +6 -5
  135. package/dist/permissions/index.d.mts +5 -4
  136. package/dist/permissions/index.mjs +6 -4
  137. package/dist/{permissions-BrtiBKGs.mjs → permissions-L--QDAir.mjs} +518 -316
  138. package/dist/{pipe-BOLmGIxa.mjs → pipe-DRDNsI2T.mjs} +2 -1
  139. package/dist/pipeline/index.d.mts +2 -1
  140. package/dist/pipeline/index.mjs +2 -1
  141. package/dist/{pipeline-BGRnV-To.mjs → pipeline-UPQFatit.mjs} +7 -20
  142. package/dist/plugins/index.d.mts +8 -7
  143. package/dist/plugins/index.mjs +16 -15
  144. package/dist/plugins/response-cache.d.mts +1 -0
  145. package/dist/plugins/response-cache.mjs +2 -1
  146. package/dist/plugins/tracing-entry.d.mts +2 -1
  147. package/dist/plugins/tracing-entry.mjs +2 -1
  148. package/dist/{pluralize-BhrgwZb9.mjs → pluralize-O0FZgESl.mjs} +1 -0
  149. package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-CyoJMSiO.d.mts} +117 -12
  150. package/dist/{preloadResources-DSY5HGpS.mjs → preloadResources-Dz6BjzM6.mjs} +1 -0
  151. package/dist/presets/filesUpload.d.mts +6 -5
  152. package/dist/presets/filesUpload.mjs +9 -11
  153. package/dist/presets/index.d.mts +15 -2
  154. package/dist/presets/index.mjs +2 -1
  155. package/dist/presets/multiTenant.d.mts +2 -1
  156. package/dist/presets/multiTenant.mjs +3 -2
  157. package/dist/presets/search.d.mts +3 -2
  158. package/dist/presets/search.mjs +2 -1
  159. package/dist/{presets-DwFDVKP0.mjs → presets-C4-g4_4P.mjs} +27 -10
  160. package/dist/projection-BHw6xlOQ.mjs +18 -0
  161. package/dist/{pushref-registry-BbcnpNTm.d.mts → pushref-registry-Dov9l4qE.d.mts} +1 -0
  162. package/dist/{queryCachePlugin-DSGuSVLW.mjs → queryCachePlugin-BZr8v46E.mjs} +5 -4
  163. package/dist/{queryCachePlugin-br4otiqF.d.mts → queryCachePlugin-BzqxconG.d.mts} +3 -2
  164. package/dist/{rateLimit-DrED-C4v.mjs → rateLimit-CMPY5Qms.mjs} +7 -3
  165. package/dist/{rateLimitKey-BoWRH3e7.mjs → rateLimitKey-Dn5JNmkj.mjs} +1 -0
  166. package/dist/{realtime-DEuDu9io.mjs → realtime-C5NoxWFY.mjs} +20 -8
  167. package/dist/{redis-UDtLaNj6.d.mts → redis-CduhKsJi.d.mts} +2 -1
  168. package/dist/{redis-stream-uIaauNpZ.d.mts → redis-stream-qN6A-RFv.d.mts} +2 -1
  169. package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-CBN84fc7.mjs} +5 -2
  170. package/dist/registry/index.d.mts +3 -2
  171. package/dist/registry/index.mjs +3 -2
  172. package/dist/{registry-9km_w1aP.mjs → registry-4213U4zb.mjs} +3 -2
  173. package/dist/{renewingLease-3cuQH7h8.mjs → renewingLease-Dll8mGgc.mjs} +1 -0
  174. package/dist/{replyHelpers-67tRawQZ.mjs → replyHelpers-DIiZKGUt.mjs} +2 -1
  175. package/dist/repositoryFilter-f9eAfq_l.mjs +45 -0
  176. package/dist/{requestContext-MrlJwECf.mjs → requestContext-2TxXOJ7T.mjs} +1 -0
  177. package/dist/{rolldown-runtime-8H4AJuhK.mjs → rolldown-runtime-CUsjNX9R.mjs} +1 -0
  178. package/dist/{schedules-CjOd2MG9.mjs → schedules-DdtlICTf.mjs} +3 -2
  179. package/dist/{schemaConverter-DJvodiHj.mjs → schemaConverter-D--wdJM-.mjs} +70 -17
  180. package/dist/{schemaIR-Ca7ySNPx.mjs → schemaIR-Bm2enbnh.mjs} +2 -1
  181. package/dist/schemas/index.d.mts +1 -0
  182. package/dist/schemas/index.mjs +1 -0
  183. package/dist/scim/index.d.mts +1 -0
  184. package/dist/scim/index.mjs +2 -1
  185. package/dist/scope/index.d.mts +3 -2
  186. package/dist/scope/index.mjs +5 -4
  187. package/dist/{scope-DfaPntpo.mjs → scope-DHx5Fous.mjs} +4 -3
  188. package/dist/{sessionManager-BHOM3N_0.d.mts → sessionManager-qimlKV0P.d.mts} +1 -0
  189. package/dist/{simpleEqualityMatcher-Cg05HPsn.mjs → simpleEqualityMatcher-DjQJ7FKA.mjs} +1 -0
  190. package/dist/{sse-BQ1w0dfl.mjs → sse-CUTo9y0X.mjs} +5 -4
  191. package/dist/{sseStream-BQ7Qaynp.mjs → sseStream-B_5XcVVd.mjs} +2 -1
  192. package/dist/{storage-DLUZ04kK.d.mts → storage-Cx9Sz3Ou.d.mts} +1 -0
  193. package/dist/{store-helpers-DY6xq4sM.mjs → store-helpers-D1MP_gRG.mjs} +1 -0
  194. package/dist/{streaming-BQcW6_Kq.mjs → streaming-PSZwi8JV.mjs} +1 -0
  195. package/dist/subscribe-helpers-C0Tb2aUp.mjs +340 -0
  196. package/dist/sync/changelog.d.mts +1 -0
  197. package/dist/sync/changelog.mjs +1 -0
  198. package/dist/testing/cleanupStoreContract.d.mts +2 -1
  199. package/dist/testing/cleanupStoreContract.mjs +1 -0
  200. package/dist/testing/index.d.mts +167 -12
  201. package/dist/testing/index.mjs +522 -205
  202. package/dist/testing/outboxStoreContract.d.mts +1 -0
  203. package/dist/testing/outboxStoreContract.mjs +1 -0
  204. package/dist/testing/storageContract.d.mts +2 -1
  205. package/dist/testing/storageContract.mjs +1 -0
  206. package/dist/{tracing-CsGKQJGg.d.mts → tracing-Dro5eH6j.d.mts} +1 -0
  207. package/dist/{transactionContext-PNdoyLLm.mjs → transactionContext-dAfBVUrk.mjs} +1 -0
  208. package/dist/{typeGuards-C9KXUly6.mjs → typeGuards-B0ryPo3I.mjs} +1 -0
  209. package/dist/types/index.d.mts +6 -5
  210. package/dist/types/index.mjs +1 -0
  211. package/dist/types/storage.d.mts +2 -1
  212. package/dist/types/storage.mjs +1 -0
  213. package/dist/{types-Dmw-6Qeo.d.mts → types-B7XeLEgK.d.mts} +127 -90
  214. package/dist/{types-Cc91nXuA.d.mts → types-ByHn9rEO.d.mts} +1 -0
  215. package/dist/{types-C_ZZRcD6.d.mts → types-C4QE8qnm.d.mts} +1 -0
  216. package/dist/{types-Cu6vVJmh.d.mts → types-CBgvkp8O.d.mts} +82 -12
  217. package/dist/{types-DjC-KUCz.d.mts → types-ChuShpCP.d.mts} +1 -0
  218. package/dist/{types-QFGc3Cjv.d.mts → types-CwcUQ610.d.mts} +2 -6
  219. package/dist/{types-CSyCxl2V.mjs → types-Cys0ko9Z.mjs} +4 -3
  220. package/dist/usage/index.d.mts +3 -2
  221. package/dist/usage/index.mjs +0 -0
  222. package/dist/{userHelpers-Cg_y3fBz.mjs → userHelpers-ChYJ6CdK.mjs} +1 -0
  223. package/dist/utils/index.d.mts +16 -10
  224. package/dist/utils/index.mjs +10 -9
  225. package/dist/{utils-taUVD4p3.mjs → utils-vL8BrDE2.mjs} +3 -2
  226. package/dist/{validate-Bk44F2yT.mjs → validate-_KbifpHF.mjs} +2 -1
  227. package/dist/{versioning-gKWyryT3.mjs → versioning-CHDTor3P.mjs} +3 -2
  228. package/dist/{versioning-DXgY5BUm.d.mts → versioning-D_rhHfZm.d.mts} +5 -2
  229. package/dist/{websocket-_Pd0CgR0.d.mts → websocket-BOXVggMf.d.mts} +2 -1
  230. package/package.json +15 -7
  231. package/skills/arc/SKILL.md +22 -8
  232. package/skills/arc/references/api-reference.md +4 -0
  233. package/skills/arc/references/auth.md +3 -0
  234. package/skills/arc/references/integrations.md +33 -0
  235. package/skills/arc/references/mcp.md +6 -7
  236. package/skills/arc/references/production.md +24 -25
  237. package/skills/arc-code-review/references/anti-patterns.md +3 -3
  238. package/dist/index-CZY4EGga.d.mts +0 -107
  239. package/dist/module-_-M7C5ES.mjs +0 -29
@@ -1,10 +1,69 @@
1
- import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
- import { n as getUserRoles } from "./userHelpers-Cg_y3fBz.mjs";
3
- import { C as isService, S as isOrgInScope, a as getDPoPJkt, b as isElevated, d as getScopeContext, f as getScopeContextMap, g as getUserId, m as getTeamId, o as getMandate, p as getServiceScopes, u as getRequestScope, v as hasOrgAccess, x as isMember } from "./types-CSyCxl2V.mjs";
4
- import { t as MemoryCacheStore } from "./memory-CmnzXE5Z.mjs";
1
+ /*! @classytic/arc v2.31.0 | MIT | https://classytic.com */
2
+ import { t as __exportAll } from "./rolldown-runtime-CUsjNX9R.mjs";
3
+ import { arcLog } from "./logger/index.mjs";
4
+ import { a as conjoinPolicyFilters, i as normalizeToDecision } from "./authorizationDecision-ChvUQGG1.mjs";
5
+ import { n as getUserRoles } from "./userHelpers-ChYJ6CdK.mjs";
6
+ import { C as isService, S as isOrgInScope, a as getDPoPJkt, b as isElevated, d as getScopeContext, f as getScopeContextMap, g as getUserId, m as getTeamId, n as PUBLIC_SCOPE, o as getMandate, p as getServiceScopes, u as getRequestScope, v as hasOrgAccess, x as isMember } from "./types-Cys0ko9Z.mjs";
7
+ import { t as MemoryCacheStore } from "./memory-DR6wpa9F.mjs";
5
8
  import { randomUUID } from "node:crypto";
9
+ //#region src/permissions/context.ts
10
+ /**
11
+ * PermissionContext accessors (arc 2.30 — transport-neutral evaluation).
12
+ *
13
+ * The goal of the standardized authorization core is that a `PermissionCheck`
14
+ * reads FACTS off its {@link PermissionContext}, never off a raw
15
+ * `FastifyRequest`. That makes evaluation:
16
+ * - pure (a combinator can hand a child a NEW context with an updated scope
17
+ * instead of mutating `request.scope` — see `algebra.ts`),
18
+ * - transport-neutral (MCP / jobs / websockets build a context directly, no
19
+ * `fakeRequest`),
20
+ * - unit-testable (no Fastify object required).
21
+ *
22
+ * `scopeOf` is the single seam every primitive uses to read the request scope.
23
+ * It prefers the context's first-class `scope` (set by combinators + adapters)
24
+ * and falls back to the raw request during migration (the `ctx.request` shim
25
+ * removed in the final phase). Once `scope` is populated everywhere, the
26
+ * fallback is dead and drops out.
27
+ */
28
+ /**
29
+ * Resolve the request scope for a permission check. Prefers the context's
30
+ * first-class `scope`; falls back to the raw HTTP `request.scope` when present,
31
+ * else `PUBLIC_SCOPE`. Primitives MUST use this instead of touching
32
+ * `ctx.request` so scope threads through combinators and non-HTTP transports
33
+ * (MCP, jobs) — which have no request — resolve identity correctly.
34
+ */
35
+ function scopeOf(ctx) {
36
+ if (ctx.scope) return ctx.scope;
37
+ return ctx.request ? getRequestScope(ctx.request) : PUBLIC_SCOPE;
38
+ }
39
+ //#endregion
6
40
  //#region src/permissions/core.ts
7
41
  /**
42
+ * Build an `allow` {@link AuthorizationDecision}, optionally attaching a row-level
43
+ * `policy` and/or a `scope` to install. The idiomatic "grant" return for arc
44
+ * 2.30 checks (parallels Cedar's `permit`).
45
+ *
46
+ * @example
47
+ * return allow(); // simple grant
48
+ * return allow({ policy: { ownerId: userId } }); // grant + row policy
49
+ */
50
+ function allow(extra) {
51
+ return extra ? {
52
+ effect: "allow",
53
+ ...extra
54
+ } : { effect: "allow" };
55
+ }
56
+ /**
57
+ * Build a `deny` {@link AuthorizationDecision} with an optional human-readable
58
+ * reason (parallels Cedar's `forbid`). The idiomatic "refuse" return.
59
+ */
60
+ function deny(reason) {
61
+ return reason ? {
62
+ effect: "deny",
63
+ reason
64
+ } : { effect: "deny" };
65
+ }
66
+ /**
8
67
  * Normalize a `string | [readonly string[]]` rest-args tuple into a single
9
68
  * `readonly string[]`. Lets a permission helper accept BOTH variadic and
10
69
  * array call shapes from one overload signature.
@@ -45,12 +104,10 @@ function allowPublic() {
45
104
  */
46
105
  function requireAuth() {
47
106
  const check = (ctx) => {
48
- if (!ctx.user) return {
49
- granted: false,
50
- reason: "Authentication required"
51
- };
107
+ if (!ctx.user) return deny("Authentication required");
52
108
  return true;
53
109
  };
110
+ check._requiresAuth = true;
54
111
  return check;
55
112
  }
56
113
  function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
@@ -69,24 +126,48 @@ function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
69
126
  }
70
127
  const includeOrgRoles = options?.includeOrgRoles ?? true;
71
128
  const check = (ctx) => {
72
- if (!ctx.user) return {
73
- granted: false,
74
- reason: "Authentication required"
75
- };
129
+ if (!ctx.user) return deny("Authentication required");
76
130
  const userRoles = getUserRoles(ctx.user);
77
131
  if (options?.bypassRoles?.some((r) => userRoles.includes(r))) return true;
78
132
  if (roles.some((r) => userRoles.includes(r))) return true;
79
133
  if (includeOrgRoles) {
80
- const scope = getRequestScope(ctx.request);
134
+ const scope = scopeOf(ctx);
81
135
  if (isElevated(scope)) return true;
82
136
  if (isMember(scope) && roles.some((r) => scope.orgRoles.includes(r))) return true;
83
137
  }
84
- return {
85
- granted: false,
86
- reason: `Required roles: ${roles.join(", ")}`
87
- };
138
+ return deny(`Required roles: ${roles.join(", ")}`);
139
+ };
140
+ check._roles = roles;
141
+ return check;
142
+ }
143
+ /**
144
+ * Require a PLATFORM role — global roles only. An organization role can never
145
+ * satisfy this gate.
146
+ *
147
+ * `requireRoles(["ops"])` also accepts an ORG role named `ops` (that is its
148
+ * documented default), and `requireOrgRole("manager")` grants ANY organization's
149
+ * manager. Both return a bare allow with no policy, so a surface that is global
150
+ * by construction — a queue dashboard, a cluster-wide operations endpoint —
151
+ * cannot distinguish them from a real operator gate by looking at the decision.
152
+ * That makes "platform-only" a property the check has to DECLARE, which is what
153
+ * `_platformOnly` records and what such a surface can then require.
154
+ *
155
+ * Elevated scope passes: platform admin is the identity this gate is for.
156
+ *
157
+ * @example
158
+ * permissions: { list: requirePlatformRole("platform-ops") }
159
+ */
160
+ function requirePlatformRole(...args) {
161
+ const roles = normalizeVariadicOrArray(args);
162
+ const check = (ctx) => {
163
+ if (isElevated(scopeOf(ctx))) return true;
164
+ if (!ctx.user) return deny("Authentication required");
165
+ const userRoles = getUserRoles(ctx.user);
166
+ if (roles.some((r) => userRoles.includes(r))) return true;
167
+ return deny(`Required platform roles: ${roles.join(", ")}`);
88
168
  };
89
169
  check._roles = roles;
170
+ check._platformOnly = true;
90
171
  return check;
91
172
  }
92
173
  /**
@@ -97,19 +178,13 @@ function requireRoles(rolesOrFirst, optionsOrSecond, ...rest) {
97
178
  function roles(...args) {
98
179
  const roleList = normalizeVariadicOrArray(args);
99
180
  const check = (ctx) => {
100
- if (!ctx.user) return {
101
- granted: false,
102
- reason: "Authentication required"
103
- };
181
+ if (!ctx.user) return deny("Authentication required");
104
182
  const userRoles = getUserRoles(ctx.user);
105
183
  if (roleList.some((r) => userRoles.includes(r))) return true;
106
- const scope = getRequestScope(ctx.request);
184
+ const scope = scopeOf(ctx);
107
185
  if (isElevated(scope)) return true;
108
186
  if (isMember(scope) && roleList.some((r) => scope.orgRoles.includes(r))) return true;
109
- return {
110
- granted: false,
111
- reason: `Required roles: ${roleList.join(", ")}`
112
- };
187
+ return deny(`Required roles: ${roleList.join(", ")}`);
113
188
  };
114
189
  check._roles = roleList;
115
190
  return check;
@@ -128,31 +203,29 @@ function roles(...args) {
128
203
  */
129
204
  function requireOwnership(ownerField = "userId", options) {
130
205
  return (ctx) => {
131
- if (!ctx.user) return {
132
- granted: false,
133
- reason: "Authentication required"
134
- };
206
+ if (!ctx.user) return deny("Authentication required");
135
207
  const userRoles = getUserRoles(ctx.user);
136
208
  if (options?.bypassRoles?.some((r) => userRoles.includes(r))) return true;
137
- const userId = getUserId(getRequestScope(ctx.request)) ?? ctx.user.id ?? ctx.user._id;
138
- if (!userId) return {
139
- granted: false,
140
- reason: "User identity missing (no id or _id)"
141
- };
142
- return {
143
- granted: true,
144
- filters: { [ownerField]: userId }
145
- };
209
+ const userId = getUserId(scopeOf(ctx)) ?? ctx.user.id ?? ctx.user._id;
210
+ if (!userId) return deny("User identity missing (no id or _id)");
211
+ return allow({ policy: { [ownerField]: userId } });
146
212
  };
147
213
  }
148
214
  /**
149
215
  * Combine multiple checks — ALL must pass (AND logic).
150
216
  *
151
- * Each child runs against the **accumulated** state of previous children:
152
- * - `filters` from earlier children merge into the next child's `_policyFilters`
153
- * - `scope` from earlier children installs on the request before the next child runs
217
+ * Evaluation is PURE — nothing is written to the request. Each child runs
218
+ * against the accumulated state of previous children, threaded through a fresh
219
+ * child context:
220
+ * - `policy` from earlier children is CONJOINED (logical AND) — a later child
221
+ * can add or narrow restrictions but never silently replaces an earlier
222
+ * child's constraint on the same key (conflicting values are preserved under
223
+ * `$and`; see `conjoinPolicyFilters`)
224
+ * - `scope` installed by an earlier child is visible to the next through
225
+ * `scopeOf(ctx)`, and never downgrades an already-authoritative scope
154
226
  *
155
- * The final result carries both merged `filters` and merged `scope`.
227
+ * The returned decision carries the merged `policy` + `scope`; the enforcement
228
+ * point (`applyAuthorizationDecision`) applies them to the request once.
156
229
  *
157
230
  * @example
158
231
  * ```typescript
@@ -163,50 +236,46 @@ function requireOwnership(ownerField = "userId", options) {
163
236
  * ```
164
237
  */
165
238
  function allOf(...checks) {
166
- return async (ctx) => {
239
+ const check = async (ctx) => {
167
240
  let mergedFilters = {};
168
241
  let installedScope;
169
- const sink = ctx.request;
170
- const originalFilters = sink._policyFilters;
171
- const originalScope = sink.scope;
172
- try {
173
- for (const check of checks) {
174
- const result = await check(ctx);
175
- const normalized = typeof result === "boolean" ? { granted: result } : result;
176
- if (!normalized.granted) {
177
- sink._policyFilters = originalFilters;
178
- sink.scope = originalScope;
179
- return normalized;
180
- }
181
- if (normalized.filters) {
182
- mergedFilters = {
183
- ...mergedFilters,
184
- ...normalized.filters
185
- };
186
- sink._policyFilters = {
187
- ...sink._policyFilters ?? {},
188
- ...normalized.filters
189
- };
190
- }
191
- if (normalized.scope) {
192
- const current = sink.scope;
193
- if (!current || current.kind === "public") {
194
- sink.scope = normalized.scope;
195
- installedScope = normalized.scope;
196
- } else if (!installedScope) installedScope = normalized.scope;
197
- }
198
- }
199
- } catch (err) {
200
- sink._policyFilters = originalFilters;
201
- sink.scope = originalScope;
202
- throw err;
242
+ let threaded = ctx.scope;
243
+ let childCtx = ctx;
244
+ for (const inner of checks) {
245
+ const decision = normalizeToDecision(await inner(childCtx));
246
+ if (decision.effect !== "allow") return deny(decision.reason);
247
+ if (decision.policy) mergedFilters = conjoinPolicyFilters(mergedFilters, decision.policy);
248
+ if (decision.scope && (!threaded || threaded.kind === "public")) {
249
+ threaded = decision.scope;
250
+ installedScope = decision.scope;
251
+ } else if (decision.scope && !installedScope) installedScope = decision.scope;
252
+ if (threaded !== childCtx.scope) childCtx = {
253
+ ...childCtx,
254
+ scope: threaded
255
+ };
203
256
  }
204
- return {
205
- granted: true,
206
- filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : void 0,
207
- scope: installedScope
208
- };
257
+ return allow({
258
+ ...Object.keys(mergedFilters).length > 0 ? { policy: mergedFilters } : {},
259
+ ...installedScope ? { scope: installedScope } : {}
260
+ });
209
261
  };
262
+ const roleBranches = checks.filter((c) => (c._roles?.length ?? 0) > 0 || (c._orgRoles?.length ?? 0) > 0);
263
+ if (checks.length > 0 && checks.every((c) => c._isPublic)) check._isPublic = true;
264
+ else if (roleBranches.length === 1) {
265
+ const [rb] = roleBranches;
266
+ if (rb?._roles?.length) check._roles = [...rb._roles];
267
+ if (rb?._orgRoles?.length) check._orgRoles = [...rb._orgRoles];
268
+ }
269
+ const scopeContext = {};
270
+ let scopeConflict = false;
271
+ for (const c of checks) for (const [dim, val] of Object.entries(c._scopeContext ?? {})) if (dim in scopeContext && scopeContext[dim] !== val) scopeConflict = true;
272
+ else scopeContext[dim] = val;
273
+ if (Object.keys(scopeContext).length > 0) check._scopeContext = scopeContext;
274
+ const hasMetaSurface = (c) => Boolean(c._isPublic || c._roles?.length || c._orgRoles?.length || c._scopeContext && Object.keys(c._scopeContext).length > 0);
275
+ const opaqueBranch = checks.some((c) => !hasMetaSurface(c) && !c._requiresAuth);
276
+ const childConditional = checks.some((c) => c._conditional);
277
+ if (opaqueBranch || scopeConflict || childConditional) check._conditional = true;
278
+ return check;
210
279
  }
211
280
  /**
212
281
  * Combine multiple checks — ANY must pass (OR logic).
@@ -219,19 +288,31 @@ function allOf(...checks) {
219
288
  * ```
220
289
  */
221
290
  function anyOf(...checks) {
222
- return async (ctx) => {
291
+ const check = async (ctx) => {
223
292
  const reasons = [];
224
- for (const check of checks) {
225
- const result = await check(ctx);
226
- const normalized = typeof result === "boolean" ? { granted: result } : result;
227
- if (normalized.granted) return normalized;
228
- if (normalized.reason) reasons.push(normalized.reason);
293
+ for (const inner of checks) {
294
+ const decision = normalizeToDecision(await inner(ctx));
295
+ if (decision.effect === "allow") return allow({
296
+ ...decision.policy ? { policy: decision.policy } : {},
297
+ ...decision.scope ? { scope: decision.scope } : {}
298
+ });
299
+ if (decision.reason) reasons.push(decision.reason);
229
300
  }
230
- return {
231
- granted: false,
232
- reason: reasons.join("; ")
233
- };
301
+ return deny(reasons.join("; "));
234
302
  };
303
+ if (checks.some((c) => c._isPublic)) check._isPublic = true;
304
+ else if (checks.every((c) => (c._roles?.length ?? 0) > 0 || (c._orgRoles?.length ?? 0) > 0)) {
305
+ const roles = /* @__PURE__ */ new Set();
306
+ const orgRoles = /* @__PURE__ */ new Set();
307
+ for (const c of checks) {
308
+ for (const r of c._roles ?? []) roles.add(r);
309
+ for (const r of c._orgRoles ?? []) orgRoles.add(r);
310
+ }
311
+ if (roles.size > 0) check._roles = [...roles];
312
+ if (orgRoles.size > 0) check._orgRoles = [...orgRoles];
313
+ if (checks.some((c) => c._conditional)) check._conditional = true;
314
+ }
315
+ return check;
235
316
  }
236
317
  /**
237
318
  * Invert a permission check. Grants when the wrapped check denies, denies
@@ -251,11 +332,7 @@ function anyOf(...checks) {
251
332
  */
252
333
  function not(check, reason = "Access denied") {
253
334
  return async (ctx) => {
254
- const result = await check(ctx);
255
- return (typeof result === "boolean" ? { granted: result } : result).granted ? {
256
- granted: false,
257
- reason
258
- } : true;
335
+ return normalizeToDecision(await check(ctx)).effect === "allow" ? deny(reason) : true;
259
336
  };
260
337
  }
261
338
  /**
@@ -267,10 +344,7 @@ function not(check, reason = "Access denied") {
267
344
  * ```
268
345
  */
269
346
  function denyAll(reason = "Access denied") {
270
- return () => ({
271
- granted: false,
272
- reason
273
- });
347
+ return () => deny(reason);
274
348
  }
275
349
  /**
276
350
  * Dynamic permission based on a condition function.
@@ -284,11 +358,7 @@ function denyAll(reason = "Access denied") {
284
358
  */
285
359
  function when(condition) {
286
360
  return async (ctx) => {
287
- const result = await condition(ctx);
288
- return {
289
- granted: result,
290
- reason: result ? void 0 : "Condition not met"
291
- };
361
+ return await condition(ctx) ? allow() : deny("Condition not met");
292
362
  };
293
363
  }
294
364
  //#endregion
@@ -349,16 +419,10 @@ const DEFAULT_TTL_GRACE_MS = 3e4;
349
419
  */
350
420
  function requireDPoP() {
351
421
  const check = (ctx) => {
352
- const scope = getRequestScope(ctx.request);
422
+ const scope = scopeOf(ctx);
353
423
  if (isElevated(scope)) return true;
354
- if (!isService(scope)) return {
355
- granted: false,
356
- reason: "DPoP-bound service identity required. Configure your authenticate callback to set scope.dpopJkt after verifying the DPoP proof header (RFC 9449)."
357
- };
358
- if (!getDPoPJkt(scope)) return {
359
- granted: false,
360
- reason: "Sender-constrained credential required (DPoP). Inbound token is bearer; replay-resistance is mandatory on this endpoint."
361
- };
424
+ if (!isService(scope)) return deny("DPoP-bound service identity required. Configure your authenticate callback to set scope.dpopJkt after verifying the DPoP proof header (RFC 9449).");
425
+ if (!getDPoPJkt(scope)) return deny("Sender-constrained credential required (DPoP). Inbound token is bearer; replay-resistance is mandatory on this endpoint.");
362
426
  return true;
363
427
  };
364
428
  check._dpopRequired = true;
@@ -404,38 +468,20 @@ function requireMandate(capability, opts = {}) {
404
468
  const ttlGrace = opts.ttlGraceMs ?? DEFAULT_TTL_GRACE_MS;
405
469
  const noElevatedBypass = opts.noElevatedBypass === true;
406
470
  const check = (ctx) => {
407
- const scope = getRequestScope(ctx.request);
471
+ const scope = scopeOf(ctx);
408
472
  if (isElevated(scope) && !noElevatedBypass) return true;
409
473
  const mandate = getMandate(scope);
410
- if (!mandate) return {
411
- granted: false,
412
- reason: `Capability mandate required (${capability}). Configure your authenticate callback to populate request.scope.mandate after verifying the mandate JWT/VC.`
413
- };
414
- if (mandate.capability !== capability) return {
415
- granted: false,
416
- reason: `Mandate authorizes "${mandate.capability}", not "${capability}"`
417
- };
418
- if (mandate.expiresAt !== void 0 && Date.now() > mandate.expiresAt + ttlGrace) return {
419
- granted: false,
420
- reason: `Mandate expired at ${new Date(mandate.expiresAt).toISOString()}`
421
- };
474
+ if (!mandate) return deny(`Capability mandate required (${capability}). Configure your authenticate callback to populate request.scope.mandate after verifying the mandate JWT/VC.`);
475
+ if (mandate.capability !== capability) return deny(`Mandate authorizes "${mandate.capability}", not "${capability}"`);
476
+ if (mandate.expiresAt !== void 0 && Date.now() > mandate.expiresAt + ttlGrace) return deny(`Mandate expired at ${new Date(mandate.expiresAt).toISOString()}`);
422
477
  if (opts.audience) {
423
478
  const required = typeof opts.audience === "function" ? opts.audience(ctx) : opts.audience;
424
- if (required && mandate.audience && mandate.audience !== required) return {
425
- granted: false,
426
- reason: `Mandate is bound to "${mandate.audience}", not "${required}"`
427
- };
428
- if (required && !mandate.audience) return {
429
- granted: false,
430
- reason: `Mandate must be bound to "${required}" (no audience claim)`
431
- };
479
+ if (required && mandate.audience && mandate.audience !== required) return deny(`Mandate is bound to "${mandate.audience}", not "${required}"`);
480
+ if (required && !mandate.audience) return deny(`Mandate must be bound to "${required}" (no audience claim)`);
432
481
  }
433
482
  if (opts.validateAmount) {
434
483
  const result = opts.validateAmount(ctx, mandate);
435
- if (result !== true) return {
436
- granted: false,
437
- reason: typeof result === "string" ? result : `Action exceeds mandate cap ${mandate.cap}${mandate.currency ? ` ${mandate.currency}` : ""}`
438
- };
484
+ if (result !== true) return deny(typeof result === "string" ? result : `Action exceeds mandate cap ${mandate.cap}${mandate.currency ? ` ${mandate.currency}` : ""}`);
439
485
  }
440
486
  return true;
441
487
  };
@@ -482,30 +528,18 @@ function requireAgentScope(opts) {
482
528
  const dpopCheck = needsDPoP ? requireDPoP() : null;
483
529
  const requiredScopes = scopes && scopes.length > 0 ? [...scopes] : null;
484
530
  const check = async (ctx) => {
485
- const scope = getRequestScope(ctx.request);
531
+ const scope = scopeOf(ctx);
486
532
  if (isElevated(scope) && !mandateOpts.noElevatedBypass) return true;
487
- if (!isService(scope)) return {
488
- granted: false,
489
- reason: "Service identity required (machine principal). Agent flows must authenticate as a service, not a logged-in user."
490
- };
533
+ if (!isService(scope)) return deny("Service identity required (machine principal). Agent flows must authenticate as a service, not a logged-in user.");
491
534
  if (requiredScopes) {
492
535
  const granted = scope.scopes ?? [];
493
- if (!requiredScopes.some((s) => granted.includes(s))) return {
494
- granted: false,
495
- reason: `Service identity is missing required OAuth scope(s): ${requiredScopes.join(", ")}`
496
- };
536
+ if (!requiredScopes.some((s) => granted.includes(s))) return deny(`Service identity is missing required OAuth scope(s): ${requiredScopes.join(", ")}`);
497
537
  }
498
538
  const mandateResult = await mandateCheck(ctx);
499
- if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult : {
500
- granted: false,
501
- reason: "Mandate check failed"
502
- };
539
+ if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult : deny("Mandate check failed");
503
540
  if (dpopCheck) {
504
541
  const dpopResult = await dpopCheck(ctx);
505
- if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult : {
506
- granted: false,
507
- reason: "DPoP binding required"
508
- };
542
+ if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult : deny("DPoP binding required");
509
543
  }
510
544
  return true;
511
545
  };
@@ -521,9 +555,9 @@ function requireAgentScope(opts) {
521
555
  /**
522
556
  * Permission Scope — checks bound to RequestScope.
523
557
  *
524
- * All read `request.scope` populated by an auth adapter (Better Auth bridge,
525
- * JWT custom auth, or an upstream permission check returning
526
- * `PermissionResult.scope`).
558
+ * All read the request scope via `scopeOf(ctx)` - populated by an auth adapter
559
+ * (Better Auth bridge, JWT custom auth) or threaded from an upstream check's
560
+ * `AuthorizationDecision.scope`.
527
561
  */
528
562
  /**
529
563
  * Require an org-bound caller. Grants for `member`, `service`, and
@@ -544,15 +578,9 @@ function requireAgentScope(opts) {
544
578
  */
545
579
  function requireOrgMembership() {
546
580
  const check = (ctx) => {
547
- if (hasOrgAccess(getRequestScope(ctx.request))) return true;
548
- if (!ctx.user) return {
549
- granted: false,
550
- reason: "Authentication required"
551
- };
552
- return {
553
- granted: false,
554
- reason: "Organization membership required"
555
- };
581
+ if (hasOrgAccess(scopeOf(ctx))) return true;
582
+ if (!ctx.user) return deny("Authentication required");
583
+ return deny("Organization membership required");
556
584
  };
557
585
  check._orgPermission = "membership";
558
586
  return check;
@@ -579,25 +607,13 @@ function requireOrgMembership() {
579
607
  function requireOrgRole(...args) {
580
608
  const roles = normalizeVariadicOrArray(args);
581
609
  const check = (ctx) => {
582
- const scope = getRequestScope(ctx.request);
610
+ const scope = scopeOf(ctx);
583
611
  if (isElevated(scope)) return true;
584
- if (isService(scope)) return {
585
- granted: false,
586
- reason: "Service scopes (API keys) cannot satisfy requireOrgRole. Use requireServiceScope(...) for machine identities, or compose with anyOf(requireOrgRole(...), requireServiceScope(...)) to accept both."
587
- };
588
- if (!ctx.user) return {
589
- granted: false,
590
- reason: "Authentication required"
591
- };
592
- if (!isMember(scope)) return {
593
- granted: false,
594
- reason: "Organization membership required"
595
- };
612
+ if (isService(scope)) return deny("Service scopes (API keys) cannot satisfy requireOrgRole. Use requireServiceScope(...) for machine identities, or compose with anyOf(requireOrgRole(...), requireServiceScope(...)) to accept both.");
613
+ if (!ctx.user) return deny("Authentication required");
614
+ if (!isMember(scope)) return deny("Organization membership required");
596
615
  if (roles.some((r) => scope.orgRoles.includes(r))) return true;
597
- return {
598
- granted: false,
599
- reason: `Required org roles: ${roles.join(", ")}`
600
- };
616
+ return deny(`Required org roles: ${roles.join(", ")}`);
601
617
  };
602
618
  check._orgRoles = roles;
603
619
  return check;
@@ -629,18 +645,12 @@ function requireServiceScope(...args) {
629
645
  const required = normalizeVariadicOrArray(args);
630
646
  if (required.length === 0) throw new Error("requireServiceScope() requires at least one scope string (e.g. requireServiceScope('jobs:write'))");
631
647
  const check = (ctx) => {
632
- const scope = getRequestScope(ctx.request);
648
+ const scope = scopeOf(ctx);
633
649
  if (isElevated(scope)) return true;
634
- if (!isService(scope)) return {
635
- granted: false,
636
- reason: "Service identity required (API key). For human users, use requireOrgRole(...) or compose with anyOf(requireOrgRole(...), requireServiceScope(...))."
637
- };
650
+ if (!isService(scope)) return deny("Service identity required (API key). For human users, use requireOrgRole(...) or compose with anyOf(requireOrgRole(...), requireServiceScope(...)).");
638
651
  const granted = getServiceScopes(scope);
639
652
  if (required.some((r) => granted.includes(r))) return true;
640
- return {
641
- granted: false,
642
- reason: `Required service scopes: ${required.join(", ")} (granted: ${granted.length > 0 ? granted.join(", ") : "none"})`
643
- };
653
+ return deny(`Required service scopes: ${required.join(", ")} (granted: ${granted.length > 0 ? granted.join(", ") : "none"})`);
644
654
  };
645
655
  check._serviceScopes = required;
646
656
  return check;
@@ -678,23 +688,14 @@ function requireScopeContext(keyOrMap, value) {
678
688
  const requiredKeys = Object.keys(required);
679
689
  if (requiredKeys.length === 0) throw new Error("requireScopeContext() requires at least one key (e.g. requireScopeContext('branchId'))");
680
690
  const check = (ctx) => {
681
- const scope = getRequestScope(ctx.request);
691
+ const scope = scopeOf(ctx);
682
692
  if (isElevated(scope)) return true;
683
- if (!getScopeContextMap(scope)) return {
684
- granted: false,
685
- reason: "Scope context required (member, service, or elevated scope). Populate request.scope.context in your auth function."
686
- };
693
+ if (!getScopeContextMap(scope)) return deny("Scope context required (member, service, or elevated scope). Populate request.scope.context in your auth function.");
687
694
  for (const key of requiredKeys) {
688
695
  const expected = required[key];
689
696
  const actual = getScopeContext(scope, key);
690
- if (actual === void 0) return {
691
- granted: false,
692
- reason: `Required scope context key "${key}" is missing`
693
- };
694
- if (expected !== void 0 && actual !== expected) return {
695
- granted: false,
696
- reason: `Required scope context "${key}" must equal "${expected}" (got "${actual}")`
697
- };
697
+ if (actual === void 0) return deny(`Required scope context key "${key}" is missing`);
698
+ if (expected !== void 0 && actual !== expected) return deny(`Required scope context "${key}" must equal "${expected}" (got "${actual}")`);
698
699
  }
699
700
  return true;
700
701
  };
@@ -711,8 +712,8 @@ function requireScopeContext(keyOrMap, value) {
711
712
  *
712
713
  * **Two call shapes:**
713
714
  * ```typescript
714
- * requireOrgInScope('acme-holding') // static
715
- * requireOrgInScope((ctx) => ctx.request.params.orgId) // dynamic
715
+ * requireOrgInScope('acme-holding') // static
716
+ * requireOrgInScope((ctx) => ctx.params?.orgId) // dynamic — from ctx facts, not the raw request
716
717
  * ```
717
718
  *
718
719
  * `elevated` scope grants unconditionally (cross-org bypass).
@@ -720,9 +721,9 @@ function requireScopeContext(keyOrMap, value) {
720
721
  * @example
721
722
  * ```typescript
722
723
  * permissions: {
723
- * list: requireOrgInScope((ctx) => ctx.request.params.orgId),
724
+ * list: requireOrgInScope((ctx) => ctx.params?.orgId),
724
725
  * create: allOf(
725
- * requireOrgInScope((ctx) => ctx.request.body?.organizationId),
726
+ * requireOrgInScope((ctx) => (ctx.data as { organizationId?: string })?.organizationId),
726
727
  * requireOrgRole('admin'),
727
728
  * ),
728
729
  * }
@@ -731,18 +732,12 @@ function requireScopeContext(keyOrMap, value) {
731
732
  function requireOrgInScope(target) {
732
733
  if (target === void 0 || target === null) throw new Error("requireOrgInScope() requires a target org id (string) or an extractor function");
733
734
  const check = (ctx) => {
734
- const scope = getRequestScope(ctx.request);
735
+ const scope = scopeOf(ctx);
735
736
  if (isElevated(scope)) return true;
736
737
  const targetOrgId = typeof target === "function" ? target(ctx) : target;
737
- if (!targetOrgId) return {
738
- granted: false,
739
- reason: "requireOrgInScope: target org id could not be resolved from the request"
740
- };
738
+ if (!targetOrgId) return deny("requireOrgInScope: target org id could not be resolved from the request");
741
739
  if (isOrgInScope(scope, targetOrgId)) return true;
742
- return {
743
- granted: false,
744
- reason: `Target organization "${targetOrgId}" is not in the caller's org hierarchy`
745
- };
740
+ return deny(`Target organization "${targetOrgId}" is not in the caller's org hierarchy`);
746
741
  };
747
742
  check._orgInScopeTarget = target;
748
743
  return check;
@@ -762,20 +757,11 @@ function requireOrgInScope(target) {
762
757
  */
763
758
  function requireTeamMembership() {
764
759
  const check = (ctx) => {
765
- if (!ctx.user) return {
766
- granted: false,
767
- reason: "Authentication required"
768
- };
769
- const scope = getRequestScope(ctx.request);
760
+ if (!ctx.user) return deny("Authentication required");
761
+ const scope = scopeOf(ctx);
770
762
  if (isElevated(scope)) return true;
771
- if (!isMember(scope)) return {
772
- granted: false,
773
- reason: "Organization membership required"
774
- };
775
- if (!getTeamId(scope)) return {
776
- granted: false,
777
- reason: "No active team"
778
- };
763
+ if (!isMember(scope)) return deny("Organization membership required");
764
+ if (!getTeamId(scope)) return deny("No active team");
779
765
  return true;
780
766
  };
781
767
  check._teamPermission = "membership";
@@ -832,21 +818,12 @@ function createOrgPermissions(config) {
832
818
  return {
833
819
  can(permissions) {
834
820
  return (ctx) => {
835
- if (!ctx.user) return {
836
- granted: false,
837
- reason: "Authentication required"
838
- };
839
- const scope = getRequestScope(ctx.request);
821
+ if (!ctx.user) return deny("Authentication required");
822
+ const scope = scopeOf(ctx);
840
823
  if (isElevated(scope)) return true;
841
- if (!isMember(scope)) return {
842
- granted: false,
843
- reason: "Organization membership required"
844
- };
824
+ if (!isMember(scope)) return deny("Organization membership required");
845
825
  if (hasPermissions(scope.orgRoles, permissions)) return true;
846
- return {
847
- granted: false,
848
- reason: `Missing permissions: ${Object.entries(permissions).map(([r, a]) => `${r}:[${a.join(",")}]`).join(", ")}`
849
- };
826
+ return deny(`Missing permissions: ${Object.entries(permissions).map(([r, a]) => `${r}:[${a.join(",")}]`).join(", ")}`);
850
827
  };
851
828
  },
852
829
  requireRole(...roles) {
@@ -873,7 +850,7 @@ function createOrgPermissions(config) {
873
850
  * - Cross-node invalidation via the event bus
874
851
  */
875
852
  function createDynamicPermissionMatrix(config) {
876
- const logger = config.logger ?? console;
853
+ const logger = config.logger ?? arcLog("permissions:dynamic");
877
854
  const configuredTtlSeconds = config.cache?.ttlSeconds ?? 0;
878
855
  const hasExternalStore = !!config.cacheStore;
879
856
  const cacheTtlSeconds = configuredTtlSeconds > 0 ? configuredTtlSeconds : hasExternalStore ? 300 : 0;
@@ -883,20 +860,66 @@ function createDynamicPermissionMatrix(config) {
883
860
  }) : void 0;
884
861
  const cacheStore = config.cacheStore ?? internalStore;
885
862
  const trackedKeys = /* @__PURE__ */ new Set();
863
+ const orgKeyIndex = /* @__PURE__ */ new Map();
864
+ const maxTrackedKeys = config.cache?.maxEntries ?? 1e4;
865
+ /**
866
+ * Record a key and ENFORCE the cap in the same step.
867
+ *
868
+ * The cap has to live here rather than at the write site: a node that mostly
869
+ * READS a shared cache registers on every hit but may rarely set, so a
870
+ * cap applied only after a `set` never runs and the bookkeeping grows with
871
+ * every distinct org/user/role combination the node has ever seen — unbounded,
872
+ * and outliving the cache entries themselves, which the external store expires
873
+ * on its own schedule. Eviction is insertion-ordered (`Set` iteration order),
874
+ * so the oldest bookkeeping goes first; dropping a key only costs a missed
875
+ * local invalidation for an entry that will still expire by TTL.
876
+ */
877
+ const registerKey = (orgId, key) => {
878
+ trackedKeys.add(key);
879
+ const bucket = orgId ?? "no-org";
880
+ let keys = orgKeyIndex.get(bucket);
881
+ if (!keys) {
882
+ keys = /* @__PURE__ */ new Set();
883
+ orgKeyIndex.set(bucket, keys);
884
+ }
885
+ keys.add(key);
886
+ if (trackedKeys.size > maxTrackedKeys) {
887
+ const overflow = trackedKeys.size - maxTrackedKeys;
888
+ const iter = trackedKeys.values();
889
+ for (let i = 0; i < overflow; i++) {
890
+ const oldest = iter.next().value;
891
+ if (oldest) forgetKey(oldest);
892
+ }
893
+ }
894
+ };
895
+ const forgetKey = (key) => {
896
+ trackedKeys.delete(key);
897
+ for (const [bucket, keys] of orgKeyIndex) if (keys.delete(key) && keys.size === 0) orgKeyIndex.delete(bucket);
898
+ };
886
899
  const nodeId = randomUUID().slice(0, 8);
887
900
  const DEFAULT_EVENT_TYPE = "arc.permissions.invalidated";
888
901
  let eventBridge = null;
889
902
  async function localInvalidateByOrg(orgId) {
890
903
  if (!cacheStore) return;
891
904
  const prefix = `${orgId}::`;
892
- const toDelete = [];
893
- for (const key of trackedKeys) if (key.startsWith(prefix)) toDelete.push(key);
905
+ if (cacheStore.clear) try {
906
+ await cacheStore.clear(`${prefix}*`);
907
+ const keys = orgKeyIndex.get(orgId);
908
+ if (keys) for (const k of keys) trackedKeys.delete(k);
909
+ orgKeyIndex.delete(orgId);
910
+ return;
911
+ } catch (error) {
912
+ logger.warn(`[DynamicPermissionMatrix] invalidateByOrg clear('${prefix}*') failed — falling back to per-key delete: ${error instanceof Error ? error.message : String(error)}`);
913
+ }
914
+ const toDelete = new Set(orgKeyIndex.get(orgId) ?? []);
915
+ for (const key of trackedKeys) if (key.startsWith(prefix)) toDelete.add(key);
894
916
  for (const key of toDelete) try {
895
917
  await cacheStore.delete(key);
896
- trackedKeys.delete(key);
918
+ forgetKey(key);
897
919
  } catch (error) {
898
920
  logger.warn(`[DynamicPermissionMatrix] invalidateByOrg delete failed for '${key}': ${error instanceof Error ? error.message : String(error)}`);
899
921
  }
922
+ orgKeyIndex.delete(orgId);
900
923
  }
901
924
  function isActionAllowed(actions, action) {
902
925
  if (!actions || actions.length === 0) return false;
@@ -916,27 +939,22 @@ function createDynamicPermissionMatrix(config) {
916
939
  }
917
940
  async function resolveMatrix(ctx, orgId, orgRoles) {
918
941
  if (!cacheStore) return config.resolveRolePermissions(ctx);
919
- const cacheKey = config.cache?.key?.(ctx) ?? buildDefaultCacheKey(ctx, orgId, orgRoles);
942
+ const customKey = config.cache?.key?.(ctx);
943
+ const cacheKey = customKey ? `${orgId ?? "no-org"}::${(orgRoles ?? []).slice().sort().join(",")}::${customKey}` : buildDefaultCacheKey(ctx, orgId, orgRoles);
920
944
  if (!cacheKey) return config.resolveRolePermissions(ctx);
921
945
  try {
922
946
  const hit = await cacheStore.get(cacheKey);
923
- if (hit) return hit;
947
+ if (hit) {
948
+ registerKey(orgId, cacheKey);
949
+ return hit;
950
+ }
924
951
  } catch (error) {
925
952
  logger.warn(`[DynamicPermissionMatrix] Cache get failed for '${cacheKey}': ${error instanceof Error ? error.message : String(error)}`);
926
953
  }
927
954
  const value = await config.resolveRolePermissions(ctx);
928
955
  try {
929
956
  await cacheStore.set(cacheKey, value, cacheTtlSeconds);
930
- trackedKeys.add(cacheKey);
931
- const maxTracked = config.cache?.maxEntries ?? 1e4;
932
- if (trackedKeys.size > maxTracked) {
933
- const overflow = trackedKeys.size - maxTracked;
934
- const iter = trackedKeys.values();
935
- for (let i = 0; i < overflow; i++) {
936
- const oldest = iter.next().value;
937
- if (oldest) trackedKeys.delete(oldest);
938
- }
939
- }
957
+ registerKey(orgId, cacheKey);
940
958
  } catch (error) {
941
959
  logger.warn(`[DynamicPermissionMatrix] Cache set failed for '${cacheKey}': ${error instanceof Error ? error.message : String(error)}`);
942
960
  }
@@ -944,34 +962,20 @@ function createDynamicPermissionMatrix(config) {
944
962
  }
945
963
  function can(required) {
946
964
  return async (ctx) => {
947
- if (!ctx.user) return {
948
- granted: false,
949
- reason: "Authentication required"
950
- };
951
- const scope = getRequestScope(ctx.request);
965
+ if (!ctx.user) return deny("Authentication required");
966
+ const scope = scopeOf(ctx);
952
967
  if (isElevated(scope)) return true;
953
- if (!isMember(scope)) return {
954
- granted: false,
955
- reason: "Organization membership required"
956
- };
968
+ if (!isMember(scope)) return deny("Organization membership required");
957
969
  const orgRoles = scope.orgRoles;
958
- if (orgRoles.length === 0) return {
959
- granted: false,
960
- reason: "Not a member of this organization"
961
- };
970
+ if (orgRoles.length === 0) return deny("Not a member of this organization");
962
971
  let matrix;
963
972
  try {
964
973
  matrix = await resolveMatrix(ctx, scope.organizationId, orgRoles);
965
974
  } catch (error) {
966
- return {
967
- granted: false,
968
- reason: `Permission matrix resolution failed: ${error instanceof Error ? error.message : String(error)}`
969
- };
975
+ logger.warn(`[DynamicPermissionMatrix] matrix resolution failed for org '${scope.organizationId ?? "?"}': ${error instanceof Error ? error.message : String(error)}`);
976
+ return deny("Permission policy is temporarily unavailable");
970
977
  }
971
- for (const [resource, actions] of Object.entries(required)) for (const action of actions) if (!orgRoles.some((role) => roleAllows(matrix, role, resource, action))) return {
972
- granted: false,
973
- reason: `Missing permission: ${resource}:${action}`
974
- };
978
+ for (const [resource, actions] of Object.entries(required)) for (const action of actions) if (!orgRoles.some((role) => roleAllows(matrix, role, resource, action))) return deny(`Missing permission: ${resource}:${action}`);
975
979
  return true;
976
980
  };
977
981
  }
@@ -1005,6 +1009,7 @@ function createDynamicPermissionMatrix(config) {
1005
1009
  if (cacheStore.clear) try {
1006
1010
  await cacheStore.clear();
1007
1011
  trackedKeys.clear();
1012
+ orgKeyIndex.clear();
1008
1013
  return;
1009
1014
  } catch (error) {
1010
1015
  logger.warn(`[DynamicPermissionMatrix] cacheStore.clear failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -1015,6 +1020,7 @@ function createDynamicPermissionMatrix(config) {
1015
1020
  logger.warn(`[DynamicPermissionMatrix] Cache delete failed for '${key}': ${error instanceof Error ? error.message : String(error)}`);
1016
1021
  }
1017
1022
  trackedKeys.clear();
1023
+ orgKeyIndex.clear();
1018
1024
  },
1019
1025
  async connectEvents(events, options) {
1020
1026
  if (eventBridge) await this.disconnectEvents();
@@ -1052,8 +1058,187 @@ function createDynamicPermissionMatrix(config) {
1052
1058
  };
1053
1059
  }
1054
1060
  //#endregion
1061
+ //#region src/permissions/explain.ts
1062
+ /**
1063
+ * Extract a check's static requirement from its `PermissionCheckMeta`. Public
1064
+ * wins; then a scope-context gate (optionally with roles); then the union of
1065
+ * platform + org roles a role gate declares; otherwise "authenticated"
1066
+ * (auth-required / custom / request-time checks). An `allOf` that couldn't be
1067
+ * fully captured statically carries `conditional: true` (see `_conditional`).
1068
+ */
1069
+ function describePermission(check) {
1070
+ if (check._isPublic) return { kind: "public" };
1071
+ const roles = [...check._roles ?? [], ...check._orgRoles ?? []];
1072
+ const scope = check._scopeContext;
1073
+ const conditional = check._conditional ? true : void 0;
1074
+ if (scope && Object.keys(scope).length > 0) return roles.length > 0 ? {
1075
+ kind: "scoped",
1076
+ scope,
1077
+ roles,
1078
+ conditional
1079
+ } : {
1080
+ kind: "scoped",
1081
+ scope,
1082
+ conditional
1083
+ };
1084
+ if (roles.length > 0) return {
1085
+ kind: "roles",
1086
+ roles,
1087
+ conditional
1088
+ };
1089
+ return { kind: "authenticated" };
1090
+ }
1091
+ /**
1092
+ * Explain whether a principal (by roles) passes a permission check, from static
1093
+ * metadata alone — for permission-matrix UIs, docs, and audits. Omit `principal`
1094
+ * to get the requirement-only view (roles → `conditional`).
1095
+ *
1096
+ * @example
1097
+ * explainAccess(requireRoles("admin"), { roles: ["editor"] })
1098
+ * // → { decision: "deny", reason: "requires one of: admin", requirement: { kind: "roles", roles: ["admin"] } }
1099
+ */
1100
+ function explainAccess(check, principal) {
1101
+ const requirement = describePermission(check);
1102
+ if (requirement.kind === "public") return {
1103
+ decision: "allow",
1104
+ reason: "public — no authentication required",
1105
+ requirement
1106
+ };
1107
+ if (requirement.kind === "authenticated") return {
1108
+ decision: "conditional",
1109
+ reason: "requires authentication; decided per request",
1110
+ requirement
1111
+ };
1112
+ const held = /* @__PURE__ */ new Set([...principal?.roles ?? [], ...principal?.orgRoles ?? []]);
1113
+ if (requirement.kind === "scoped") {
1114
+ const scopeReason = describeScope(requirement.scope);
1115
+ const rolesReason = requirement.roles?.length ? `role(s) ${requirement.roles.join(", ")} + ` : "";
1116
+ if (requirement.roles?.length && principal && !requirement.roles.some((r) => held.has(r))) return {
1117
+ decision: "deny",
1118
+ reason: `requires ${rolesReason}${scopeReason}`,
1119
+ requirement
1120
+ };
1121
+ if (!principal?.scope) return {
1122
+ decision: "conditional",
1123
+ reason: `requires ${rolesReason}${scopeReason}`,
1124
+ requirement
1125
+ };
1126
+ if (!Object.entries(requirement.scope).every(([dim, val]) => val === void 0 ? principal.scope?.[dim] !== void 0 : principal.scope?.[dim] === val)) return {
1127
+ decision: "deny",
1128
+ reason: `requires ${rolesReason}${scopeReason}`,
1129
+ requirement
1130
+ };
1131
+ if (requirement.conditional) return {
1132
+ decision: "conditional",
1133
+ reason: `in scope ${scopeReason}; additional runtime conditions apply`,
1134
+ requirement
1135
+ };
1136
+ return {
1137
+ decision: "allow",
1138
+ reason: `granted in scope: ${scopeReason}`,
1139
+ requirement
1140
+ };
1141
+ }
1142
+ const rolesText = `requires one of: ${requirement.roles.join(", ")}`;
1143
+ if (!principal) return {
1144
+ decision: "conditional",
1145
+ reason: rolesText,
1146
+ requirement
1147
+ };
1148
+ const matched = requirement.roles.filter((r) => held.has(r));
1149
+ if (matched.length === 0) return {
1150
+ decision: "deny",
1151
+ reason: rolesText,
1152
+ requirement
1153
+ };
1154
+ if (requirement.conditional) return {
1155
+ decision: "conditional",
1156
+ reason: `has role(s) ${matched.join(", ")}; additional runtime conditions apply`,
1157
+ requirement
1158
+ };
1159
+ return {
1160
+ decision: "allow",
1161
+ reason: `granted via role(s): ${matched.join(", ")}`,
1162
+ requirement
1163
+ };
1164
+ }
1165
+ /** Render a scope-context requirement as a readable clause: `branchRole=head_office`. */
1166
+ function describeScope(scope) {
1167
+ return Object.entries(scope).map(([dim, val]) => val === void 0 ? `${dim} (any)` : `${dim}=${val}`).join(", ");
1168
+ }
1169
+ /**
1170
+ * Introspect a whole permission map (`{ list, get, create, ... }` or any
1171
+ * action→check record) into `action → requirement`. The reusable core of a
1172
+ * `/permissions/matrix` endpoint or an access audit — framework-owned, so a host
1173
+ * never hand-rolls `(check as any)._roles` peeking. Undefined slots (a CRUD op
1174
+ * with no gate = public-by-omission) surface as `public` so the inventory is
1175
+ * honest about what's exposed.
1176
+ */
1177
+ function describePermissionMap(permissions) {
1178
+ const out = {};
1179
+ for (const [action, check] of Object.entries(permissions)) out[action] = check ? describePermission(check) : { kind: "public" };
1180
+ return out;
1181
+ }
1182
+ /**
1183
+ * The public attack surface of a permission map — the actions reachable with NO
1184
+ * authentication (explicit `allowPublic()` or public-by-omission). Empty is the
1185
+ * healthy default for a protected resource; a non-empty list is what a
1186
+ * "what's exposed?" audit report should show.
1187
+ */
1188
+ function collectPublicSurface(permissions) {
1189
+ return Object.entries(describePermissionMap(permissions)).filter(([, req]) => req.kind === "public").map(([action]) => action);
1190
+ }
1191
+ //#endregion
1055
1192
  //#region src/permissions/grants.ts
1056
1193
  /**
1194
+ * Per-record grants — the `requireGrant` combinator + mode lattice.
1195
+ *
1196
+ * The one ACL idea arc's permission vocabulary lacked (design verdict:
1197
+ * `designs/record-sharing.md`): subject × record × mode grants, Puter-style,
1198
+ * WITHOUT Puter's mistakes. Arc ships only the GATE — grant storage is a
1199
+ * host `defineResource` (tenant-scoped, `audit: true`, any kit via
1200
+ * repo-core's `RepositoryLike`), and the `resolve` callback is structural,
1201
+ * so core stays DB-agnostic and storage-free by construction.
1202
+ *
1203
+ * What this design deliberately avoids (verified against Puter's source):
1204
+ * - string-typed permissions (`fs:<uuid>:read` VARCHARs that silently
1205
+ * never match when malformed) → typed {@link GrantMode} union
1206
+ * - per-item ACL checks on list responses (their readdir returns
1207
+ * unfiltered children; the UI stats each item — N+1) → resolutions
1208
+ * return a row `policy` that composes into THE list query via the
1209
+ * decision's `policy` → `_policyFilters` (one paginated, correct query)
1210
+ * - un-revocable capability tokens → share links should reference a
1211
+ * grant ROW (`subjectType: 'link'`); delete the row, the link dies
1212
+ *
1213
+ * @example
1214
+ * ```ts
1215
+ * // Host owns the grants table — it's just a resource:
1216
+ * // { resource, resourceId, subjectType: 'user'|'team'|'link',
1217
+ * // subjectId, mode, grantedBy, expiresAt? }
1218
+ * import { anyOf, requireGrant, requireOwnership } from '@classytic/arc/permissions';
1219
+ *
1220
+ * defineResource({
1221
+ * name: 'document',
1222
+ * permissions: {
1223
+ * get: anyOf(requireOwnership('ownerId'), requireGrant({ mode: 'read', resolve })),
1224
+ * update: anyOf(requireOwnership('ownerId'), requireGrant({ mode: 'write', resolve })),
1225
+ * list: requireGrant({
1226
+ * mode: 'list',
1227
+ * // List-shaped resolution: return filters — owner OR granted ids.
1228
+ * resolve: async (ctx) => ({
1229
+ * filters: {
1230
+ * $or: [
1231
+ * { ownerId: ctx.user?.id },
1232
+ * { _id: { $in: await grants.grantedIds(ctx) } },
1233
+ * ],
1234
+ * },
1235
+ * }),
1236
+ * }),
1237
+ * },
1238
+ * });
1239
+ * ```
1240
+ */
1241
+ /**
1057
1242
  * The grant-mode lattice, weakest → strongest. Holding a mode satisfies
1058
1243
  * every mode at or below it (`write` ⊇ `read` ⊇ `list` ⊇ `see`); `manage`
1059
1244
  * (grant/revoke rights) satisfies everything. Same semantics as Puter's
@@ -1095,36 +1280,21 @@ function requireGrant(options) {
1095
1280
  try {
1096
1281
  resolution = await resolve(ctx);
1097
1282
  } catch (err) {
1098
- ctx.request.log?.warn?.({
1283
+ ctx.request?.log?.warn?.({
1099
1284
  err,
1100
1285
  resource: ctx.resource,
1101
1286
  action: ctx.action
1102
1287
  }, "requireGrant: resolve threw — denying (fail-closed)");
1103
- return {
1104
- granted: false,
1105
- reason: "Grant lookup failed"
1106
- };
1288
+ return deny("Grant lookup failed");
1107
1289
  }
1108
1290
  if (typeof resolution === "boolean") return resolution;
1109
1291
  const { mode: held, filters, reason } = resolution;
1110
1292
  if (held !== void 0) {
1111
- if (!modeSatisfies(held, required)) return {
1112
- granted: false,
1113
- ...reason ? { reason } : {}
1114
- };
1115
- return {
1116
- granted: true,
1117
- ...filters ? { filters } : {}
1118
- };
1293
+ if (!modeSatisfies(held, required)) return deny(reason);
1294
+ return allow(filters ? { policy: filters } : void 0);
1119
1295
  }
1120
- if (filters !== void 0) return {
1121
- granted: true,
1122
- filters
1123
- };
1124
- return {
1125
- granted: false,
1126
- ...reason ? { reason } : {}
1127
- };
1296
+ if (filters !== void 0) return allow({ policy: filters });
1297
+ return deny(reason);
1128
1298
  };
1129
1299
  }
1130
1300
  //#endregion
@@ -1138,27 +1308,59 @@ function requireGrant(options) {
1138
1308
  */
1139
1309
  function createRoleHierarchy(map) {
1140
1310
  const cache = /* @__PURE__ */ new Map();
1141
- function resolveRole(role, visited) {
1142
- if (visited.has(role)) return [];
1143
- visited.add(role);
1311
+ /**
1312
+ * Resolve a role's transitive expansion.
1313
+ *
1314
+ * `stack` is the CURRENT recursion path (a DFS stack), NOT a shared visited
1315
+ * accumulator: a role is added on entry and REMOVED on exit, so sibling
1316
+ * branches never pollute each other. Without this, a diamond `A→B→D`, `A→C→D`
1317
+ * would let B's traversal leave `D` in a shared set, making C's `D` look like a
1318
+ * back-edge and caching `C` WITHOUT `D` — a nondeterministic under-grant.
1319
+ *
1320
+ * Returns `cyclic` = whether a back-edge into `stack` was cut anywhere in the
1321
+ * subtree. A cyclic subtree's result depends on the entry path, so it is NOT
1322
+ * cached (recomputed each call — correct, if unmemoized). Acyclic subtrees (the
1323
+ * norm) cache safely.
1324
+ */
1325
+ function resolveRole(role, stack) {
1326
+ if (stack.has(role)) return {
1327
+ roles: [],
1328
+ cyclic: true
1329
+ };
1144
1330
  const cached = cache.get(role);
1145
- if (cached) return cached;
1331
+ if (cached) return {
1332
+ roles: cached,
1333
+ cyclic: false
1334
+ };
1146
1335
  const children = map[role];
1147
1336
  if (!children || children.length === 0) {
1148
1337
  cache.set(role, [role]);
1149
- return [role];
1338
+ return {
1339
+ roles: [role],
1340
+ cyclic: false
1341
+ };
1150
1342
  }
1343
+ stack.add(role);
1151
1344
  const result = [role];
1152
- for (const child of children) result.push(...resolveRole(child, visited));
1345
+ let cyclic = false;
1346
+ for (const child of children) {
1347
+ const childResult = resolveRole(child, stack);
1348
+ result.push(...childResult.roles);
1349
+ if (childResult.cyclic) cyclic = true;
1350
+ }
1351
+ stack.delete(role);
1153
1352
  const deduped = [...new Set(result)];
1154
- cache.set(role, deduped);
1155
- return deduped;
1353
+ if (!cyclic) cache.set(role, deduped);
1354
+ return {
1355
+ roles: deduped,
1356
+ cyclic
1357
+ };
1156
1358
  }
1157
1359
  return {
1158
1360
  expand(roles) {
1159
1361
  if (roles.length === 0) return [];
1160
1362
  const all = /* @__PURE__ */ new Set();
1161
- for (const role of roles) for (const expanded of resolveRole(role, /* @__PURE__ */ new Set())) all.add(expanded);
1363
+ for (const role of roles) for (const expanded of resolveRole(role, /* @__PURE__ */ new Set()).roles) all.add(expanded);
1162
1364
  return [...all];
1163
1365
  },
1164
1366
  includes(userRoles, requiredRole) {
@@ -1343,4 +1545,4 @@ function readOnly(overrides) {
1343
1545
  }, overrides);
1344
1546
  }
1345
1547
  //#endregion
1346
- export { requireAuth as A, requireDPoP as C, anyOf as D, allowPublic as E, requireRoles as M, roles as N, denyAll as O, when as P, requireAgentScope as S, allOf as T, requireOrgMembership as _, permissionMatrix as a, requireServiceScope as b, publicReadAdminWrite as c, GRANT_MODES as d, modeSatisfies as f, requireOrgInScope as g, createOrgPermissions as h, ownerWithAdminBypass as i, requireOwnership as j, not as k, readOnly as l, createDynamicPermissionMatrix as m, authenticated as n, presets_exports as o, requireGrant as p, fullPublic as r, publicRead as s, adminOnly as t, createRoleHierarchy as u, requireOrgRole as v, requireMandate as w, requireTeamMembership as x, requireScopeContext as y };
1548
+ export { allow as A, roles as B, requireScopeContext as C, requireDPoP as D, requireAgentScope as E, not as F, scopeOf as H, requireAuth as I, requireOwnership as L, anyOf as M, deny as N, requireMandate as O, denyAll as P, requirePlatformRole as R, requireOrgRole as S, requireTeamMembership as T, when as V, explainAccess as _, permissionMatrix as a, requireOrgInScope as b, publicReadAdminWrite as c, GRANT_MODES as d, modeSatisfies as f, describePermissionMap as g, describePermission as h, ownerWithAdminBypass as i, allowPublic as j, allOf as k, readOnly as l, collectPublicSurface as m, authenticated as n, presets_exports as o, requireGrant as p, fullPublic as r, publicRead as s, adminOnly as t, createRoleHierarchy as u, createDynamicPermissionMatrix as v, requireServiceScope as w, requireOrgMembership as x, createOrgPermissions as y, requireRoles as z };