@classytic/arc 2.22.0 → 2.23.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 (86) hide show
  1. package/dist/{BaseController-C6FlRWxF.mjs → BaseController-BQ8KNvWC.mjs} +3 -2
  2. package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
  3. package/dist/{actionPermissions-ze70xXJi.mjs → actionPermissions-lszoJle1.mjs} +1 -1
  4. package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
  5. package/dist/audit/index.d.mts +1 -1
  6. package/dist/auth/index.d.mts +1 -1
  7. package/dist/auth/index.mjs +2 -2
  8. package/dist/cli/commands/describe.d.mts +1 -1
  9. package/dist/cli/commands/describe.mjs +1 -1
  10. package/dist/cli/commands/docs.mjs +2 -2
  11. package/dist/cli/commands/init.mjs +1 -1
  12. package/dist/cli/commands/introspect.mjs +1 -1
  13. package/dist/cli/index.mjs +1 -1
  14. package/dist/core/index.d.mts +2 -2
  15. package/dist/core/index.mjs +3 -3
  16. package/dist/{core-b6fgFgnd.mjs → core-DQ6rDTyH.mjs} +10 -9
  17. package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-BuOv-m4Y.mjs} +3 -3
  18. package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-CgJv_BU_.mjs} +1 -1
  19. package/dist/{createApp-Ci7yG5gv.mjs → createApp-CjOeWJ9_.mjs} +11 -2
  20. package/dist/docs/index.d.mts +1 -1
  21. package/dist/docs/index.mjs +1 -1
  22. package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
  23. package/dist/events/index.mjs +5 -2
  24. package/dist/factory/index.d.mts +20 -4
  25. package/dist/factory/index.mjs +31 -11
  26. package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-g_f9omSc.d.mts} +1 -1
  27. package/dist/hooks/index.d.mts +1 -1
  28. package/dist/idempotency/index.d.mts +7 -0
  29. package/dist/idempotency/index.mjs +5 -17
  30. package/dist/{index-DbtgCOCj.d.mts → index-BBsvQt7U.d.mts} +26 -4
  31. package/dist/{index-BEu4wjbL.d.mts → index-Ba10fdKp.d.mts} +11 -2
  32. package/dist/{index-D8MKYGDa.d.mts → index-CC5ruDGh.d.mts} +2 -2
  33. package/dist/{index-ogIHvOjo.d.mts → index-wMoWBLkn.d.mts} +67 -1
  34. package/dist/index.d.mts +4 -4
  35. package/dist/index.mjs +6 -5
  36. package/dist/{init-DleK3p9f.mjs → init-CjO4p_q1.mjs} +5 -2
  37. package/dist/integrations/event-gateway.mjs +1 -1
  38. package/dist/integrations/index.d.mts +1 -1
  39. package/dist/integrations/mcp/index.d.mts +66 -3
  40. package/dist/integrations/mcp/index.mjs +1 -1
  41. package/dist/integrations/mcp/testing.d.mts +1 -1
  42. package/dist/integrations/mcp/testing.mjs +1 -1
  43. package/dist/integrations/webhooks.d.mts +123 -3
  44. package/dist/integrations/webhooks.mjs +160 -5
  45. package/dist/interface-n5hpKg1d.mjs +16 -0
  46. package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
  47. package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-BtzIL_kM.mjs} +308 -39
  48. package/dist/middleware/index.d.mts +1 -1
  49. package/dist/{openapi-BRpMZD7C.mjs → openapi-jSDx-VDv.mjs} +3 -3
  50. package/dist/permissions/index.d.mts +2 -2
  51. package/dist/permissions/index.mjs +3 -2
  52. package/dist/{permissions-BJMuKMl7.mjs → permissions-tbxFHxjZ.mjs} +77 -264
  53. package/dist/pipeline/index.d.mts +1 -1
  54. package/dist/plugins/index.d.mts +6 -6
  55. package/dist/plugins/index.mjs +5 -4
  56. package/dist/plugins/tracing-entry.mjs +1 -1
  57. package/dist/presets/filesUpload.d.mts +1 -1
  58. package/dist/presets/filesUpload.mjs +1 -1
  59. package/dist/presets/index.d.mts +1 -1
  60. package/dist/presets/index.mjs +1 -1
  61. package/dist/presets/multiTenant.d.mts +1 -1
  62. package/dist/presets/search.d.mts +1 -1
  63. package/dist/presets/search.mjs +1 -1
  64. package/dist/{presets-gw0ciIUD.mjs → presets-BOe4Ugkl.mjs} +1 -1
  65. package/dist/realtime-I8OrfnMR.mjs +375 -0
  66. package/dist/{registerRoute-xHc42fwB.mjs → registerRoute-CWO6mdYe.mjs} +1 -1
  67. package/dist/registry/index.d.mts +2 -2
  68. package/dist/registry/index.mjs +1 -1
  69. package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
  70. package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
  71. package/dist/scope/index.mjs +1 -1
  72. package/dist/scope-LONQ3mfZ.mjs +37 -0
  73. package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
  74. package/dist/{sse-CfUjwjDk.mjs → sse-0u4P4-OI.mjs} +7 -30
  75. package/dist/sseStream-BQ7Qaynp.mjs +57 -0
  76. package/dist/testing/index.d.mts +2 -2
  77. package/dist/testing/index.mjs +2 -2
  78. package/dist/types/index.d.mts +1 -1
  79. package/dist/{types-Bc42CUyk.d.mts → types-5OuqeTDX.d.mts} +37 -2
  80. package/dist/utils/index.d.mts +62 -2
  81. package/dist/utils/index.mjs +5 -3
  82. package/dist/{utils-FhJ88TJE.mjs → utils-CZyeRXTh.mjs} +21 -69
  83. package/dist/{versioning-DLwklPlG.d.mts → versioning-D6PvCwpq.d.mts} +43 -4
  84. package/package.json +64 -4
  85. package/skills/arc/references/production.md +734 -696
  86. package/dist/scope-k15RXfcq.mjs +0 -64
@@ -0,0 +1,375 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
+ import { arcLog } from "./logger/index.mjs";
3
+ import { c as getOrgId } from "./types-bHblZ9YN.mjs";
4
+ import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Cg05HPsn.mjs";
5
+ import { i as applyFieldReadPermissions, n as evaluateAndApplyPermission } from "./applyPermissionResult-Tw4iXtlf.mjs";
6
+ import { t as getUserRoles } from "./types-XTApa8b6.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 };
@@ -1,6 +1,6 @@
1
1
  import { f as createError } from "./errors-DkO8Nk6-.mjs";
2
2
  import { n as requestContext } from "./requestContext-BZKOr13H.mjs";
3
- import { N as evaluateAndApplyPermission } from "./permissions-BJMuKMl7.mjs";
3
+ import { n as evaluateAndApplyPermission } from "./applyPermissionResult-Tw4iXtlf.mjs";
4
4
  //#region src/core/middlewares/arcDecorator.ts
5
5
  /**
6
6
  * Build the `arcDecorator` preHandler for a resource.
@@ -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-BBsvQt7U.mjs";
2
+ import "../fastify-augmentation-g_f9omSc.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 };
@@ -100,7 +100,10 @@ function toJsonSchema(input, target = DEFAULT_FASTIFY_TARGET, mode = "warn") {
100
100
  if (isZodSchema(input)) {
101
101
  if (!_toJSONSchema) return unconvertible(mode, "A Zod schema was passed but zod v4 is not installed (or not yet loaded). Install zod >=4 — arc converts via zod's native z.toJSONSchema().");
102
102
  try {
103
- const converted = _toJSONSchema(input, { target });
103
+ const converted = _toJSONSchema(input, {
104
+ target,
105
+ unrepresentable: mode === "throw" ? "throw" : "any"
106
+ });
104
107
  if ("$schema" in converted) delete converted.$schema;
105
108
  return converted;
106
109
  } catch (cause) {
@@ -1,4 +1,4 @@
1
- import { o as toJsonSchema } from "./schemaConverter-Ds5reSGs.mjs";
1
+ import { o as toJsonSchema } from "./schemaConverter-DJvodiHj.mjs";
2
2
  //#region src/core/schemaIR.ts
3
3
  /**
4
4
  * Schema IR — one canonical representation, two adapters.
@@ -1,5 +1,5 @@
1
1
  import { C as isService, D as requireUserId, E as requireTeamId, S as isOrgInScope, T as requireOrgId, _ as getUserRoles, a as getDPoPJkt, b as isElevated, c as getOrgId, d as getScopeContext, f as getScopeContextMap, g as getUserId, h as getTenantFromRequest, i as getClientId, l as getOrgRoles, m as getTeamId, n as PUBLIC_SCOPE, o as getMandate, p as getServiceScopes, r as getAncestorOrgIds, s as getOrgContext, t as AUTHENTICATED_SCOPE, u as getRequestScope, v as hasOrgAccess, w as requireClientId, x as isMember, y as isAuthenticated } from "../types-bHblZ9YN.mjs";
2
2
  import { n as elevation_default, t as elevationPlugin } from "../elevation-COpYKt2h.mjs";
3
3
  import { t as createTenantKeyGenerator } from "../rateLimitKey-BoWRH3e7.mjs";
4
- import { t as resolveOrgFromHeader } from "../scope-k15RXfcq.mjs";
4
+ import { t as resolveOrgFromHeader } from "../scope-LONQ3mfZ.mjs";
5
5
  export { AUTHENTICATED_SCOPE, PUBLIC_SCOPE, createTenantKeyGenerator, elevation_default as elevationPlugin, elevationPlugin as elevationPluginFn, getAncestorOrgIds, getClientId, getDPoPJkt, getMandate, getOrgContext, getOrgId, getOrgRoles, getRequestScope, getScopeContext, getScopeContextMap, getServiceScopes, getTeamId, getTenantFromRequest, getUserId, getUserRoles, hasOrgAccess, isAuthenticated, isElevated, isMember, isOrgInScope, isService, requireClientId, requireOrgId, requireTeamId, requireUserId, resolveOrgFromHeader };
@@ -0,0 +1,37 @@
1
+ import { a as OrgAccessDeniedError, l as UnauthorizedError, u as ValidationError } from "./errors-DkO8Nk6-.mjs";
2
+ import { t as getUserRoles } from "./types-XTApa8b6.mjs";
3
+ //#region src/scope/resolveOrgFromHeader.ts
4
+ /**
5
+ * Create a preHandler hook that resolves org scope from a header.
6
+ * Must run AFTER authentication so `request.user` is populated.
7
+ * If the header is present and user is a member, sets `request.scope` to `member`.
8
+ * If the header is absent, scope stays as-is (typically `authenticated`).
9
+ */
10
+ function resolveOrgFromHeader(options) {
11
+ const { header = "x-organization-id", resolveMembership } = options;
12
+ return async (request, _reply) => {
13
+ const raw = request.headers[header.toLowerCase()];
14
+ if (Array.isArray(raw)) throw new ValidationError(`Duplicate '${header}' header`);
15
+ const orgId = raw;
16
+ if (!orgId) return;
17
+ const scope = request.scope;
18
+ if (!scope || scope.kind === "public") throw new UnauthorizedError("Authentication required for organization access");
19
+ if (scope.kind === "elevated") return;
20
+ const user = request.user;
21
+ if (!user) throw new UnauthorizedError("Authentication required for organization access");
22
+ const userId = String(user.id ?? user._id ?? "");
23
+ if (!userId) throw new UnauthorizedError("User identity required for organization access");
24
+ const membership = await resolveMembership(userId, orgId);
25
+ if (!membership) throw new OrgAccessDeniedError("Not a member of this organization");
26
+ const carriedRoles = "userRoles" in scope && Array.isArray(scope.userRoles) ? scope.userRoles : void 0;
27
+ request.scope = {
28
+ kind: "member",
29
+ userId: userId || void 0,
30
+ userRoles: carriedRoles?.length ? carriedRoles : getUserRoles(user),
31
+ organizationId: orgId,
32
+ orgRoles: membership.roles
33
+ };
34
+ };
35
+ }
36
+ //#endregion
37
+ export { resolveOrgFromHeader as t };
@@ -0,0 +1,68 @@
1
+ //#region src/utils/simpleEqualityMatcher.ts
2
+ /**
3
+ * `simpleEqualityMatcher` — a minimal, dialect-agnostic flat-key equality
4
+ * matcher for `DataAdapter.matchesFilter` / `BaseController({ matchesFilter })`.
5
+ *
6
+ * **What it does:** for each `[key, expected]` in the filter, compares
7
+ * `item[key]` to `expected` via string coercion (so Mongo `ObjectId` values
8
+ * match their string representation) and returns `true` only if every
9
+ * filter entry matches. Array item values are matched implicitly (contains).
10
+ *
11
+ * **What it does NOT do:**
12
+ * - No `$eq` / `$ne` / `$in` / `$nin` / `$gt` / `$lt` / `$regex` / `$exists`
13
+ * - No `$and` / `$or`
14
+ * - No dot-path traversal (`"owner.id"`)
15
+ * - No schema-specific coercion
16
+ *
17
+ * **Why it exists:** 95%+ of arc's `_policyFilters` are produced by built-in
18
+ * permission helpers and are shaped like `{ ownerId: "u1" }` or
19
+ * `{ organizationId: "org_x" }` — flat equality. For that common shape,
20
+ * this helper is a safe, tested, 15-line defense-in-depth matcher that
21
+ * hosts using minimal repos (no `getOne(compoundFilter)` DB path) can opt
22
+ * into without arc shipping a full Mongo-syntax engine.
23
+ *
24
+ * **When to use:**
25
+ * - Your adapter/repo doesn't natively filter on `getOne(compoundFilter)`
26
+ * - Your `_policyFilters` are flat equality (from arc's built-in permission helpers)
27
+ * - You want defense-in-depth on `validateItemAccess` / `fetchDetailed`'s `getById` fallback
28
+ *
29
+ * **When NOT to use:**
30
+ * - Your `_policyFilters` use operators (`$in`, `$ne`, etc.) — supply a
31
+ * native matcher (mongokit's repo does the filter at the DB layer; for
32
+ * custom repos, wrap the kit's own predicate engine).
33
+ * - You're a mongokit / sqlitekit / Prisma user — the DB-level filter
34
+ * applied by `getOne(compoundFilter)` already covers this.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { simpleEqualityMatcher } from '@classytic/arc/utils';
39
+ *
40
+ * // On a custom adapter
41
+ * const adapter: DataAdapter = {
42
+ * repository,
43
+ * type: 'custom',
44
+ * name: 'in-memory',
45
+ * matchesFilter: simpleEqualityMatcher,
46
+ * };
47
+ *
48
+ * // Or directly on BaseController for ad-hoc controllers
49
+ * new BaseController(repo, { matchesFilter: simpleEqualityMatcher });
50
+ * ```
51
+ */
52
+ function simpleEqualityMatcher(item, filters) {
53
+ if (!item || typeof item !== "object") return false;
54
+ const obj = item;
55
+ for (const [key, expected] of Object.entries(filters)) {
56
+ if (expected && typeof expected === "object" && !Array.isArray(expected) && Object.getPrototypeOf(expected) === Object.prototype && Object.keys(expected).some((k) => k.startsWith("$"))) return false;
57
+ const actual = obj[key];
58
+ if (Array.isArray(actual)) {
59
+ const expectedStr = String(expected);
60
+ if (!actual.some((v) => String(v) === expectedStr)) return false;
61
+ continue;
62
+ }
63
+ if (String(actual) !== String(expected)) return false;
64
+ }
65
+ return true;
66
+ }
67
+ //#endregion
68
+ export { simpleEqualityMatcher as t };
@@ -1,7 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { c as getOrgId, n as PUBLIC_SCOPE } from "./types-bHblZ9YN.mjs";
4
- import { a as promoteStreamTokenToHeader, n as forwardedStreamHeaders } from "./streaming-BQcW6_Kq.mjs";
4
+ import { a as promoteStreamTokenToHeader } from "./streaming-BQcW6_Kq.mjs";
5
+ import { t as openSseStream } from "./sseStream-BQ7Qaynp.mjs";
5
6
  import fp from "fastify-plugin";
6
7
  //#region src/plugins/sse.ts
7
8
  var sse_exports = /* @__PURE__ */ __exportAll({
@@ -29,16 +30,8 @@ const ssePlugin = async (fastify, opts = {}) => {
29
30
  } }
30
31
  },
31
32
  handler: async (request, reply) => {
32
- reply.hijack();
33
- reply.raw.writeHead(200, {
34
- "content-type": "text/event-stream",
35
- "cache-control": "no-cache",
36
- connection: "keep-alive",
37
- "x-accel-buffering": "no",
38
- ...forwardedStreamHeaders(reply)
39
- });
40
- reply.raw.flushHeaders();
41
- const unsubscribers = [];
33
+ const stream = openSseStream(request, reply, { heartbeatMs: heartbeat });
34
+ stream.onCleanup(() => activeConnections.delete(stream.close));
42
35
  const requestOrgId = getOrgId(request.scope ?? PUBLIC_SCOPE);
43
36
  const dropOrgEvents = orgScoped && !requestOrgId;
44
37
  for (const pattern of patterns) {
@@ -57,27 +50,11 @@ const ssePlugin = async (fastify, opts = {}) => {
57
50
  timestamp: event.meta.timestamp
58
51
  }
59
52
  });
60
- if (!reply.raw.write(`event: ${event.type}\ndata: ${data}\n\n`)) {
61
- request.raw.destroy(/* @__PURE__ */ new Error("SSE connection terminated: slow client backpressure"));
62
- cleanup();
63
- }
53
+ stream.write(event.type, data);
64
54
  });
65
- unsubscribers.push(unsub);
55
+ stream.onCleanup(unsub);
66
56
  }
67
- const heartbeatTimer = setInterval(() => {
68
- if (!reply.raw.write(": heartbeat\n\n")) {
69
- request.raw.destroy(/* @__PURE__ */ new Error("SSE connection terminated: heartbeat backpressure"));
70
- cleanup();
71
- }
72
- }, heartbeat);
73
- const cleanup = () => {
74
- clearInterval(heartbeatTimer);
75
- for (const unsub of unsubscribers) unsub();
76
- if (!reply.raw.writableEnded) reply.raw.end();
77
- activeConnections.delete(cleanup);
78
- };
79
- activeConnections.add(cleanup);
80
- request.raw.on("close", cleanup);
57
+ activeConnections.add(stream.close);
81
58
  }
82
59
  };
83
60
  if (requireAuth) {
@@ -0,0 +1,57 @@
1
+ import { n as forwardedStreamHeaders } from "./streaming-BQcW6_Kq.mjs";
2
+ //#region src/utils/sseStream.ts
3
+ /**
4
+ * Take over the reply socket and open an SSE stream. Call ONLY after every
5
+ * gate (auth, permissions, validation) has passed — after `hijack()` no
6
+ * JSON error can be sent.
7
+ */
8
+ function openSseStream(request, reply, options = {}) {
9
+ const { heartbeatMs = 3e4, retryMs } = options;
10
+ reply.hijack();
11
+ reply.raw.writeHead(200, {
12
+ "content-type": "text/event-stream",
13
+ "cache-control": "no-cache",
14
+ connection: "keep-alive",
15
+ "x-accel-buffering": "no",
16
+ ...forwardedStreamHeaders(reply)
17
+ });
18
+ reply.raw.flushHeaders();
19
+ if (retryMs !== void 0) reply.raw.write(`retry: ${retryMs}\n\n`);
20
+ const cleanups = [];
21
+ let closed = false;
22
+ const close = () => {
23
+ if (closed) return;
24
+ closed = true;
25
+ clearInterval(heartbeatTimer);
26
+ for (const fn of cleanups) try {
27
+ fn();
28
+ } catch {}
29
+ if (!reply.raw.writableEnded) reply.raw.end();
30
+ };
31
+ const destroyOnBackpressure = (context) => {
32
+ request.raw.destroy(/* @__PURE__ */ new Error(`SSE connection terminated: ${context} backpressure`));
33
+ close();
34
+ };
35
+ const heartbeatTimer = setInterval(() => {
36
+ if (closed) return;
37
+ if (!reply.raw.write(": heartbeat\n\n")) destroyOnBackpressure("heartbeat");
38
+ }, heartbeatMs);
39
+ request.raw.on("close", close);
40
+ return {
41
+ write(eventName, data, id) {
42
+ if (closed) return false;
43
+ const idField = id !== void 0 ? `id: ${id}\n` : "";
44
+ if (!reply.raw.write(`${idField}event: ${eventName}\ndata: ${data}\n\n`)) {
45
+ destroyOnBackpressure("slow client");
46
+ return false;
47
+ }
48
+ return true;
49
+ },
50
+ close,
51
+ onCleanup(fn) {
52
+ cleanups.push(fn);
53
+ }
54
+ };
55
+ }
56
+ //#endregion
57
+ export { openSseStream as t };
@@ -1,6 +1,6 @@
1
- import { V as ResourceDefinition, qt as AnyRecord } from "../index-DbtgCOCj.mjs";
1
+ import { Jt as AnyRecord, V as ResourceDefinition } from "../index-BBsvQt7U.mjs";
2
2
  import { n as ArcModuleInput } from "../module-B4u10iOJ.mjs";
3
- import { d as preloadResourcesAsync, t as CreateAppOptions, u as preloadResources } from "../index-BEu4wjbL.mjs";
3
+ import { d as preloadResourcesAsync, t as CreateAppOptions, u as preloadResources } from "../index-Ba10fdKp.mjs";
4
4
  import { StorageContractSetup, StorageContractSetupResult, runStorageContract } from "./storageContract.mjs";
5
5
  import { FastifyInstance, FastifyServerOptions } from "fastify";
6
6
  import { Mock } from "vitest";