@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,6 +1,4 @@
1
- import { f as createError } from "./errors-DkO8Nk6-.mjs";
2
- import { n as requestContext } from "./requestContext-BZKOr13H.mjs";
3
- import { N as evaluateAndApplyPermission } from "./permissions-BJMuKMl7.mjs";
1
+ import { n as requestContext } from "./requestContext-MrlJwECf.mjs";
4
2
  //#region src/core/middlewares/arcDecorator.ts
5
3
  /**
6
4
  * Build the `arcDecorator` preHandler for a resource.
@@ -179,76 +177,6 @@ function describeValue(v) {
179
177
  return `${typeof v} (${JSON.stringify(v).slice(0, 80)})`;
180
178
  }
181
179
  //#endregion
182
- //#region src/core/middlewares/permissions.ts
183
- /**
184
- * Build a PermissionContext from a Fastify request. Extracted so the CRUD
185
- * permission middleware and the dynamic action-permission check use the same
186
- * field layout — divergence here silently broke policy filters for actions.
187
- */
188
- function buildPermissionContext(req, opts) {
189
- const reqWithExtras = req;
190
- const params = req.params;
191
- return {
192
- user: reqWithExtras.user ?? null,
193
- request: req,
194
- resource: opts.resource,
195
- action: opts.action,
196
- resourceId: opts.resourceId ?? params?.id,
197
- params,
198
- data: opts.data ?? req.body
199
- };
200
- }
201
- /**
202
- * Static per-route CRUD permission gate. The permission and action are known
203
- * at route-registration time, so the gate is a plain preHandler.
204
- *
205
- * Actions use the dynamic counterpart `buildActionPermissionMw` — their
206
- * permission is resolved from `body.action` at request time.
207
- */
208
- function buildCrudPermissionMw(permissionCheck, resourceName, action) {
209
- if (!permissionCheck) return null;
210
- return async (req, reply) => {
211
- await evaluateAndApplyPermission(permissionCheck, buildPermissionContext(req, {
212
- resource: resourceName,
213
- action
214
- }), req, reply);
215
- };
216
- }
217
- /**
218
- * Dynamic per-action permission gate for the action router.
219
- *
220
- * Resolves the permission from `body.action` at request time and runs
221
- * `evaluateAndApplyPermission` from the canonical `permissionMw` slot — so
222
- * `_policyFilters` and `request.scope` are installed BEFORE `pluginMw`
223
- * (idempotency) and `routeGuards` run. Previously this check lived inside
224
- * the main action handler, which meant idempotency recorded unauthorized
225
- * requests and route guards saw unfiltered scope — the very divergence
226
- * routerShared exists to prevent.
227
- *
228
- * Also acts as a defensive fallback for invalid action names — the
229
- * `oneOf` body schema normally rejects these at AJV validation, but
230
- * hosts that disable schema validation still get a 400 here.
231
- */
232
- function buildActionPermissionMw(actionEnum, actionPermissions, globalAuth, resourceName) {
233
- const enumSet = new Set(actionEnum);
234
- const validActions = [...actionEnum];
235
- return async (req, reply) => {
236
- const body = req.body ?? {};
237
- const action = body.action;
238
- if (!action || !enumSet.has(action)) throw createError(400, `Invalid action '${action ?? ""}'. Valid actions: ${validActions.join(", ")}`, { validActions });
239
- const permissionCheck = actionPermissions[action] ?? globalAuth;
240
- if (!permissionCheck) return;
241
- const { action: _discard, ...data } = body;
242
- const params = req.params;
243
- await evaluateAndApplyPermission(permissionCheck, buildPermissionContext(req, {
244
- resource: resourceName,
245
- action,
246
- resourceId: params?.id,
247
- data
248
- }), req, reply, { defaultDenialMessage: (user) => user ? `Permission denied for '${action}'` : "Authentication required" });
249
- };
250
- }
251
- //#endregion
252
180
  //#region src/core/middlewares/pluginMw.ts
253
181
  /**
254
182
  * Pick the request-lifecycle plugin middleware for an HTTP method:
@@ -314,39 +242,4 @@ function buildRateLimitConfig(rateLimit) {
314
242
  } };
315
243
  }
316
244
  //#endregion
317
- //#region src/core/middlewares/registerRoute.ts
318
- /**
319
- * Register a route with friendly handling of `FST_ERR_DUPLICATED_ROUTE`.
320
- *
321
- * Boot-time `validateRouteCrudCollisions()` catches custom-route → auto-CRUD
322
- * overlaps before Fastify ever runs, but a few cases still slip through to
323
- * Fastify itself: two presets contributing the same `routes` entry, hosts
324
- * mounting two resources at the same prefix, custom routes that share a
325
- * URL with another plugin. Fastify's default message ("Method 'GET'
326
- * already declared for route '/'") gives no hint at the resource-shaped
327
- * fix — `disabledRoutes: ['list']` — so wrap it with one.
328
- */
329
- function tryRegisterRoute(fastify, opts, context) {
330
- try {
331
- fastify.route(opts);
332
- } catch (err) {
333
- const fastifyErr = err;
334
- if (fastifyErr?.code !== "FST_ERR_DUPLICATED_ROUTE") throw err;
335
- const method = opts.method;
336
- const url = opts.url;
337
- const opHint = context.op ? ` (op: ${context.op})` : "";
338
- const fixHint = context.op && [
339
- "list",
340
- "get",
341
- "create",
342
- "update",
343
- "delete"
344
- ].includes(context.op) ? ` Suppress the auto-CRUD route with \`disabledRoutes: ['${context.op}']\`.` : " If a custom route in `routes:` collides with auto-CRUD, add `disabledRoutes: ['list' | 'get' | 'create' | 'update' | 'delete']` to `defineResource()` to suppress the matching auto-CRUD route.";
345
- const enhanced = /* @__PURE__ */ new Error(`[arc] Duplicate route ${method} ${url} on resource "${context.resourceName}"${opHint}.${fixHint} Original: ${fastifyErr.message ?? "FST_ERR_DUPLICATED_ROUTE"}`);
346
- enhanced.code = "FST_ERR_DUPLICATED_ROUTE";
347
- enhanced.cause = err;
348
- throw enhanced;
349
- }
350
- }
351
- //#endregion
352
- export { selectPluginMw as a, buildRouteHooks as c, buildAuthMiddleware as d, buildAuthMiddlewareForPermissions as f, resolveRouterPluginMw as i, resolveRoutePreHandlers as l, buildRateLimitConfig as n, buildActionPermissionMw as o, buildArcDecorator as p, buildRouteConfig as r, buildCrudPermissionMw as s, tryRegisterRoute as t, routeHookOptions as u };
245
+ export { buildRouteHooks as a, buildAuthMiddleware as c, selectPluginMw as i, buildAuthMiddlewareForPermissions as l, buildRouteConfig as n, resolveRoutePreHandlers as o, resolveRouterPluginMw as r, routeHookOptions as s, buildRateLimitConfig as t, buildArcDecorator as u };
@@ -0,0 +1,375 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
+ import { arcLog } from "./logger/index.mjs";
3
+ import { n as getUserRoles } from "./userHelpers-Cg_y3fBz.mjs";
4
+ import { c as getOrgId } from "./types-CSyCxl2V.mjs";
5
+ import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Cg05HPsn.mjs";
6
+ import { i as applyFieldReadPermissions, n as evaluateAndApplyPermission } from "./applyPermissionResult-Tw4iXtlf.mjs";
7
+ import { a as promoteStreamTokenToHeader } from "./streaming-BQcW6_Kq.mjs";
8
+ import { t as openSseStream } from "./sseStream-BQ7Qaynp.mjs";
9
+ import fp from "fastify-plugin";
10
+ //#region src/plugins/realtime.ts
11
+ var realtime_exports = /* @__PURE__ */ __exportAll({
12
+ buildChangeFrame: () => buildChangeFrame,
13
+ connectionDeadlineMs: () => connectionDeadlineMs,
14
+ default: () => realtime_default,
15
+ realtimePlugin: () => realtimePlugin,
16
+ resolveDelivery: () => resolveDelivery,
17
+ resolveRowMatcher: () => resolveRowMatcher
18
+ });
19
+ const log = arcLog("realtime");
20
+ const CHANGE_OPERATIONS = [
21
+ "created",
22
+ "updated",
23
+ "deleted"
24
+ ];
25
+ /**
26
+ * SSE `retry:` reconnection hint (ms). Deliberate: the feed has no replay,
27
+ * so a reconnecting client refetches the list anyway — a short retry keeps
28
+ * the gap small without hammering the server.
29
+ */
30
+ const DEFAULT_RETRY_MS = 3e3;
31
+ /**
32
+ * Cap on resources per multiplexed connection. Generous for real
33
+ * dashboards (Supabase caps channels-per-client similarly); prevents a
34
+ * single socket from fanning out unbounded subscription work.
35
+ */
36
+ const MAX_MULTIPLEX_RESOURCES = 20;
37
+ /** Node's `setTimeout` ceiling (~24.8 days); larger delays fire immediately. */
38
+ const MAX_TIMER_MS = 2147483647;
39
+ /**
40
+ * Milliseconds from `now` until the feed should force-close to bound
41
+ * permission staleness — `min(auth-token exp, maxConnectionMs)`. Returns
42
+ * `undefined` when neither bound applies (no `exp` claim, no cap → the feed
43
+ * lives until client disconnect), `0` when already expired, else the
44
+ * (setTimeout-ceiling-clamped) delay. Pure; exported for tests.
45
+ */
46
+ function connectionDeadlineMs(user, maxConnectionMs, now = Date.now()) {
47
+ let deadline;
48
+ const exp = user?.exp;
49
+ if (typeof exp === "number" && Number.isFinite(exp)) deadline = exp * 1e3 - now;
50
+ if (maxConnectionMs !== void 0 && maxConnectionMs > 0) deadline = deadline === void 0 ? maxConnectionMs : Math.min(deadline, maxConnectionMs);
51
+ if (deadline === void 0) return void 0;
52
+ if (deadline <= 0) return 0;
53
+ return Math.min(deadline, MAX_TIMER_MS);
54
+ }
55
+ /**
56
+ * Are these filters flat equality (every value a scalar, no `$` operators)?
57
+ * That is the shape `simpleEqualityMatcher` is safe for — anything richer
58
+ * needs the adapter's own matcher.
59
+ */
60
+ function isFlatEquality(filters) {
61
+ for (const [key, value] of Object.entries(filters)) {
62
+ if (key.startsWith("$")) return false;
63
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) return false;
64
+ }
65
+ return true;
66
+ }
67
+ /**
68
+ * Decide how (or whether) this subscription's row filters can be enforced
69
+ * in process: adapter matcher when supplied, `simpleEqualityMatcher` for
70
+ * flat-equality shapes, UNENFORCEABLE (fail-closed → 501) for
71
+ * operator-shaped filters with no adapter matcher. No filters → nothing
72
+ * to enforce.
73
+ */
74
+ function resolveRowMatcher(filters, adapter) {
75
+ const hasFilters = !!filters && Object.keys(filters).length > 0;
76
+ if (adapter?.matchesFilter) {
77
+ const matcher = adapter.matchesFilter.bind(adapter);
78
+ if (hasFilters && filters) try {
79
+ matcher({}, filters);
80
+ } catch {
81
+ return { enforceable: false };
82
+ }
83
+ return {
84
+ enforceable: true,
85
+ matcher
86
+ };
87
+ }
88
+ if (!hasFilters) return { enforceable: true };
89
+ if (isFlatEquality(filters)) return {
90
+ enforceable: true,
91
+ matcher: simpleEqualityMatcher
92
+ };
93
+ return { enforceable: false };
94
+ }
95
+ /**
96
+ * Decide this event's fate for one subscriber. Pure — the whole
97
+ * authorization + membership-transition story lives here:
98
+ *
99
+ * 1. tenant guard: org-carrying events only reach matching-org subscribers
100
+ * (a cross-org record was never visible → drop, never a leave).
101
+ * 2. row filters (connect-time snapshot):
102
+ * - matches (or no filters) → deliver.
103
+ * - no match + operation is `updated` → LEAVE (membership
104
+ * transition out; the record may have been in the subscriber's view).
105
+ * - no match + operation is `created`/`deleted` → drop (never entered
106
+ * the view — a created record that doesn't match was never shown; a
107
+ * deleted record that doesn't match was already filtered out).
108
+ *
109
+ * The ENTER transition (a record updated so it now DOES match) needs no
110
+ * special signal: the matching `updated` frame carries the full record, so
111
+ * the client adds it if absent.
112
+ */
113
+ function resolveDelivery(event, policy, operation) {
114
+ if (policy.tenantScoped) {
115
+ const eventOrgId = event.meta?.organizationId;
116
+ if (eventOrgId && eventOrgId !== policy.subscriberOrgId) return { kind: "drop" };
117
+ }
118
+ const doc = event.payload?.data;
119
+ if (!(!!policy.filters && Object.keys(policy.filters).length > 0)) return {
120
+ kind: "deliver",
121
+ doc
122
+ };
123
+ if (!!doc && typeof doc === "object" && !!policy.matcher?.(doc, policy.filters)) return {
124
+ kind: "deliver",
125
+ doc
126
+ };
127
+ return operation === "updated" ? { kind: "leave" } : { kind: "drop" };
128
+ }
129
+ /**
130
+ * Build the wire frame for one delivered change. Envelope note: this is
131
+ * deliberately DOCUMENT-CHANGE-shaped (`data` = the masked document),
132
+ * unlike the sse plugin's raw event mirror (`payload` = whatever was
133
+ * published) — the two streams serve different consumers and the
134
+ * divergence is intentional, not drift.
135
+ */
136
+ function buildChangeFrame(event, resourceName, data) {
137
+ return JSON.stringify({
138
+ type: event.type,
139
+ resource: resourceName,
140
+ id: event.meta?.resourceId,
141
+ data,
142
+ meta: {
143
+ timestamp: event.meta?.timestamp,
144
+ correlationId: event.meta?.correlationId
145
+ }
146
+ });
147
+ }
148
+ const realtimePlugin = async (fastify, opts = {}) => {
149
+ const { path = "/realtime/:resource", heartbeat = 3e4, resources: allowlist, operations = CHANGE_OPERATIONS, tokenQueryParam = "token", maxConnectionMs } = opts;
150
+ if (!fastify.hasDecorator("events")) {
151
+ log.warn("Events plugin (arc-events) not registered. Realtime plugin will not function. Register eventPlugin before realtimePlugin.");
152
+ return;
153
+ }
154
+ if (!path.includes(":resource")) throw new Error(`[arc-realtime] path must contain ':resource' (got '${path}')`);
155
+ const activeConnections = /* @__PURE__ */ new Set();
156
+ const isProtected = (entry) => {
157
+ const listCheck = entry.permissions?.list;
158
+ return typeof listCheck === "function" && listCheck._isPublic !== true;
159
+ };
160
+ /**
161
+ * Run auth once for a set of requested resources: `authenticate` when ANY
162
+ * is protected (401 on missing/invalid token), `optionalAuthenticate`
163
+ * otherwise so tokens still parse. Returns false when a reply was sent.
164
+ */
165
+ const runAuth = async (request, reply, entries) => {
166
+ const needsAuth = entries.some(isProtected);
167
+ if (tokenQueryParam) promoteStreamTokenToHeader(request, tokenQueryParam);
168
+ const authenticate = needsAuth ? fastify.authenticate : fastify.optionalAuthenticate;
169
+ if (needsAuth && typeof authenticate !== "function") {
170
+ await reply.code(401).send({
171
+ code: "arc.unauthorized",
172
+ message: "Authentication required",
173
+ status: 401
174
+ });
175
+ return false;
176
+ }
177
+ if (typeof authenticate === "function") {
178
+ await authenticate(request, reply);
179
+ if (reply.sent) return false;
180
+ }
181
+ return true;
182
+ };
183
+ /**
184
+ * Authorize ONE resource for this connection: run its list permission,
185
+ * snapshot its row filters IN ISOLATION (evaluateAndApplyPermission
186
+ * accumulates onto request._policyFilters — on a multiplexed connection
187
+ * resource B must never inherit resource A's filters, so the field is
188
+ * saved/reset/restored around each evaluation), and resolve the row
189
+ * matcher fail-closed. Sends the denial/501 reply itself; returns null
190
+ * when the caller should stop.
191
+ */
192
+ const authorizeResource = async (resourceName, entry, request, reply) => {
193
+ const holder = request;
194
+ const saved = holder._policyFilters;
195
+ holder._policyFilters = void 0;
196
+ try {
197
+ const listCheck = entry.permissions?.list;
198
+ if (typeof listCheck === "function") {
199
+ if (!await evaluateAndApplyPermission(listCheck, {
200
+ user: request.user ?? null,
201
+ request,
202
+ resource: resourceName,
203
+ action: "list"
204
+ }, request, reply)) return null;
205
+ }
206
+ const filters = holder._policyFilters;
207
+ const resolution = resolveRowMatcher(filters, fastify.arc?.registry?.getAdapter(resourceName));
208
+ if (!resolution.enforceable) {
209
+ await reply.code(501).send({
210
+ code: "arc.realtime.unfilterable",
211
+ message: `The '${resourceName}' list permission returned operator-shaped row filters, which this feed cannot enforce in-process. Supply \`matchesFilter\` on the resource's DataAdapter to enable realtime for filter-scoped subscribers.`,
212
+ status: 501
213
+ });
214
+ return null;
215
+ }
216
+ const scope = request.scope;
217
+ return {
218
+ name: resourceName,
219
+ policy: {
220
+ tenantScoped: entry.tenantField !== false,
221
+ subscriberOrgId: scope ? getOrgId(scope) : void 0,
222
+ filters,
223
+ matcher: resolution.matcher
224
+ },
225
+ fields: entry.fields
226
+ };
227
+ } finally {
228
+ holder._policyFilters = saved;
229
+ }
230
+ };
231
+ /** Open the stream and wire every authorized subscription onto it. */
232
+ const sharedFrames = /* @__PURE__ */ new WeakMap();
233
+ const streamSubscriptions = async (request, reply, subscriptions) => {
234
+ const userRoles = getUserRoles(request.user ?? {});
235
+ const stream = openSseStream(request, reply, {
236
+ heartbeatMs: heartbeat,
237
+ retryMs: DEFAULT_RETRY_MS
238
+ });
239
+ stream.onCleanup(() => activeConnections.delete(stream.close));
240
+ activeConnections.add(stream.close);
241
+ const closeAt = connectionDeadlineMs(request.user, maxConnectionMs);
242
+ if (closeAt !== void 0) {
243
+ if (closeAt <= 0) {
244
+ stream.close();
245
+ return;
246
+ }
247
+ const timer = setTimeout(stream.close, closeAt);
248
+ timer.unref?.();
249
+ stream.onCleanup(() => clearTimeout(timer));
250
+ }
251
+ for (const sub of subscriptions) {
252
+ const deliver = (event, operation) => {
253
+ const decision = resolveDelivery(event, sub.policy, operation);
254
+ if (decision.kind === "drop") return;
255
+ if (decision.kind === "leave") {
256
+ stream.write(`${sub.name}.left`, JSON.stringify({
257
+ type: `${sub.name}.left`,
258
+ resource: sub.name,
259
+ id: event.meta?.resourceId
260
+ }), event.meta?.id);
261
+ return;
262
+ }
263
+ const { doc } = decision;
264
+ const needsMask = !!sub.fields && !!doc && typeof doc === "object";
265
+ let frame;
266
+ if (needsMask) {
267
+ const masked = applyFieldReadPermissions(doc, sub.fields, userRoles);
268
+ frame = buildChangeFrame(event, sub.name, masked);
269
+ } else {
270
+ const cached = sharedFrames.get(event);
271
+ frame = cached ?? buildChangeFrame(event, sub.name, doc);
272
+ if (!cached) sharedFrames.set(event, frame);
273
+ }
274
+ stream.write(event.type, frame, event.meta?.id);
275
+ };
276
+ for (const operation of operations) {
277
+ const unsub = await fastify.events.subscribe(`${sub.name}.${operation}`, (event) => deliver(event, operation));
278
+ stream.onCleanup(unsub);
279
+ }
280
+ }
281
+ stream.write("ready", JSON.stringify({
282
+ resources: subscriptions.map((s) => s.name),
283
+ operations: [...operations]
284
+ }));
285
+ };
286
+ const lookupEntry = (name) => {
287
+ if (allowlist && !allowlist.includes(name)) return void 0;
288
+ return fastify.arc?.registry?.get(name);
289
+ };
290
+ const notFound = (reply, name) => reply.code(404).send({
291
+ code: "arc.not_found",
292
+ message: `Resource '${name}' has no realtime feed`,
293
+ status: 404
294
+ });
295
+ fastify.route({
296
+ method: "GET",
297
+ url: path,
298
+ schema: {
299
+ tags: ["Events"],
300
+ summary: "Realtime resource change feed",
301
+ description: "SSE stream of created/updated/deleted events for one resource, gated by its list permission — row filters and field-level read permissions apply per event.",
302
+ response: { 200: {
303
+ type: "string",
304
+ description: "text/event-stream"
305
+ } }
306
+ },
307
+ handler: async (request, reply) => {
308
+ const resourceName = request.params.resource;
309
+ const entry = lookupEntry(resourceName);
310
+ if (!entry) return notFound(reply, resourceName);
311
+ if (!await runAuth(request, reply, [entry])) return;
312
+ const sub = await authorizeResource(resourceName, entry, request, reply);
313
+ if (!sub) return;
314
+ await streamSubscriptions(request, reply, [sub]);
315
+ }
316
+ });
317
+ const multiplexPath = path.replace(/\/:resource$/, "");
318
+ fastify.route({
319
+ method: "GET",
320
+ url: multiplexPath,
321
+ schema: {
322
+ tags: ["Events"],
323
+ summary: "Realtime multi-resource change feed",
324
+ description: "Multiplexed SSE stream for several resources over one connection (?resources=a,b,c). Every resource is gated by its own list permission; any denial rejects the subscription.",
325
+ querystring: {
326
+ type: "object",
327
+ properties: { resources: { type: "string" } },
328
+ required: ["resources"]
329
+ },
330
+ response: { 200: {
331
+ type: "string",
332
+ description: "text/event-stream"
333
+ } }
334
+ },
335
+ handler: async (request, reply) => {
336
+ const raw = request.query.resources;
337
+ const names = [...new Set(raw.split(",").map((s) => s.trim()).filter(Boolean))];
338
+ if (names.length === 0 || names.length > MAX_MULTIPLEX_RESOURCES) return reply.code(400).send({
339
+ code: "arc.bad_request",
340
+ message: `resources must name 1–${MAX_MULTIPLEX_RESOURCES} registered resources`,
341
+ status: 400
342
+ });
343
+ const entries = [];
344
+ for (const name of names) {
345
+ const entry = lookupEntry(name);
346
+ if (!entry) return notFound(reply, name);
347
+ entries.push(entry);
348
+ }
349
+ if (!await runAuth(request, reply, entries)) return;
350
+ const subscriptions = [];
351
+ for (let i = 0; i < names.length; i++) {
352
+ const sub = await authorizeResource(names[i], entries[i], request, reply);
353
+ if (!sub) return;
354
+ subscriptions.push(sub);
355
+ }
356
+ await streamSubscriptions(request, reply, subscriptions);
357
+ }
358
+ });
359
+ fastify.addHook("onClose", async () => {
360
+ for (const close of activeConnections) close();
361
+ activeConnections.clear();
362
+ });
363
+ log.debug("Plugin registered", {
364
+ path,
365
+ operations,
366
+ allowlist
367
+ });
368
+ };
369
+ var realtime_default = fp(realtimePlugin, {
370
+ name: "arc-realtime",
371
+ fastify: "5.x",
372
+ dependencies: ["arc-events"]
373
+ });
374
+ //#endregion
375
+ export { realtime_default as n, realtime_exports as r, realtimePlugin as t };
@@ -59,6 +59,20 @@ interface RedisStreamTransportOptions {
59
59
  * @default 10
60
60
  */
61
61
  batchSize?: number;
62
+ /**
63
+ * Bounded concurrency for processing a batch's ENTRIES (default: 1 —
64
+ * strict stream order, the safe default). With one slow handler,
65
+ * throughput per consumer is ~`1 / total handler latency`; raising this
66
+ * processes up to N entries of a batch concurrently.
67
+ *
68
+ * Trade-off: entries within a batch may complete OUT OF ORDER — only
69
+ * raise it when handlers are order-independent (idempotent, keyed
70
+ * upserts). Handlers on this transport should be short and I/O-bound
71
+ * regardless; heavy work belongs in jobs. Matching handlers for a single
72
+ * event always run sequentially.
73
+ * @default 1
74
+ */
75
+ processingConcurrency?: number;
62
76
  /**
63
77
  * Max delivery attempts before moving to dead letter stream.
64
78
  * @default 5
@@ -133,6 +147,7 @@ declare class RedisStreamTransport implements EventTransport {
133
147
  private consumer;
134
148
  private blockTimeMs;
135
149
  private batchSize;
150
+ private processingConcurrency;
136
151
  private maxRetries;
137
152
  private claimTimeoutMs;
138
153
  private deadLetterStream;
@@ -207,6 +222,13 @@ declare class RedisStreamTransport implements EventTransport {
207
222
  private ensureGroup;
208
223
  private pollLoop;
209
224
  private readNewMessages;
225
+ /**
226
+ * Process a batch's entries with bounded concurrency
227
+ * (`processingConcurrency`, default 1 = strict order). Worker-pool over a
228
+ * shared index — at most N entries in flight, each entry's own handlers
229
+ * still sequential inside `processEntry`.
230
+ */
231
+ private processEntries;
210
232
  private claimPending;
211
233
  private processEntry;
212
234
  private getMatchingHandlers;
@@ -0,0 +1,108 @@
1
+ import { f as createError } from "./errors-DkO8Nk6-.mjs";
2
+ import { n as evaluateAndApplyPermission } from "./applyPermissionResult-Tw4iXtlf.mjs";
3
+ //#region src/core/middlewares/permissions.ts
4
+ /**
5
+ * Build a PermissionContext from a Fastify request. Extracted so the CRUD
6
+ * permission middleware and the dynamic action-permission check use the same
7
+ * field layout — divergence here silently broke policy filters for actions.
8
+ */
9
+ function buildPermissionContext(req, opts) {
10
+ const reqWithExtras = req;
11
+ const params = req.params;
12
+ return {
13
+ user: reqWithExtras.user ?? null,
14
+ request: req,
15
+ resource: opts.resource,
16
+ action: opts.action,
17
+ resourceId: opts.resourceId ?? params?.id,
18
+ params,
19
+ data: opts.data ?? req.body
20
+ };
21
+ }
22
+ /**
23
+ * Static per-route CRUD permission gate. The permission and action are known
24
+ * at route-registration time, so the gate is a plain preHandler.
25
+ *
26
+ * Actions use the dynamic counterpart `buildActionPermissionMw` — their
27
+ * permission is resolved from `body.action` at request time.
28
+ */
29
+ function buildCrudPermissionMw(permissionCheck, resourceName, action) {
30
+ if (!permissionCheck) return null;
31
+ return async (req, reply) => {
32
+ await evaluateAndApplyPermission(permissionCheck, buildPermissionContext(req, {
33
+ resource: resourceName,
34
+ action
35
+ }), req, reply);
36
+ };
37
+ }
38
+ /**
39
+ * Dynamic per-action permission gate for the action router.
40
+ *
41
+ * Resolves the permission from `body.action` at request time and runs
42
+ * `evaluateAndApplyPermission` from the canonical `permissionMw` slot — so
43
+ * `_policyFilters` and `request.scope` are installed BEFORE `pluginMw`
44
+ * (idempotency) and `routeGuards` run. Previously this check lived inside
45
+ * the main action handler, which meant idempotency recorded unauthorized
46
+ * requests and route guards saw unfiltered scope — the very divergence
47
+ * routerShared exists to prevent.
48
+ *
49
+ * Also acts as a defensive fallback for invalid action names — the
50
+ * `oneOf` body schema normally rejects these at AJV validation, but
51
+ * hosts that disable schema validation still get a 400 here.
52
+ */
53
+ function buildActionPermissionMw(actionEnum, actionPermissions, globalAuth, resourceName) {
54
+ const enumSet = new Set(actionEnum);
55
+ const validActions = [...actionEnum];
56
+ return async (req, reply) => {
57
+ const body = req.body ?? {};
58
+ const action = body.action;
59
+ if (!action || !enumSet.has(action)) throw createError(400, `Invalid action '${action ?? ""}'. Valid actions: ${validActions.join(", ")}`, { validActions });
60
+ const permissionCheck = actionPermissions[action] ?? globalAuth;
61
+ if (!permissionCheck) return;
62
+ const { action: _discard, ...data } = body;
63
+ const params = req.params;
64
+ await evaluateAndApplyPermission(permissionCheck, buildPermissionContext(req, {
65
+ resource: resourceName,
66
+ action,
67
+ resourceId: params?.id,
68
+ data
69
+ }), req, reply, { defaultDenialMessage: (user) => user ? `Permission denied for '${action}'` : "Authentication required" });
70
+ };
71
+ }
72
+ //#endregion
73
+ //#region src/core/middlewares/registerRoute.ts
74
+ /**
75
+ * Register a route with friendly handling of `FST_ERR_DUPLICATED_ROUTE`.
76
+ *
77
+ * Boot-time `validateRouteCrudCollisions()` catches custom-route → auto-CRUD
78
+ * overlaps before Fastify ever runs, but a few cases still slip through to
79
+ * Fastify itself: two presets contributing the same `routes` entry, hosts
80
+ * mounting two resources at the same prefix, custom routes that share a
81
+ * URL with another plugin. Fastify's default message ("Method 'GET'
82
+ * already declared for route '/'") gives no hint at the resource-shaped
83
+ * fix — `disabledRoutes: ['list']` — so wrap it with one.
84
+ */
85
+ function tryRegisterRoute(fastify, opts, context) {
86
+ try {
87
+ fastify.route(opts);
88
+ } catch (err) {
89
+ const fastifyErr = err;
90
+ if (fastifyErr?.code !== "FST_ERR_DUPLICATED_ROUTE") throw err;
91
+ const method = opts.method;
92
+ const url = opts.url;
93
+ const opHint = context.op ? ` (op: ${context.op})` : "";
94
+ const fixHint = context.op && [
95
+ "list",
96
+ "get",
97
+ "create",
98
+ "update",
99
+ "delete"
100
+ ].includes(context.op) ? ` Suppress the auto-CRUD route with \`disabledRoutes: ['${context.op}']\`.` : " If a custom route in `routes:` collides with auto-CRUD, add `disabledRoutes: ['list' | 'get' | 'create' | 'update' | 'delete']` to `defineResource()` to suppress the matching auto-CRUD route.";
101
+ const enhanced = /* @__PURE__ */ new Error(`[arc] Duplicate route ${method} ${url} on resource "${context.resourceName}"${opHint}.${fixHint} Original: ${fastifyErr.message ?? "FST_ERR_DUPLICATED_ROUTE"}`);
102
+ enhanced.code = "FST_ERR_DUPLICATED_ROUTE";
103
+ enhanced.cause = err;
104
+ throw enhanced;
105
+ }
106
+ }
107
+ //#endregion
108
+ export { buildActionPermissionMw as n, buildCrudPermissionMw as r, tryRegisterRoute as t };
@@ -1,5 +1,5 @@
1
- import { K as ResolvedTenantPurge, R as RegisterOptions, V as ResourceDefinition, X as CrudRouteKey, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-DbtgCOCj.mjs";
2
- import "../fastify-augmentation-9_fuetB4.mjs";
1
+ import { K as ResolvedTenantPurge, R as RegisterOptions, V as ResourceDefinition, X as CrudRouteKey, k as IntrospectionPluginOptions, p as RegistryEntry, z as ResourceRegistry } from "../index-DPRH0Cme.mjs";
2
+ import "../fastify-augmentation-dPNBVeIj.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
  import { TenantPurgeProgress, TenantPurgeStrategy } from "@classytic/repo-core/repository";
5
5
  //#region src/registry/assertNoTenantData.d.ts
@@ -1,3 +1,3 @@
1
1
  import { a as cascadeDeleteForOrganization, c as assertNoTenantData, i as introspectionPlugin_default, n as buildResourceManifestFromRegistry, o as getCascadingResources, r as introspectionPlugin, s as getCascadingResourcesWithMetadata, t as buildResourceManifest } from "../registry-vcOCtPij.mjs";
2
- import { t as ResourceRegistry } from "../ResourceRegistry-7RnrEWNP.mjs";
2
+ import { t as ResourceRegistry } from "../ResourceRegistry-y9Hnypus.mjs";
3
3
  export { ResourceRegistry, assertNoTenantData, buildResourceManifest, buildResourceManifestFromRegistry, cascadeDeleteForOrganization, getCascadingResources, getCascadingResourcesWithMetadata, introspectionPlugin_default as introspectionPlugin, introspectionPlugin as introspectionPluginFn };