@classytic/arc 2.28.0 → 2.30.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 (83) hide show
  1. package/dist/{BaseController-B65zbz9m.mjs → BaseController-B5DwufbB.mjs} +12 -10
  2. package/dist/EventTransport-C6YA8xY9.mjs +103 -0
  3. package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-WkIougo6.mjs} +15 -35
  4. package/dist/applyPermissionResult-Cf6zUCvb.mjs +257 -0
  5. package/dist/audit/index.d.mts +2 -2
  6. package/dist/audit/index.mjs +1 -1
  7. package/dist/auth/index.d.mts +2 -2
  8. package/dist/auth/index.mjs +1 -1
  9. package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-BYS3Vkfz.mjs} +32 -6
  10. package/dist/cleanup/index.d.mts +2 -2
  11. package/dist/cleanup/index.mjs +1 -1
  12. package/dist/cli/commands/describe.d.mts +1 -1
  13. package/dist/cli/commands/docs.mjs +1 -1
  14. package/dist/core/index.d.mts +2 -2
  15. package/dist/core/index.mjs +5 -4
  16. package/dist/{core-BGa8TXJR.mjs → core-poalJLvp.mjs} +2 -48
  17. package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-BxuywVRB.mjs} +2 -2
  18. package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-BiY-lGdY.mjs} +7 -20
  19. package/dist/{createApp-Du5G54TM.mjs → createApp-CXrKFciH.mjs} +24 -3
  20. package/dist/defineAggregation-ufWjUwml.mjs +47 -0
  21. package/dist/{defineResource-2zvVVpxg.mjs → defineResource-CxWqDCAy.mjs} +35 -42
  22. package/dist/docs/index.d.mts +1 -1
  23. package/dist/docs/index.mjs +1 -1
  24. package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-D2NNR4py.mjs} +2 -103
  25. package/dist/events/index.mjs +4 -339
  26. package/dist/factory/index.d.mts +4 -4
  27. package/dist/factory/index.mjs +1 -1
  28. package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-0WhPnUH7.d.mts} +3 -3
  29. package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CQ_7KQWw.mjs} +1 -107
  30. package/dist/hooks/index.d.mts +1 -1
  31. package/dist/hooks/index.mjs +1 -1
  32. package/dist/idempotency/index.mjs +1 -1
  33. package/dist/{index-CZY4EGga.d.mts → index-CCzVR-N3.d.mts} +1 -1
  34. package/dist/{index-BMnbzvtO.d.mts → index-Ch16aAah.d.mts} +201 -21
  35. package/dist/{index-CjVg-Epg.d.mts → index-Cx7bro4R.d.mts} +4 -4
  36. package/dist/{index-DPRH0Cme.d.mts → index-Q5gzEIWY.d.mts} +97 -66
  37. package/dist/index.d.mts +6 -6
  38. package/dist/index.mjs +8 -7
  39. package/dist/integrations/index.d.mts +1 -1
  40. package/dist/integrations/mcp/index.d.mts +6 -6
  41. package/dist/integrations/mcp/index.mjs +1 -1
  42. package/dist/integrations/mcp/testing.d.mts +1 -1
  43. package/dist/integrations/mcp/testing.mjs +1 -1
  44. package/dist/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-nRzm5g2s.mjs} +36 -43
  45. package/dist/middleware/index.d.mts +1 -1
  46. package/dist/migrations/index.d.mts +0 -3
  47. package/dist/migrations/index.mjs +0 -3
  48. package/dist/{openapi-BLV4OMXb.mjs → openapi-B8Vkbxbn.mjs} +1 -1
  49. package/dist/permissions/index.d.mts +3 -3
  50. package/dist/permissions/index.mjs +4 -3
  51. package/dist/{permissions-BrtiBKGs.mjs → permissions-Bfu_8y1U.mjs} +318 -289
  52. package/dist/pipeline/index.d.mts +1 -1
  53. package/dist/{pipeline-BGRnV-To.mjs → pipeline-BJnpkY9i.mjs} +4 -18
  54. package/dist/plugins/index.d.mts +4 -4
  55. package/dist/plugins/index.mjs +2 -2
  56. package/dist/plugins/tracing-entry.mjs +1 -1
  57. package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-DCfa2egz.d.mts} +3 -3
  58. package/dist/presets/filesUpload.d.mts +2 -2
  59. package/dist/presets/filesUpload.mjs +1 -1
  60. package/dist/presets/index.d.mts +14 -2
  61. package/dist/presets/index.mjs +1 -1
  62. package/dist/presets/multiTenant.d.mts +1 -1
  63. package/dist/presets/search.d.mts +2 -2
  64. package/dist/presets/search.mjs +1 -1
  65. package/dist/{presets-DwFDVKP0.mjs → presets-CJOEXLuk.mjs} +25 -9
  66. package/dist/projection-UBDgfMwu.mjs +17 -0
  67. package/dist/{realtime-DEuDu9io.mjs → realtime-B9nmIY5V.mjs} +2 -1
  68. package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-JkxIEBJq.mjs} +3 -1
  69. package/dist/registry/index.d.mts +2 -2
  70. package/dist/registry/index.mjs +1 -1
  71. package/dist/{registry-9km_w1aP.mjs → registry-BYXUUg21.mjs} +1 -1
  72. package/dist/repositoryFilter-Dz8k3S4P.mjs +44 -0
  73. package/dist/subscribe-helpers-DfzsivM6.mjs +339 -0
  74. package/dist/testing/index.d.mts +34 -6
  75. package/dist/testing/index.mjs +314 -211
  76. package/dist/types/index.d.mts +3 -3
  77. package/dist/{types-QFGc3Cjv.d.mts → types-Bmmn-fO0.d.mts} +1 -1
  78. package/dist/{types-Dmw-6Qeo.d.mts → types-ChvvVNP8.d.mts} +93 -88
  79. package/dist/{types-Cu6vVJmh.d.mts → types-D_n8L6VY.d.mts} +32 -1
  80. package/dist/usage/index.d.mts +1 -1
  81. package/dist/usage/index.mjs +0 -0
  82. package/dist/utils/index.d.mts +2 -5
  83. package/package.json +539 -539
@@ -95,6 +95,56 @@ declare function getUserRoles(user: {
95
95
  } | null | undefined): string[];
96
96
  //#endregion
97
97
  //#region src/permissions/types.d.ts
98
+ /**
99
+ * Row-level data policy — the constraint every returned/queried row must
100
+ * satisfy. This is arc's **Mongo-style record dialect** (`{ ownerId }`,
101
+ * `{ $or: [...] }`, `{ status: { $ne: "archived" } }`), the ONE canonical
102
+ * internal representation every permission helper emits and every enforcement
103
+ * seam composes (`conjoinPolicyFilters`).
104
+ *
105
+ * It is compiled to the portable repo-core `Filter` IR at the repository
106
+ * boundary (`core/repositoryFilter.ts` → `policyRecordToFilter`) so it runs
107
+ * identically on Mongo / SQL / any kit — arc's answer to oso-style "data
108
+ * filtering" + Postgres RLS, but datastore-portable and in-process. The IR is a
109
+ * boundary/compile concern, NOT the decision surface: keeping the decision in
110
+ * one record dialect means composition (`allOf`/`anyOf`/`conjoin`) has a single
111
+ * well-defined shape instead of a `Filter | Record` union the composer can't
112
+ * honor.
113
+ */
114
+ type DataPolicy = Record<string, unknown>;
115
+ /**
116
+ * The canonical authorization decision (arc 2.30). A single typed value that
117
+ * cleanly separates the concerns a permission check expresses:
118
+ *
119
+ * - `effect` — allow / deny (Cedar / XACML decision)
120
+ * - `reason` — human-readable diagnostic for the denial (explainability)
121
+ * - `policy` — row-level {@link DataPolicy} to enforce at the repository
122
+ * - `scope` — identity/context to install on the request (PIP)
123
+ *
124
+ * Build one with the `allow()` / `deny()` constructors. A check may also return
125
+ * a bare `boolean` (terse allow/deny), normalized via `normalizeToDecision`.
126
+ *
127
+ * NOTE: arc deliberately does NOT model XACML-style "obligations" on the
128
+ * decision. Audit and field redaction are first-class arc subsystems already
129
+ * (`audit: true` on a resource; field-level read/write permissions), so an
130
+ * obligations channel would be a second, unenforced way to ask for the same
131
+ * effects. If a future need arises it will ship WITH a dispatcher that every
132
+ * enforcement surface runs — never as a declared-but-ignored contract.
133
+ */
134
+ interface AuthorizationDecision {
135
+ /** Allow or deny. */
136
+ effect: "allow" | "deny";
137
+ /** Human-readable denial reason (surfaced in error messages / explain). */
138
+ reason?: string;
139
+ /** Row-level data policy to enforce at the repository layer. */
140
+ policy?: DataPolicy;
141
+ /**
142
+ * Scope to install on `request.scope` when allowed — flows to
143
+ * `metadata._scope` for tenant-field filtering. The clean integration point
144
+ * for custom auth (API keys, service accounts, gateway headers).
145
+ */
146
+ scope?: RequestScope;
147
+ }
98
148
  /**
99
149
  * User base interface - minimal shape Arc expects
100
150
  * Your actual User can have any additional fields
@@ -107,25 +157,33 @@ interface UserBase {
107
157
  [key: string]: unknown;
108
158
  }
109
159
  /**
110
- * Extract normalized roles from a user object.
160
+ * Context passed to permission check functions.
111
161
  *
112
- * Reads `user.role` which can be:
113
- * - A comma-separated string: `"superadmin,user"` (Better Auth admin plugin)
114
- * - A string array: `["admin", "user"]` (JWT / custom auth)
115
- * - A single string: `"admin"`
116
- */
117
- /**
118
- * Normalize a raw role value (string, comma-separated string, or array) into a string[].
119
- * Shared low-level helper used by both getUserRoles() and the Better Auth adapter.
120
- */
121
- /**
122
- * Context passed to permission check functions
162
+ * arc 2.30: checks read FACTS off this context — never off a raw request. Use
163
+ * `scopeOf(ctx)` (from `./context.js`) to read the scope; it prefers the
164
+ * first-class {@link PermissionContext.scope} and lets combinators thread an
165
+ * updated scope to child checks WITHOUT mutating the request. `request` is a
166
+ * transport escape hatch (present for HTTP; absent for MCP/jobs/ws) and is being
167
+ * retired as the primary channel.
123
168
  */
124
169
  interface PermissionContext<TDoc = Record<string, unknown>> {
125
170
  /** Authenticated user or null if unauthenticated */
126
171
  user: UserBase | null;
127
- /** Fastify request object */
128
- request: FastifyRequest;
172
+ /**
173
+ * Resolved request scope (identity + tenant/org context). First-class so
174
+ * combinators can thread an accumulated scope purely and non-HTTP transports
175
+ * need no request. Read via `scopeOf(ctx)`.
176
+ */
177
+ scope?: RequestScope;
178
+ /**
179
+ * Raw Fastify request — transport escape hatch, present ONLY on the HTTP
180
+ * surface. It is `undefined` for MCP, jobs, and any non-Fastify transport, so
181
+ * a permission check must NOT depend on it: read identity via `scopeOf(ctx)`,
182
+ * inputs via `ctx.data` / `ctx.params`, and host facts via `ctx.attributes`.
183
+ * Kept solely for genuinely HTTP-coupled checks (e.g. a usage meter on
184
+ * `request.server`) which are inherently transport-specific.
185
+ */
186
+ request?: FastifyRequest;
129
187
  /** Resource name being accessed */
130
188
  resource: string;
131
189
  /** Action being performed (list, get, create, update, delete, or custom operation name) */
@@ -136,93 +194,40 @@ interface PermissionContext<TDoc = Record<string, unknown>> {
136
194
  params?: Record<string, string>;
137
195
  /** Request body data */
138
196
  data?: Partial<TDoc> | Record<string, unknown>;
139
- }
140
- /**
141
- * Result from a permission check.
142
- *
143
- * Permission checks can do three things:
144
- * 1. **Grant or deny** access (`granted`, `reason`)
145
- * 2. **Attach row-level filters** (`filters`) — these merge into `_policyFilters`
146
- * and narrow subsequent queries (e.g. `{ userId: ctx.user.id }` for ownership)
147
- * 3. **Install the request scope** (`scope`) — when a custom authenticator wants
148
- * to set tenant/identity context directly from the permission layer, without
149
- * relying on a separate auth plugin
150
- *
151
- * The `scope` field is the clean integration point for custom auth strategies
152
- * (API keys, service accounts, gateway headers). When present, Arc writes it to
153
- * `request.scope` which then flows through the normal tenant-filtering pipeline
154
- * (QueryResolver + AccessControl). This is the idiomatic way to wire non-Better-Auth
155
- * identity providers into Arc's multi-tenancy without touching the auth plugin layer.
156
- *
157
- * @example
158
- * ```typescript
159
- * // Custom API-key auth — grant access AND install a service scope in one step
160
- * export function requireApiKey(): PermissionCheck {
161
- * return async ({ request }) => {
162
- * const apiKey = request.headers['x-api-key'] as string | undefined;
163
- * if (!apiKey) return { granted: false, reason: 'Missing API key' };
164
- *
165
- * const client = await ClientModel.findOne({ apiKey });
166
- * if (!client) return { granted: false, reason: 'Invalid API key' };
167
- *
168
- * return {
169
- * granted: true,
170
- * // Install service scope — Arc writes this to request.scope automatically,
171
- * // and tenantField filtering picks it up via metadata._scope
172
- * scope: {
173
- * kind: 'service',
174
- * clientId: String(client._id),
175
- * organizationId: String(client.companyId),
176
- * scopes: client.allowedScopes,
177
- * },
178
- * // Optional row-level narrowing (e.g. per-project API keys)
179
- * filters: client.projectId ? { projectId: client.projectId } : undefined,
180
- * };
181
- * };
182
- * }
183
- * ```
184
- */
185
- interface PermissionResult {
186
- /** Whether access is granted */
187
- granted: boolean;
188
- /** Reason for denial (for error messages) */
189
- reason?: string;
190
- /** Query filters to apply (for ownership / row-level security patterns) */
191
- filters?: Record<string, unknown>;
192
197
  /**
193
- * Install this scope on `request.scope` when granted. Flows through to
194
- * `metadata._scope` and is read by QueryResolver / AccessControl for
195
- * tenant-field filtering. Use this to wire custom auth (API keys, service
196
- * accounts, gateway headers) into Arc's multi-tenancy without a separate
197
- * auth plugin.
198
+ * Transport/host-supplied attributes a check may need for ABAC decisions
199
+ * (headers, claims, feature flags) without reaching into the raw request.
198
200
  */
199
- scope?: RequestScope;
201
+ attributes?: Readonly<Record<string, unknown>>;
200
202
  }
201
203
  /**
202
- * Permission Check Function
204
+ * Permission Check Function — THE way to define permissions in Arc.
203
205
  *
204
- * THE ONLY way to define permissions in Arc.
205
- * Returns boolean, PermissionResult, or Promise of either.
206
+ * Returns a `boolean` (terse allow/deny) or an {@link AuthorizationDecision}
207
+ * (effect + optional data `policy` and `scope`). Build decisions with the
208
+ * `allow()` / `deny()` constructors from `@classytic/arc/permissions`.
206
209
  *
207
210
  * @example
208
211
  * ```typescript
209
- * // Simple boolean return
212
+ * // Simple boolean
210
213
  * const isAdmin: PermissionCheck = (ctx) => getUserRoles(ctx.user).includes('admin');
211
214
  *
212
- * // With filters for ownership
213
- * const ownedByUser: PermissionCheck = (ctx) => ({
214
- * granted: true,
215
- * filters: { userId: ctx.user?.id }
216
- * });
215
+ * // Grant + row-level data policy (ownership)
216
+ * const ownedByUser: PermissionCheck = (ctx) =>
217
+ * allow({ policy: { userId: ctx.user?.id } });
217
218
  *
218
- * // Async check
219
- * const canAccessOrg: PermissionCheck = async (ctx) => {
220
- * const isMember = await checkMembership(ctx.user?.id, ctx.organizationId);
221
- * return { granted: isMember, reason: isMember ? undefined : 'Not a member' };
219
+ * // Custom API-key auth — grant AND install a service scope
220
+ * const requireApiKey: PermissionCheck = async ({ request }) => {
221
+ * const client = await lookup(request.headers['x-api-key']);
222
+ * if (!client) return deny('Invalid API key');
223
+ * return allow({
224
+ * scope: { kind: 'service', clientId: String(client._id), organizationId: String(client.companyId) },
225
+ * ...(client.projectId ? { policy: { projectId: client.projectId } } : {}),
226
+ * });
222
227
  * };
223
228
  * ```
224
229
  */
225
- type PermissionCheck<TDoc = Record<string, unknown>> = ((context: PermissionContext<TDoc>) => boolean | PermissionResult | Promise<boolean | PermissionResult>) & PermissionCheckMeta;
230
+ type PermissionCheck<TDoc = Record<string, unknown>> = ((context: PermissionContext<TDoc>) => boolean | AuthorizationDecision | Promise<boolean | AuthorizationDecision>) & PermissionCheckMeta;
226
231
  /**
227
232
  * Optional metadata attached to permission check functions.
228
233
  * Used for OpenAPI data, introspection, and route-level auth decisions.
@@ -287,4 +292,4 @@ interface PermissionCheckMeta {
287
292
  };
288
293
  }
289
294
  //#endregion
290
- export { getUserId as a, AnyRecord as c, ObjectId as d, UserLike as f, UserBase as i, ArcRequest as l, PermissionContext as n, getUserRoles as o, UserOrganization as p, PermissionResult as r, normalizeRoles as s, PermissionCheck as t, JWTPayload as u };
295
+ export { UserBase as a, normalizeRoles as c, JWTPayload as d, ObjectId as f, PermissionContext as i, AnyRecord as l, UserOrganization as m, DataPolicy as n, getUserId as o, UserLike as p, PermissionCheck as r, getUserRoles as s, AuthorizationDecision as t, ArcRequest as u };
@@ -1,4 +1,4 @@
1
- import { r as EventHandler } from "./EventTransport-QmY7A258.mjs";
1
+ import { n as DomainEvent, r as EventHandler } from "./EventTransport-QmY7A258.mjs";
2
2
  import { n as ErrorMapper } from "./errorHandler-C6yv4ZTW.mjs";
3
3
  import { FastifyInstance, FastifyPluginAsync } from "fastify";
4
4
  import { LockAdapter } from "@classytic/repo-core/lock";
@@ -343,6 +343,37 @@ interface EventHandlerDefinition {
343
343
  * `inventory.order-created`; Arc does not silently rewrite public names.
344
344
  */
345
345
  name?: string;
346
+ /**
347
+ * Contain handler failures inside an error boundary — arc wraps `handler`
348
+ * with `wrapWithBoundary`, so a throw is logged (through `fastify.log`, with
349
+ * `{ err, event, eventId, handler }`) and swallowed instead of reaching the
350
+ * transport.
351
+ *
352
+ * **Off by default, deliberately.** Reaching the transport is what makes the
353
+ * durable path work: `RedisStreamTransport` leaves the message unacked, so it
354
+ * is redelivered and eventually DLQ'd. Silently swallowing by default would
355
+ * turn every module handler into best-effort delivery.
356
+ *
357
+ * Turn it ON for fire-and-forget work — projections, cache invalidation,
358
+ * notification fan-out — where a failure must not block the ack and a retry
359
+ * would only delay the next event's resync. That is precisely the case where
360
+ * a module author would otherwise abandon this arm for an imperative
361
+ * `subscribeWithBoundary` call in `afterResources` and lose arc's
362
+ * teardown-before-`onClose` guarantee.
363
+ *
364
+ * ```ts
365
+ * defineModule({ name: "search", eventHandlers: [
366
+ * { name: "search.reindex", event: "product:*", handler: reindex, boundary: true },
367
+ * ] })
368
+ * ```
369
+ *
370
+ * The object form adds an `onError` sink (metrics / alerting) in place of the
371
+ * default log; it runs INSIDE the boundary, so a throwing `onError` is itself
372
+ * caught and logged rather than escaping.
373
+ */
374
+ boundary?: boolean | {
375
+ onError?: (error: Error, event: DomainEvent) => void | Promise<void>;
376
+ };
346
377
  }
347
378
  interface ArcModule<TExports = unknown> {
348
379
  /** Stable identifier — appears in boot logs and duplicate-module detection. */
@@ -1,5 +1,5 @@
1
1
  import { i as RequestScope } from "../types-DjC-KUCz.mjs";
2
- import { n as PermissionContext, t as PermissionCheck } from "../types-Dmw-6Qeo.mjs";
2
+ import { i as PermissionContext, r as PermissionCheck } from "../types-ChvvVNP8.mjs";
3
3
  import { UsageBucket, UsageStore, usagePeriod } from "@classytic/repo-core/usage";
4
4
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
5
5
  //#region src/usage/requireQuota.d.ts
Binary file
@@ -1,6 +1,6 @@
1
- import { $ as OpenApiSchemas, S as QueryParserInterface, b as ParsedQuery } from "../index-DPRH0Cme.mjs";
1
+ import { $ as OpenApiSchemas, S as QueryParserInterface, b as ParsedQuery } from "../index-Q5gzEIWY.mjs";
2
2
  import { F as getHeader, I as requireSingleHeaderValue, P as ArcHeaders } from "../types-DjC-KUCz.mjs";
3
- import { a as getUserId, c as AnyRecord, o as getUserRoles, s as normalizeRoles } from "../types-Dmw-6Qeo.mjs";
3
+ import { c as normalizeRoles, l as AnyRecord, o as getUserId, s as getUserRoles } from "../types-ChvvVNP8.mjs";
4
4
  import { n as ErrorMapper } from "../errorHandler-C6yv4ZTW.mjs";
5
5
  import { a as NotFoundError, c as RateLimitError, d as ValidationError, f as createDomainError, i as ForbiddenError, l as ServiceUnavailableError, m as isArcError, n as ConflictError, o as OrgAccessDeniedError, p as createError, r as ErrorOptions, s as OrgRequiredError, t as ArcError, u as UnauthorizedError } from "../errors-DHcHVAgk.mjs";
6
6
  import { errorContractSchema, errorDetailSchema } from "@classytic/repo-core/errors";
@@ -1313,9 +1313,6 @@ interface StateMachine {
1313
1313
  * Record a transition
1314
1314
  */
1315
1315
  recordTransition?(from: string, to: string, action: string, metadata?: Record<string, unknown>): void;
1316
- /**
1317
- * Clear history
1318
- */
1319
1316
  clearHistory?(): void;
1320
1317
  /**
1321
1318
  * Get available actions for current status