@classytic/arc 2.22.0 → 2.25.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 (147) hide show
  1. package/dist/{BaseController-C6FlRWxF.mjs → BaseController--M8EMzt0.mjs} +92 -22
  2. package/dist/QueryCache-DOMT_iq7.d.mts +72 -0
  3. package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
  4. package/dist/actionPermissions-CAphcmUG.mjs +24 -0
  5. package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
  6. package/dist/audit/index.d.mts +32 -3
  7. package/dist/audit/index.mjs +7 -4
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +264 -260
  10. package/dist/{buildHandler-D_2ULWxZ.mjs → buildHandler-Dtwd8fdz.mjs} +1 -1
  11. package/dist/cache/index.d.mts +41 -3
  12. package/dist/cache/index.mjs +33 -4
  13. package/dist/cli/commands/describe.d.mts +1 -1
  14. package/dist/cli/commands/describe.mjs +1 -1
  15. package/dist/cli/commands/docs.mjs +2 -2
  16. package/dist/cli/commands/generate.mjs +1 -1
  17. package/dist/cli/commands/init.mjs +1 -1
  18. package/dist/cli/commands/introspect.mjs +1 -1
  19. package/dist/cli/index.mjs +2 -2
  20. package/dist/context/index.mjs +2 -64
  21. package/dist/core/index.d.mts +3 -3
  22. package/dist/core/index.mjs +4 -4
  23. package/dist/{core-b6fgFgnd.mjs → core-EUNu0u07.mjs} +298 -12
  24. package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-B6GvAuAX.mjs} +4 -3
  25. package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-wzwFa_dW.mjs} +3 -3
  26. package/dist/docs/index.d.mts +1 -1
  27. package/dist/docs/index.mjs +2 -2
  28. package/dist/{elevation-COpYKt2h.mjs → elevation-CIz1GjGm.mjs} +4 -3
  29. package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
  30. package/dist/{eventPlugin-A6EbthXR.d.mts → eventPlugin-Bg3cP2_u.d.mts} +38 -3
  31. package/dist/{eventPlugin-DJG2D7sy.mjs → eventPlugin-DrQ_FesK.mjs} +22 -4
  32. package/dist/events/index.d.mts +57 -314
  33. package/dist/events/index.mjs +168 -93
  34. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  35. package/dist/events/transports/redis-stream-entry.mjs +24 -2
  36. package/dist/factory/index.d.mts +21 -6
  37. package/dist/factory/index.mjs +58 -18
  38. package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-dPNBVeIj.d.mts} +25 -2
  39. package/dist/{generate-usRSdQmP.mjs → generate-D0wZupBg.mjs} +1 -1
  40. package/dist/headers-BvlQSDMB.mjs +45 -0
  41. package/dist/health-CB_m4S32.mjs +377 -0
  42. package/dist/hooks/index.d.mts +1 -1
  43. package/dist/idempotency/index.d.mts +7 -0
  44. package/dist/idempotency/index.mjs +32 -20
  45. package/dist/{index-ogIHvOjo.d.mts → index-BMnbzvtO.d.mts} +70 -4
  46. package/dist/{integrations/jobs.d.mts → index-CG4yqMDB.d.mts} +82 -2
  47. package/dist/{index-DbtgCOCj.d.mts → index-DPRH0Cme.d.mts} +46 -94
  48. package/dist/{module-B4u10iOJ.d.mts → index-KUoxjIq1.d.mts} +263 -23
  49. package/dist/{index-D8MKYGDa.d.mts → index-f4PTrk3B.d.mts} +37 -5
  50. package/dist/index.d.mts +6 -6
  51. package/dist/index.mjs +6 -5
  52. package/dist/{init-DleK3p9f.mjs → init-tlRSt667.mjs} +206 -205
  53. package/dist/integrations/event-gateway.d.mts +1 -1
  54. package/dist/integrations/event-gateway.mjs +1 -1
  55. package/dist/integrations/index.d.mts +3 -3
  56. package/dist/integrations/jobs/index.d.mts +2 -0
  57. package/dist/integrations/{jobs.mjs → jobs/index.mjs} +110 -67
  58. package/dist/integrations/mcp/index.d.mts +68 -5
  59. package/dist/integrations/mcp/index.mjs +2 -2
  60. package/dist/integrations/mcp/testing.d.mts +1 -1
  61. package/dist/integrations/mcp/testing.mjs +1 -1
  62. package/dist/integrations/webhooks.d.mts +149 -22
  63. package/dist/integrations/webhooks.mjs +186 -42
  64. package/dist/integrations/websocket-redis.d.mts +1 -1
  65. package/dist/integrations/websocket.d.mts +1 -1
  66. package/dist/integrations/websocket.mjs +41 -14
  67. package/dist/{interface-C1_WLTMK.d.mts → interface-BGZCzOGL.d.mts} +24 -31
  68. package/dist/interface-n5hpKg1d.mjs +16 -0
  69. package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
  70. package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-Cb7olw0j.mjs} +316 -45
  71. package/dist/{memory-CLXA2VeJ.mjs → memory-CmnzXE5Z.mjs} +29 -0
  72. package/dist/middleware/index.d.mts +17 -1
  73. package/dist/middleware/index.mjs +1 -1
  74. package/dist/migrations/index.d.mts +94 -44
  75. package/dist/migrations/index.mjs +196 -44
  76. package/dist/{multipartBody-CHEQSZ6G.mjs → multipartBody-D0eBvkeq.mjs} +17 -0
  77. package/dist/{openapi-BRpMZD7C.mjs → openapi-DrLtog_B.mjs} +4 -4
  78. package/dist/permissions/index.d.mts +3 -3
  79. package/dist/permissions/index.mjs +4 -3
  80. package/dist/{permissions-BJMuKMl7.mjs → permissions-BrtiBKGs.mjs} +305 -492
  81. package/dist/pipeline/index.d.mts +1 -1
  82. package/dist/{actionPermissions-ze70xXJi.mjs → pipeline-BN5G05rd.mjs} +4 -27
  83. package/dist/plugins/index.d.mts +7 -78
  84. package/dist/plugins/index.mjs +14 -97
  85. package/dist/plugins/response-cache.mjs +1 -0
  86. package/dist/plugins/tracing-entry.mjs +1 -1
  87. package/dist/{index-BEu4wjbL.d.mts → preloadResources-0oe-JSSS.d.mts} +59 -50
  88. package/dist/{createApp-Ci7yG5gv.mjs → preloadResources-DfyngIN9.mjs} +240 -68
  89. package/dist/presets/filesUpload.d.mts +3 -3
  90. package/dist/presets/filesUpload.mjs +3 -3
  91. package/dist/presets/index.d.mts +2 -1
  92. package/dist/presets/index.mjs +1 -1
  93. package/dist/presets/multiTenant.d.mts +1 -1
  94. package/dist/presets/multiTenant.mjs +1 -1
  95. package/dist/presets/search.d.mts +2 -2
  96. package/dist/presets/search.mjs +1 -1
  97. package/dist/{presets-gw0ciIUD.mjs → presets-DwFDVKP0.mjs} +2 -2
  98. package/dist/{queryCachePlugin-DzqLN904.mjs → queryCachePlugin-DSGuSVLW.mjs} +39 -8
  99. package/dist/{queryCachePlugin-CfYWq-Ak.d.mts → queryCachePlugin-br4otiqF.d.mts} +2 -2
  100. package/dist/{registerRoute-xHc42fwB.mjs → rateLimit-DrED-C4v.mjs} +2 -109
  101. package/dist/realtime-Cuw_deA-.mjs +375 -0
  102. package/dist/{redis-stream-iVR2l7aS.d.mts → redis-stream-uIaauNpZ.d.mts} +22 -0
  103. package/dist/registerRoute-v0Wb6Plt.mjs +108 -0
  104. package/dist/registry/index.d.mts +2 -2
  105. package/dist/registry/index.mjs +1 -1
  106. package/dist/renewingLease-3cuQH7h8.mjs +44 -0
  107. package/dist/{requestContext-BZKOr13H.mjs → requestContext-MrlJwECf.mjs} +2 -2
  108. package/dist/schedules-CjOd2MG9.mjs +113 -0
  109. package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
  110. package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
  111. package/dist/schemas/index.d.mts +67 -67
  112. package/dist/schemas/index.mjs +2 -3
  113. package/dist/scim/index.mjs +1 -2
  114. package/dist/scope/index.d.mts +2 -2
  115. package/dist/scope/index.mjs +4 -4
  116. package/dist/scope-DfaPntpo.mjs +36 -0
  117. package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
  118. package/dist/{sse-CfUjwjDk.mjs → sse-BQ1w0dfl.mjs} +8 -31
  119. package/dist/sseStream-BQ7Qaynp.mjs +57 -0
  120. package/dist/testing/index.d.mts +4 -3
  121. package/dist/testing/index.mjs +2 -2
  122. package/dist/transactionContext-PNdoyLLm.mjs +64 -0
  123. package/dist/types/index.d.mts +4 -4
  124. package/dist/{types-bHblZ9YN.mjs → types-CSyCxl2V.mjs} +38 -7
  125. package/dist/{types-DH0GupI7.d.mts → types-DjC-KUCz.d.mts} +118 -20
  126. package/dist/{types-Csxga7d3.d.mts → types-Dmw-6Qeo.d.mts} +96 -4
  127. package/dist/{types-Bc42CUyk.d.mts → types-QFGc3Cjv.d.mts} +37 -2
  128. package/dist/usage/index.d.mts +3 -52
  129. package/dist/usage/index.mjs +0 -0
  130. package/dist/userHelpers-Cg_y3fBz.mjs +29 -0
  131. package/dist/utils/index.d.mts +90 -57
  132. package/dist/utils/index.mjs +7 -3
  133. package/dist/{utils-FhJ88TJE.mjs → utils-taUVD4p3.mjs} +47 -385
  134. package/dist/{validate-C2mGgSXg.mjs → validate-Bk44F2yT.mjs} +1 -1
  135. package/dist/{versioning-DLwklPlG.d.mts → versioning-DXgY5BUm.d.mts} +52 -31
  136. package/dist/{versioning-Dk-71iIz.mjs → versioning-gKWyryT3.mjs} +8 -1
  137. package/dist/{websocket-uuWqM6UY.d.mts → websocket-_Pd0CgR0.d.mts} +15 -1
  138. package/package.json +526 -453
  139. package/skills/arc/references/api-reference.md +3 -1
  140. package/skills/arc/references/integrations.md +20 -11
  141. package/skills/arc/references/production.md +734 -696
  142. package/dist/QueryCache-Z1B38koh.d.mts +0 -40
  143. package/dist/betterAuthOpenApi-CV1lxpRX.mjs +0 -2
  144. package/dist/health-ScyjIVo5.mjs +0 -202
  145. package/dist/requestId-5BhTVzBv.mjs +0 -113
  146. package/dist/scope-k15RXfcq.mjs +0 -64
  147. package/dist/types-XTApa8b6.mjs +0 -24
@@ -1,496 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
- 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-bHblZ9YN.mjs";
3
- import { t as getUserRoles } from "./types-XTApa8b6.mjs";
4
- import { t as MemoryCacheStore } from "./memory-CLXA2VeJ.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";
5
5
  import { randomUUID } from "node:crypto";
6
- //#region src/permissions/fields.ts
7
- /**
8
- * Field-Level Permissions
9
- *
10
- * Control field visibility and writability per role.
11
- * Integrated into the response path (read) and sanitization path (write).
12
- *
13
- * @example
14
- * ```typescript
15
- * import { fields, defineResource } from '@classytic/arc';
16
- *
17
- * const userResource = defineResource({
18
- * name: 'user',
19
- * adapter: userAdapter,
20
- * fields: {
21
- * salary: fields.visibleTo(['admin', 'hr']),
22
- * internalNotes: fields.writableBy(['admin']),
23
- * email: fields.redactFor(['viewer']),
24
- * password: fields.hidden(),
25
- * },
26
- * });
27
- * ```
28
- */
29
- /** Type guard for Mongoose-like documents with toObject() */
30
- function isMongooseDoc(obj) {
31
- return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
32
- }
33
- const fields = {
34
- /**
35
- * Field is never included in responses. Not writable via API.
36
- *
37
- * @example
38
- * ```typescript
39
- * fields: { password: fields.hidden() }
40
- * ```
41
- */
42
- hidden() {
43
- return { _type: "hidden" };
44
- },
45
- /**
46
- * Field is only visible to users with specified roles.
47
- * Other users don't see the field at all.
48
- *
49
- * @example
50
- * ```typescript
51
- * fields: { salary: fields.visibleTo(['admin', 'hr']) }
52
- * ```
53
- */
54
- visibleTo(roles) {
55
- return {
56
- _type: "visibleTo",
57
- roles
58
- };
59
- },
60
- /**
61
- * Field is only writable by users with specified roles.
62
- * All users can still read the field. Users without the role
63
- * have the field silently stripped from write operations.
64
- *
65
- * @example
66
- * ```typescript
67
- * fields: { role: fields.writableBy(['admin']) }
68
- * ```
69
- */
70
- writableBy(roles) {
71
- return {
72
- _type: "writableBy",
73
- roles
74
- };
75
- },
76
- /**
77
- * Field is redacted (replaced with a placeholder) for specified roles.
78
- * Other users see the real value.
79
- *
80
- * @param roles - Roles that see the redacted value
81
- * @param redactValue - Replacement value (default: '***')
82
- *
83
- * @example
84
- * ```typescript
85
- * fields: {
86
- * email: fields.redactFor(['viewer']),
87
- * ssn: fields.redactFor(['basic'], '***-**-****'),
88
- * }
89
- * ```
90
- */
91
- redactFor(roles, redactValue = "***") {
92
- return {
93
- _type: "redactFor",
94
- roles,
95
- redactValue
96
- };
97
- }
98
- };
99
- /**
100
- * Apply field-level READ permissions to a response object.
101
- * Strips hidden fields, enforces visibility, and applies redaction.
102
- *
103
- * @param data - The response object (mutated in place for performance)
104
- * @param fieldPermissions - Field permission map from resource config
105
- * @param userRoles - Current user's roles (empty array for unauthenticated)
106
- * @returns The filtered object
107
- */
108
- function applyFieldReadPermissions(data, fieldPermissions, userRoles) {
109
- if (!data || typeof data !== "object") return data;
110
- const result = { ...isMongooseDoc(data) ? data.toObject() : data };
111
- for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
112
- case "hidden":
113
- delete result[field];
114
- break;
115
- case "visibleTo":
116
- if (!perm.roles?.some((r) => userRoles.includes(r))) delete result[field];
117
- break;
118
- case "redactFor":
119
- if (perm.roles?.some((r) => userRoles.includes(r))) result[field] = perm.redactValue ?? "***";
120
- break;
121
- case "writableBy": break;
122
- }
123
- return result;
124
- }
125
- /**
126
- * Apply field-level WRITE permissions to request body.
127
- *
128
- * Returns both the filtered body and the list of denied fields. Callers are
129
- * expected to reject the request when `deniedFields.length > 0` — silently
130
- * stripping fields hides misconfigurations and real attacks. See
131
- * `BodySanitizer` for the default policy.
132
- *
133
- * @param body - The request body (returns a new filtered copy)
134
- * @param fieldPermissions - Field permission map from resource config
135
- * @param userRoles - Current user's roles
136
- */
137
- function applyFieldWritePermissions(body, fieldPermissions, userRoles) {
138
- const result = { ...body };
139
- const deniedFields = [];
140
- for (const [field, perm] of Object.entries(fieldPermissions)) switch (perm._type) {
141
- case "hidden":
142
- if (field in result) {
143
- deniedFields.push(field);
144
- delete result[field];
145
- }
146
- break;
147
- case "writableBy":
148
- if (field in result && !perm.roles?.some((r) => userRoles.includes(r))) {
149
- deniedFields.push(field);
150
- delete result[field];
151
- }
152
- break;
153
- }
154
- return {
155
- body: result,
156
- deniedFields
157
- };
158
- }
159
- /**
160
- * Resolve effective roles by merging global user roles with org-level roles.
161
- *
162
- * Global roles come from `req.user.role` (normalized via getUserRoles()).
163
- * Org roles come from `req.context.orgRoles` (set by BA adapter's org bridge).
164
- *
165
- * When no org context exists, returns global roles only — backward compatible.
166
- */
167
- function resolveEffectiveRoles(userRoles, orgRoles) {
168
- if (orgRoles.length === 0) return [...userRoles];
169
- if (userRoles.length === 0) return [...orgRoles];
170
- return [.../* @__PURE__ */ new Set([...userRoles, ...orgRoles])];
171
- }
172
- //#endregion
173
- //#region src/permissions/applyPermissionResult.ts
174
- /**
175
- * Normalize a permission check return value (`boolean | PermissionResult`)
176
- * into a concrete `PermissionResult`. This is the only place in Arc that
177
- * promotes booleans to results — keeps the type narrowing honest everywhere.
178
- */
179
- function normalizePermissionResult(result) {
180
- if (typeof result === "boolean") return { granted: result };
181
- return result;
182
- }
183
- /**
184
- * Apply a granted `PermissionResult` to a Fastify request — merges row-level
185
- * filters into `_policyFilters` and conditionally installs the scope.
186
- *
187
- * **Scope install rule:** only writes `scope` when the current request scope
188
- * is absent or `public`. This prevents downgrading an already-authenticated
189
- * request (e.g. Better Auth set `member`, then a permission check returns a
190
- * narrower `service` scope — the original `member` wins because it came from
191
- * a more authoritative source).
192
- *
193
- * Safe to call with a non-granted result — it simply no-ops. Callers should
194
- * still check `result.granted` and send an error response before reaching here,
195
- * but this function tolerates the misuse defensively.
196
- */
197
- function applyPermissionResult(result, request) {
198
- if (!result.granted) return;
199
- if (result.filters) request._policyFilters = {
200
- ...request._policyFilters ?? {},
201
- ...result.filters
202
- };
203
- if (result.scope) {
204
- const current = request.scope;
205
- if (!current || current.kind === "public") request.scope = result.scope;
206
- }
207
- }
208
- /**
209
- * Max length of a `PermissionResult.reason` string before we fall back to the
210
- * generic default message. Upstream checks can return arbitrary strings; we
211
- * clamp to prevent accidental leakage of internal diagnostics or oversized
212
- * payloads via the 4xx response body.
213
- */
214
- const MAX_DENIAL_REASON_LENGTH = 100;
215
- /**
216
- * End-to-end evaluator: runs the permission check, catches throws, normalizes
217
- * the result, sends a 401/403 response on denial, and applies side-effects on
218
- * grant. Returns `true` if the caller should continue, `false` if a response
219
- * has been sent and the caller should return.
220
- *
221
- * This is the single source of truth for the 5-step sequence shared by the
222
- * CRUD router, action router, and MCP tool handlers:
223
- *
224
- * 1. `try { await check(ctx) } catch { reply 403 }`
225
- * 2. `normalizePermissionResult(result)`
226
- * 3. If denied → 401 (no user) or 403 (user) with clamped reason
227
- * 4. If granted → `applyPermissionResult` (filters + scope)
228
- * 5. Return true/false so the caller knows whether to keep going
229
- *
230
- * Context construction, pre-check auth gating, and success-path handler
231
- * invocation stay at the callsite — those are genuinely different per router
232
- * and don't belong here.
233
- *
234
- * @returns `true` if authorized (caller continues), `false` if a response was sent
235
- */
236
- async function evaluateAndApplyPermission(check, context, request, reply, opts) {
237
- let result;
238
- try {
239
- result = await check(context);
240
- } catch (err) {
241
- request.log?.warn?.({
242
- err,
243
- resource: context.resource,
244
- action: context.action
245
- }, "Permission check threw");
246
- reply.code(403).send({
247
- code: "arc.forbidden",
248
- message: "Permission denied",
249
- status: 403
250
- });
251
- return false;
252
- }
253
- const permResult = normalizePermissionResult(result);
254
- if (!permResult.granted) {
255
- const defaultMsg = opts?.defaultDenialMessage?.(context.user) ?? (context.user ? "Permission denied" : "Authentication required");
256
- const reason = permResult.reason && permResult.reason.length <= MAX_DENIAL_REASON_LENGTH ? permResult.reason : defaultMsg;
257
- const status = context.user ? 403 : 401;
258
- reply.code(status).send({
259
- code: context.user ? "arc.forbidden" : "arc.unauthorized",
260
- message: reason,
261
- status
262
- });
263
- return false;
264
- }
265
- applyPermissionResult(permResult, request);
266
- return true;
267
- }
268
- //#endregion
269
- //#region src/permissions/agent.ts
270
- /**
271
- * Agent-Auth Permission Helpers — DPoP + capability mandates for AI-agent flows
272
- *
273
- * Three checks for the 2025 agent-authorization stack (AP2 / Stripe x402 /
274
- * MCP authorization / RFC 9700 / RFC 9449 / RFC 9728):
275
- *
276
- * - `requireDPoP()` — token must be sender-constrained (RFC 9449)
277
- * - `requireMandate(cap, …)` — capability mandate must authorize this action
278
- * - `requireAgentScope(opts)` — composite gate: service identity + mandate + DPoP
279
- *
280
- * Arc reads `request.scope.mandate` and `request.scope.dpopJkt` populated by
281
- * your `authenticate` callback. Arc does **not** parse mandate JWTs/VCs or
282
- * verify DPoP proofs — that's a 1-2 line `jose` call in your authenticator.
283
- * Arc validates *what's already proved* against the action being attempted.
284
- *
285
- * @example
286
- * ```typescript
287
- * import { requireDPoP, requireMandate, requireAgentScope } from '@classytic/arc/permissions';
288
- *
289
- * defineResource({
290
- * name: 'invoice',
291
- * permissions: {
292
- * pay: requireAgentScope({
293
- * capability: 'payment.charge',
294
- * requireDPoP: true,
295
- * validateAmount: (ctx, mandate) =>
296
- * typeof ctx.data?.amount === 'number' && ctx.data.amount <= (mandate.cap ?? 0),
297
- * audience: (ctx) => `invoice:${ctx.params?.id}`,
298
- * }),
299
- * },
300
- * });
301
- * ```
302
- */
303
- /** Default grace window for mandate `expiresAt` — accommodates clock skew. */
304
- const DEFAULT_TTL_GRACE_MS = 3e4;
305
- /**
306
- * Require a sender-constrained credential — the inbound token MUST carry a
307
- * DPoP proof (RFC 9449) bound to a known key. Arc reads `scope.dpopJkt` (the
308
- * JWK SHA-256 thumbprint per RFC 7638); your `authenticate` function performs
309
- * the cryptographic `jose.dpop.verify(...)` and sets the field on success.
310
- *
311
- * **Pass behavior:**
312
- * - `service` scope where `dpopJkt` is set → grant
313
- * - `elevated` scope → grant (platform admin bypass)
314
- * - Anything else → deny with a clear reason
315
- *
316
- * Use for high-value endpoints where bearer-token replay must be impossible:
317
- * payment charges, data exports, account-takeover-class admin actions.
318
- *
319
- * @example
320
- * ```typescript
321
- * permissions: { charge: allOf(requireServiceScope('payment.write'), requireDPoP()) }
322
- * ```
323
- */
324
- function requireDPoP() {
325
- const check = (ctx) => {
326
- const scope = getRequestScope(ctx.request);
327
- if (isElevated(scope)) return true;
328
- if (!isService(scope)) return {
329
- granted: false,
330
- reason: "DPoP-bound service identity required. Configure your authenticate callback to set scope.dpopJkt after verifying the DPoP proof header (RFC 9449)."
331
- };
332
- if (!getDPoPJkt(scope)) return {
333
- granted: false,
334
- reason: "Sender-constrained credential required (DPoP). Inbound token is bearer; replay-resistance is mandatory on this endpoint."
335
- };
336
- return true;
337
- };
338
- check._dpopRequired = true;
339
- return check;
340
- }
341
- /**
342
- * Require a capability mandate (AP2 / x402 / MCP authorization) that
343
- * authorizes the action being attempted.
344
- *
345
- * The mandate is set on `request.scope.mandate` by your authenticate function
346
- * after verifying the inbound mandate JWT/VC. This check validates that the
347
- * presented mandate covers the requested capability, hasn't expired, is bound
348
- * to the right resource (when `audience` opt is set), and respects the
349
- * mandate's numeric ceiling (when `validateAmount` opt is set).
350
- *
351
- * **Pass behavior:**
352
- * - `elevated` scope → grant unless `noElevatedBypass: true`
353
- * - `service` scope with mandate matching `capability`, not expired, and
354
- * passing `validateAmount` + `audience` checks → grant
355
- * - Anything else → deny with a precise reason
356
- *
357
- * Pair with `requireDPoP()` for replay-resistance, or use the bundled
358
- * `requireAgentScope(...)` to declare both at once.
359
- *
360
- * @example
361
- * ```typescript
362
- * // Single payment charge — amount must fit the mandate's cap
363
- * permissions: {
364
- * pay: requireMandate('payment.charge', {
365
- * validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
366
- * audience: (ctx) => `invoice:${ctx.params?.id}`,
367
- * }),
368
- * }
369
- *
370
- * // Boolean capability — presence of mandate is the gate
371
- * permissions: {
372
- * exportData: requireMandate('data.export'),
373
- * }
374
- * ```
375
- */
376
- function requireMandate(capability, opts = {}) {
377
- if (!capability || typeof capability !== "string") throw new Error("requireMandate(capability) requires a non-empty capability string (e.g. 'payment.charge')");
378
- const ttlGrace = opts.ttlGraceMs ?? DEFAULT_TTL_GRACE_MS;
379
- const noElevatedBypass = opts.noElevatedBypass === true;
380
- const check = (ctx) => {
381
- const scope = getRequestScope(ctx.request);
382
- if (isElevated(scope) && !noElevatedBypass) return true;
383
- const mandate = getMandate(scope);
384
- if (!mandate) return {
385
- granted: false,
386
- reason: `Capability mandate required (${capability}). Configure your authenticate callback to populate request.scope.mandate after verifying the mandate JWT/VC.`
387
- };
388
- if (mandate.capability !== capability) return {
389
- granted: false,
390
- reason: `Mandate authorizes "${mandate.capability}", not "${capability}"`
391
- };
392
- if (mandate.expiresAt !== void 0 && Date.now() > mandate.expiresAt + ttlGrace) return {
393
- granted: false,
394
- reason: `Mandate expired at ${new Date(mandate.expiresAt).toISOString()}`
395
- };
396
- if (opts.audience) {
397
- const required = typeof opts.audience === "function" ? opts.audience(ctx) : opts.audience;
398
- if (required && mandate.audience && mandate.audience !== required) return {
399
- granted: false,
400
- reason: `Mandate is bound to "${mandate.audience}", not "${required}"`
401
- };
402
- if (required && !mandate.audience) return {
403
- granted: false,
404
- reason: `Mandate must be bound to "${required}" (no audience claim)`
405
- };
406
- }
407
- if (opts.validateAmount) {
408
- const result = opts.validateAmount(ctx, mandate);
409
- if (result !== true) return {
410
- granted: false,
411
- reason: typeof result === "string" ? result : `Action exceeds mandate cap ${mandate.cap}${mandate.currency ? ` ${mandate.currency}` : ""}`
412
- };
413
- }
414
- return true;
415
- };
416
- check._mandateCapability = capability;
417
- return check;
418
- }
419
- /**
420
- * Composite gate for AI-agent / M2M flows on protected resources.
421
- *
422
- * Bundles the three things every high-value agent endpoint needs:
423
- * 1. **Service identity** — `scope.kind === 'service'` with `clientId`
424
- * 2. **Capability mandate** — narrows what *this request* may do
425
- * 3. **DPoP binding** — credential cannot be replayed from a different key
426
- *
427
- * Use this instead of hand-composing `allOf(requireServiceScope(...),
428
- * requireMandate(...), requireDPoP())` — fewer ways to misconfigure, one
429
- * meta-tag downstream tools (audit, MCP, OpenAPI) can read.
430
- *
431
- * @example
432
- * ```typescript
433
- * import { requireAgentScope } from '@classytic/arc/permissions';
434
- *
435
- * defineResource({
436
- * name: 'invoice',
437
- * actions: {
438
- * pay: {
439
- * handler: payInvoice,
440
- * permissions: requireAgentScope({
441
- * capability: 'payment.charge',
442
- * scopes: ['payment.write'],
443
- * requireDPoP: true,
444
- * audience: (ctx) => `invoice:${ctx.params?.id}`,
445
- * validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
446
- * }),
447
- * },
448
- * },
449
- * });
450
- * ```
451
- */
452
- function requireAgentScope(opts) {
453
- const { capability, scopes, requireDPoP: needsDPoP = true, ...mandateOpts } = opts;
454
- if (!capability) throw new Error("requireAgentScope({ capability }) is required");
455
- const mandateCheck = requireMandate(capability, mandateOpts);
456
- const dpopCheck = needsDPoP ? requireDPoP() : null;
457
- const requiredScopes = scopes && scopes.length > 0 ? [...scopes] : null;
458
- const check = async (ctx) => {
459
- const scope = getRequestScope(ctx.request);
460
- if (isElevated(scope) && !mandateOpts.noElevatedBypass) return true;
461
- if (!isService(scope)) return {
462
- granted: false,
463
- reason: "Service identity required (machine principal). Agent flows must authenticate as a service, not a logged-in user."
464
- };
465
- if (requiredScopes) {
466
- const granted = scope.scopes ?? [];
467
- if (!requiredScopes.some((s) => granted.includes(s))) return {
468
- granted: false,
469
- reason: `Service identity is missing required OAuth scope(s): ${requiredScopes.join(", ")}`
470
- };
471
- }
472
- const mandateResult = await mandateCheck(ctx);
473
- if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult : {
474
- granted: false,
475
- reason: "Mandate check failed"
476
- };
477
- if (dpopCheck) {
478
- const dpopResult = await dpopCheck(ctx);
479
- if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult : {
480
- granted: false,
481
- reason: "DPoP binding required"
482
- };
483
- }
484
- return true;
485
- };
486
- check._agentScope = {
487
- capability,
488
- scopes: requiredScopes ?? void 0,
489
- dpop: needsDPoP
490
- };
491
- return check;
492
- }
493
- //#endregion
494
6
  //#region src/permissions/core.ts
495
7
  /**
496
8
  * Normalize a `string | [readonly string[]]` rest-args tuple into a single
@@ -780,6 +292,231 @@ function when(condition) {
780
292
  };
781
293
  }
782
294
  //#endregion
295
+ //#region src/permissions/agent.ts
296
+ /**
297
+ * Agent-Auth Permission Helpers — DPoP + capability mandates for AI-agent flows
298
+ *
299
+ * Three checks for the 2025 agent-authorization stack (AP2 / Stripe x402 /
300
+ * MCP authorization / RFC 9700 / RFC 9449 / RFC 9728):
301
+ *
302
+ * - `requireDPoP()` — token must be sender-constrained (RFC 9449)
303
+ * - `requireMandate(cap, …)` — capability mandate must authorize this action
304
+ * - `requireAgentScope(opts)` — composite gate: service identity + mandate + DPoP
305
+ *
306
+ * Arc reads `request.scope.mandate` and `request.scope.dpopJkt` populated by
307
+ * your `authenticate` callback. Arc does **not** parse mandate JWTs/VCs or
308
+ * verify DPoP proofs — that's a 1-2 line `jose` call in your authenticator.
309
+ * Arc validates *what's already proved* against the action being attempted.
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * import { requireDPoP, requireMandate, requireAgentScope } from '@classytic/arc/permissions';
314
+ *
315
+ * defineResource({
316
+ * name: 'invoice',
317
+ * permissions: {
318
+ * pay: requireAgentScope({
319
+ * capability: 'payment.charge',
320
+ * requireDPoP: true,
321
+ * validateAmount: (ctx, mandate) =>
322
+ * typeof ctx.data?.amount === 'number' && ctx.data.amount <= (mandate.cap ?? 0),
323
+ * audience: (ctx) => `invoice:${ctx.params?.id}`,
324
+ * }),
325
+ * },
326
+ * });
327
+ * ```
328
+ */
329
+ /** Default grace window for mandate `expiresAt` — accommodates clock skew. */
330
+ const DEFAULT_TTL_GRACE_MS = 3e4;
331
+ /**
332
+ * Require a sender-constrained credential — the inbound token MUST carry a
333
+ * DPoP proof (RFC 9449) bound to a known key. Arc reads `scope.dpopJkt` (the
334
+ * JWK SHA-256 thumbprint per RFC 7638); your `authenticate` function performs
335
+ * the cryptographic `jose.dpop.verify(...)` and sets the field on success.
336
+ *
337
+ * **Pass behavior:**
338
+ * - `service` scope where `dpopJkt` is set → grant
339
+ * - `elevated` scope → grant (platform admin bypass)
340
+ * - Anything else → deny with a clear reason
341
+ *
342
+ * Use for high-value endpoints where bearer-token replay must be impossible:
343
+ * payment charges, data exports, account-takeover-class admin actions.
344
+ *
345
+ * @example
346
+ * ```typescript
347
+ * permissions: { charge: allOf(requireServiceScope('payment.write'), requireDPoP()) }
348
+ * ```
349
+ */
350
+ function requireDPoP() {
351
+ const check = (ctx) => {
352
+ const scope = getRequestScope(ctx.request);
353
+ 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
+ };
362
+ return true;
363
+ };
364
+ check._dpopRequired = true;
365
+ return check;
366
+ }
367
+ /**
368
+ * Require a capability mandate (AP2 / x402 / MCP authorization) that
369
+ * authorizes the action being attempted.
370
+ *
371
+ * The mandate is set on `request.scope.mandate` by your authenticate function
372
+ * after verifying the inbound mandate JWT/VC. This check validates that the
373
+ * presented mandate covers the requested capability, hasn't expired, is bound
374
+ * to the right resource (when `audience` opt is set), and respects the
375
+ * mandate's numeric ceiling (when `validateAmount` opt is set).
376
+ *
377
+ * **Pass behavior:**
378
+ * - `elevated` scope → grant unless `noElevatedBypass: true`
379
+ * - `service` scope with mandate matching `capability`, not expired, and
380
+ * passing `validateAmount` + `audience` checks → grant
381
+ * - Anything else → deny with a precise reason
382
+ *
383
+ * Pair with `requireDPoP()` for replay-resistance, or use the bundled
384
+ * `requireAgentScope(...)` to declare both at once.
385
+ *
386
+ * @example
387
+ * ```typescript
388
+ * // Single payment charge — amount must fit the mandate's cap
389
+ * permissions: {
390
+ * pay: requireMandate('payment.charge', {
391
+ * validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
392
+ * audience: (ctx) => `invoice:${ctx.params?.id}`,
393
+ * }),
394
+ * }
395
+ *
396
+ * // Boolean capability — presence of mandate is the gate
397
+ * permissions: {
398
+ * exportData: requireMandate('data.export'),
399
+ * }
400
+ * ```
401
+ */
402
+ function requireMandate(capability, opts = {}) {
403
+ if (!capability || typeof capability !== "string") throw new Error("requireMandate(capability) requires a non-empty capability string (e.g. 'payment.charge')");
404
+ const ttlGrace = opts.ttlGraceMs ?? DEFAULT_TTL_GRACE_MS;
405
+ const noElevatedBypass = opts.noElevatedBypass === true;
406
+ const check = (ctx) => {
407
+ const scope = getRequestScope(ctx.request);
408
+ if (isElevated(scope) && !noElevatedBypass) return true;
409
+ 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
+ };
422
+ if (opts.audience) {
423
+ 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
+ };
432
+ }
433
+ if (opts.validateAmount) {
434
+ 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
+ };
439
+ }
440
+ return true;
441
+ };
442
+ check._mandateCapability = capability;
443
+ return check;
444
+ }
445
+ /**
446
+ * Composite gate for AI-agent / M2M flows on protected resources.
447
+ *
448
+ * Bundles the three things every high-value agent endpoint needs:
449
+ * 1. **Service identity** — `scope.kind === 'service'` with `clientId`
450
+ * 2. **Capability mandate** — narrows what *this request* may do
451
+ * 3. **DPoP binding** — credential cannot be replayed from a different key
452
+ *
453
+ * Use this instead of hand-composing `allOf(requireServiceScope(...),
454
+ * requireMandate(...), requireDPoP())` — fewer ways to misconfigure, one
455
+ * meta-tag downstream tools (audit, MCP, OpenAPI) can read.
456
+ *
457
+ * @example
458
+ * ```typescript
459
+ * import { requireAgentScope } from '@classytic/arc/permissions';
460
+ *
461
+ * defineResource({
462
+ * name: 'invoice',
463
+ * actions: {
464
+ * pay: {
465
+ * handler: payInvoice,
466
+ * permissions: requireAgentScope({
467
+ * capability: 'payment.charge',
468
+ * scopes: ['payment.write'],
469
+ * requireDPoP: true,
470
+ * audience: (ctx) => `invoice:${ctx.params?.id}`,
471
+ * validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
472
+ * }),
473
+ * },
474
+ * },
475
+ * });
476
+ * ```
477
+ */
478
+ function requireAgentScope(opts) {
479
+ const { capability, scopes, requireDPoP: needsDPoP = true, ...mandateOpts } = opts;
480
+ if (!capability) throw new Error("requireAgentScope({ capability }) is required");
481
+ const mandateCheck = requireMandate(capability, mandateOpts);
482
+ const dpopCheck = needsDPoP ? requireDPoP() : null;
483
+ const requiredScopes = scopes && scopes.length > 0 ? [...scopes] : null;
484
+ const check = async (ctx) => {
485
+ const scope = getRequestScope(ctx.request);
486
+ 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
+ };
491
+ if (requiredScopes) {
492
+ 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
+ };
497
+ }
498
+ const mandateResult = await mandateCheck(ctx);
499
+ if (mandateResult !== true) return typeof mandateResult === "object" ? mandateResult : {
500
+ granted: false,
501
+ reason: "Mandate check failed"
502
+ };
503
+ if (dpopCheck) {
504
+ const dpopResult = await dpopCheck(ctx);
505
+ if (dpopResult !== true) return typeof dpopResult === "object" ? dpopResult : {
506
+ granted: false,
507
+ reason: "DPoP binding required"
508
+ };
509
+ }
510
+ return true;
511
+ };
512
+ check._agentScope = {
513
+ capability,
514
+ scopes: requiredScopes ?? void 0,
515
+ dpop: needsDPoP
516
+ };
517
+ return check;
518
+ }
519
+ //#endregion
783
520
  //#region src/permissions/scope.ts
784
521
  /**
785
522
  * Permission Scope — checks bound to RequestScope.
@@ -1315,6 +1052,82 @@ function createDynamicPermissionMatrix(config) {
1315
1052
  };
1316
1053
  }
1317
1054
  //#endregion
1055
+ //#region src/permissions/grants.ts
1056
+ /**
1057
+ * The grant-mode lattice, weakest → strongest. Holding a mode satisfies
1058
+ * every mode at or below it (`write` ⊇ `read` ⊇ `list` ⊇ `see`); `manage`
1059
+ * (grant/revoke rights) satisfies everything. Same semantics as Puter's
1060
+ * `see < list < read < write` hierarchy with `manage` folded into the
1061
+ * lattice top instead of a separate string namespace.
1062
+ */
1063
+ const GRANT_MODES = [
1064
+ "see",
1065
+ "list",
1066
+ "read",
1067
+ "write",
1068
+ "manage"
1069
+ ];
1070
+ /**
1071
+ * Does a HELD mode satisfy a REQUIRED mode? Lattice implication —
1072
+ * `modeSatisfies('write', 'read') === true`, `modeSatisfies('see', 'read')
1073
+ * === false`. Unknown strings (a corrupted grant row) never satisfy
1074
+ * anything: fail-closed.
1075
+ */
1076
+ function modeSatisfies(held, required) {
1077
+ if (held === void 0) return false;
1078
+ const heldRank = GRANT_MODES.indexOf(held);
1079
+ if (heldRank === -1) return false;
1080
+ return heldRank >= GRANT_MODES.indexOf(required);
1081
+ }
1082
+ /**
1083
+ * Per-record grant gate. Same combinator tier as `requireOwnership` —
1084
+ * compose with `anyOf`/`allOf` like any other `PermissionCheck`.
1085
+ *
1086
+ * Fail-closed contract: resolver throws → denied (logged via
1087
+ * `ctx.request.log`); resolution carries neither `mode` nor `filters` →
1088
+ * denied; unknown/corrupted mode strings → denied.
1089
+ */
1090
+ function requireGrant(options) {
1091
+ const { mode: required, resolve, bypassRoles } = options;
1092
+ return async (ctx) => {
1093
+ if (bypassRoles && ctx.user && getUserRoles(ctx.user).some((r) => bypassRoles.includes(r))) return true;
1094
+ let resolution;
1095
+ try {
1096
+ resolution = await resolve(ctx);
1097
+ } catch (err) {
1098
+ ctx.request.log?.warn?.({
1099
+ err,
1100
+ resource: ctx.resource,
1101
+ action: ctx.action
1102
+ }, "requireGrant: resolve threw — denying (fail-closed)");
1103
+ return {
1104
+ granted: false,
1105
+ reason: "Grant lookup failed"
1106
+ };
1107
+ }
1108
+ if (typeof resolution === "boolean") return resolution;
1109
+ const { mode: held, filters, reason } = resolution;
1110
+ 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
+ };
1119
+ }
1120
+ if (filters !== void 0) return {
1121
+ granted: true,
1122
+ filters
1123
+ };
1124
+ return {
1125
+ granted: false,
1126
+ ...reason ? { reason } : {}
1127
+ };
1128
+ };
1129
+ }
1130
+ //#endregion
1318
1131
  //#region src/permissions/roleHierarchy.ts
1319
1132
  /**
1320
1133
  * Create a role hierarchy from a parent → children map.
@@ -1530,4 +1343,4 @@ function readOnly(overrides) {
1530
1343
  }, overrides);
1531
1344
  }
1532
1345
  //#endregion
1533
- export { requireDPoP as A, not as C, roles as D, requireRoles as E, applyFieldReadPermissions as F, applyFieldWritePermissions as I, fields as L, applyPermissionResult as M, evaluateAndApplyPermission as N, when as O, normalizePermissionResult as P, resolveEffectiveRoles as R, denyAll as S, requireOwnership as T, requireServiceScope as _, permissionMatrix as a, allowPublic as b, publicReadAdminWrite as c, createDynamicPermissionMatrix as d, createOrgPermissions as f, requireScopeContext as g, requireOrgRole as h, ownerWithAdminBypass as i, requireMandate as j, requireAgentScope as k, readOnly as l, requireOrgMembership as m, authenticated as n, presets_exports as o, requireOrgInScope as p, fullPublic as r, publicRead as s, adminOnly as t, createRoleHierarchy as u, requireTeamMembership as v, requireAuth as w, anyOf as x, allOf as y };
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 };