@classytic/arc 2.8.5 → 2.10.3

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 (155) hide show
  1. package/README.md +50 -38
  2. package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-CbKKIflT.mjs} +193 -143
  3. package/dist/EventTransport-CUw5NNWe.d.mts +293 -0
  4. package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-BPd6NQDm.mjs} +1 -1
  5. package/dist/adapters/index.d.mts +3 -3
  6. package/dist/adapters/index.mjs +2 -2
  7. package/dist/{adapters-BBqAVvPK.mjs → adapters-BXY4i-hw.mjs} +210 -41
  8. package/dist/audit/index.d.mts +135 -11
  9. package/dist/audit/index.mjs +107 -20
  10. package/dist/auth/index.d.mts +17 -9
  11. package/dist/auth/index.mjs +14 -7
  12. package/dist/auth/redis-session.d.mts +1 -1
  13. package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi-BBRVhjQN.mjs} +1 -1
  14. package/dist/cache/index.d.mts +17 -15
  15. package/dist/cache/index.mjs +15 -14
  16. package/dist/{caching-IMuYVjTL.mjs → caching-CBpK_SCM.mjs} +8 -3
  17. package/dist/cli/commands/describe.mjs +1 -1
  18. package/dist/cli/commands/docs.mjs +2 -2
  19. package/dist/cli/commands/generate.mjs +1 -1
  20. package/dist/cli/commands/init.mjs +1 -1
  21. package/dist/cli/commands/introspect.mjs +1 -1
  22. package/dist/core/index.d.mts +3 -3
  23. package/dist/core/index.mjs +4 -6
  24. package/dist/{defineResource-tcgySDo1.mjs → core-CcR01lup.mjs} +58 -61
  25. package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-Bp_5c_2b.mjs} +3 -3
  26. package/dist/{createApp-B1EY8zxa.mjs → createApp-BuvPma24.mjs} +15 -14
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +2 -2
  29. package/dist/{elevation-DtFxrG0s.mjs → elevation-C7hgL_aI.mjs} +22 -8
  30. package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-Bb49BvPD.mjs} +59 -7
  31. package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DRQ3EqfL.d.mts} +37 -2
  32. package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-CxWgpd6K.d.mts} +14 -2
  33. package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-DCUjuiQT.mjs} +83 -5
  34. package/dist/events/index.d.mts +150 -36
  35. package/dist/events/index.mjs +355 -101
  36. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  37. package/dist/events/transports/redis.d.mts +1 -1
  38. package/dist/factory/index.d.mts +1 -1
  39. package/dist/factory/index.mjs +2 -2
  40. package/dist/{types-DZi1aYhm.d.mts → fields-Lo1VUDpt.d.mts} +121 -1
  41. package/dist/{fields-ipsbIRPK.mjs → fields-bxkeltzz.mjs} +18 -5
  42. package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-t21LS-py.mjs} +65 -7
  43. package/dist/hooks/index.d.mts +1 -1
  44. package/dist/hooks/index.mjs +1 -1
  45. package/dist/idempotency/index.d.mts +32 -5
  46. package/dist/idempotency/index.mjs +119 -12
  47. package/dist/idempotency/redis.d.mts +1 -1
  48. package/dist/{index-DtDzOBn8.d.mts → index-8qw4y6ff.d.mts} +4 -135
  49. package/dist/{index-BLXBmWud.d.mts → index-ChIw3776.d.mts} +283 -408
  50. package/dist/{interface-CMRutPfe.d.mts → index-Cl0uoKd5.d.mts} +1758 -2506
  51. package/dist/{index-C1meYuDn.d.mts → index-DStwgFUK.d.mts} +81 -7
  52. package/dist/index.d.mts +7 -8
  53. package/dist/index.mjs +11 -12
  54. package/dist/integrations/event-gateway.d.mts +1 -1
  55. package/dist/integrations/event-gateway.mjs +1 -1
  56. package/dist/integrations/index.d.mts +1 -1
  57. package/dist/integrations/mcp/index.d.mts +26 -8
  58. package/dist/integrations/mcp/index.mjs +96 -17
  59. package/dist/integrations/mcp/testing.d.mts +1 -1
  60. package/dist/integrations/mcp/testing.mjs +1 -1
  61. package/dist/integrations/webhooks.d.mts +5 -0
  62. package/dist/integrations/webhooks.mjs +6 -0
  63. package/dist/interface-D218ikEo.d.mts +77 -0
  64. package/dist/{memory-Cp7_cAko.mjs → memory-B5Amv9A1.mjs} +23 -8
  65. package/dist/{openapi-CbKUJY_m.mjs → openapi-B5F8AddX.mjs} +3 -3
  66. package/dist/org/index.d.mts +2 -2
  67. package/dist/permissions/index.d.mts +3 -4
  68. package/dist/permissions/index.mjs +5 -5
  69. package/dist/{permissions-CH4cNwJi.mjs → permissions-Dk6mshja.mjs} +315 -397
  70. package/dist/plugins/index.d.mts +7 -7
  71. package/dist/plugins/index.mjs +14 -16
  72. package/dist/plugins/response-cache.mjs +2 -2
  73. package/dist/plugins/tracing-entry.d.mts +1 -1
  74. package/dist/plugins/tracing-entry.mjs +1 -1
  75. package/dist/presets/filesUpload.d.mts +27 -5
  76. package/dist/presets/filesUpload.mjs +1 -1
  77. package/dist/presets/index.d.mts +3 -2
  78. package/dist/presets/index.mjs +4 -3
  79. package/dist/presets/multiTenant.d.mts +1 -1
  80. package/dist/presets/multiTenant.mjs +2 -2
  81. package/dist/presets/search.d.mts +178 -0
  82. package/dist/presets/search.mjs +150 -0
  83. package/dist/{presets-C2xgzW6x.mjs → presets-fLJVXdVn.mjs} +1 -1
  84. package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-BKbWjgDG.d.mts} +1 -1
  85. package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DQCEfJis.mjs} +9 -9
  86. package/dist/{queryParser-CgCtsjti.mjs → queryParser-DBqBB6AC.mjs} +1 -1
  87. package/dist/{redis-BM00zaPB.d.mts → redis-DqyeggCa.d.mts} +1 -1
  88. package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-CakIQmwR.d.mts} +1 -1
  89. package/dist/registry/index.d.mts +1 -1
  90. package/dist/registry/index.mjs +2 -2
  91. package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-BElv3xPT.mjs} +65 -48
  92. package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
  93. package/dist/scope/index.d.mts +1 -1
  94. package/dist/scope/index.mjs +2 -2
  95. package/dist/{sse-Ad7ypl9e.mjs → sse-yBCgOLGu.mjs} +1 -1
  96. package/dist/store-helpers-ZCSMJJAX.mjs +57 -0
  97. package/dist/testing/index.d.mts +9 -17
  98. package/dist/testing/index.mjs +27 -83
  99. package/dist/testing/storageContract.d.mts +1 -1
  100. package/dist/types/index.d.mts +4 -4
  101. package/dist/types/index.mjs +1 -31
  102. package/dist/types/storage.d.mts +1 -1
  103. package/dist/{types-BsbNMEDR.d.mts → types-Btdda02s.d.mts} +1 -1
  104. package/dist/{types-Ch9pTQbf.d.mts → types-Co8k3NyS.d.mts} +11 -9
  105. package/dist/types-Csi3FLfq.mjs +27 -0
  106. package/dist/utils/index.d.mts +208 -4
  107. package/dist/utils/index.mjs +5 -6
  108. package/dist/{utils-yYT3HDXt.mjs → utils-B2fNOD_i.mjs} +285 -2
  109. package/dist/{versioning-CDugduqI.mjs → versioning-C2U_bLY0.mjs} +3 -5
  110. package/package.json +20 -26
  111. package/skills/arc/SKILL.md +97 -23
  112. package/skills/arc/references/auth.md +94 -0
  113. package/skills/arc/references/events.md +200 -12
  114. package/skills/arc/references/mcp.md +4 -17
  115. package/skills/arc/references/multi-tenancy.md +43 -0
  116. package/skills/arc/references/production.md +34 -60
  117. package/dist/EventTransport-BXja8NOc.d.mts +0 -135
  118. package/dist/audit/mongodb.d.mts +0 -2
  119. package/dist/audit/mongodb.mjs +0 -2
  120. package/dist/circuitBreaker-cmi5XDv5.mjs +0 -284
  121. package/dist/circuitBreaker-dTtG-UyS.d.mts +0 -206
  122. package/dist/core-F0QoWBt2.mjs +0 -34
  123. package/dist/dynamic/index.d.mts +0 -93
  124. package/dist/dynamic/index.mjs +0 -122
  125. package/dist/fields-DpZQa_Q3.d.mts +0 -109
  126. package/dist/idempotency/mongodb.d.mts +0 -2
  127. package/dist/idempotency/mongodb.mjs +0 -123
  128. package/dist/interface-4y979v99.d.mts +0 -54
  129. package/dist/mongodb-BsP-WbhN.d.mts +0 -127
  130. package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
  131. package/dist/mongodb-Utc5k_-0.mjs +0 -90
  132. package/dist/policies/index.d.mts +0 -432
  133. package/dist/policies/index.mjs +0 -318
  134. package/dist/rpc/index.d.mts +0 -90
  135. package/dist/rpc/index.mjs +0 -248
  136. /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BNYKnrXF.mjs} +0 -0
  137. /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-QhV1Pa-g.mjs} +0 -0
  138. /package/dist/{constants-Cxde4rpC.mjs → constants-BhY1OHoH.mjs} +0 -0
  139. /package/dist/{elevation-B6S5csVA.d.mts → elevation-C5SwtkAn.d.mts} +0 -0
  140. /package/dist/{errors-Ck2h67pm.d.mts → errors-CCSsMpXE.d.mts} +0 -0
  141. /package/dist/{errors-BF2bIOIS.mjs → errors-D5c-5BJL.mjs} +0 -0
  142. /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-BQ8QijNH.d.mts} +0 -0
  143. /package/dist/{interface-DfLGcus7.d.mts → interface-CSbZdv_3.d.mts} +0 -0
  144. /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-BAzJItAJ.mjs} +0 -0
  145. /package/dist/{logger-D1YrIImS.mjs → logger-DLg8-Ueg.mjs} +0 -0
  146. /package/dist/{metrics-B-PU4-Yu.mjs → metrics-DuhiSEZI.mjs} +0 -0
  147. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-A0tWEl1K.mjs} +0 -0
  148. /package/dist/{registry-BiTKT1Dg.mjs → registry-B3lRFBWo.mjs} +0 -0
  149. /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-CXtJDAZ0.mjs} +0 -0
  150. /package/dist/{requestContext-DYvHl113.mjs → requestContext-xHIKedG6.mjs} +0 -0
  151. /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-BkzVU8h2.d.mts} +0 -0
  152. /package/dist/{storage-Dfzt4VTl.d.mts → storage-CVk_SEn2.d.mts} +0 -0
  153. /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-65B51Dw3.d.mts} +0 -0
  154. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
  155. /package/dist/{types-ZUu_h0jp.mjs → types-DV9WDfeg.mjs} +0 -0
@@ -1,8 +1,112 @@
1
1
  import { r as RequestScope } from "./types-BD85MlEK.mjs";
2
- import { n as FieldPermissionMap } from "./fields-DpZQa_Q3.mjs";
3
- import { i as UserBase, t as PermissionCheck } from "./types-DZi1aYhm.mjs";
2
+ import { c as PermissionCheck, d as UserBase, n as FieldPermissionMap } from "./fields-Lo1VUDpt.mjs";
3
+ import { n as DomainEvent } from "./EventTransport-CUw5NNWe.mjs";
4
4
  import { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod, RouteHandlerMethod as RouteHandlerMethod$1 } from "fastify";
5
+ import * as _$_classytic_repo_core_repository0 from "@classytic/repo-core/repository";
6
+ import { MinimalRepo, QueryOptions, StandardRepo } from "@classytic/repo-core/repository";
7
+ import { KeysetPaginationResult, OffsetPaginationResult } from "@classytic/repo-core/pagination";
5
8
 
9
+ //#region src/types/base.d.ts
10
+ declare module "fastify" {
11
+ interface FastifyRequest {
12
+ /** Request scope — set by auth adapter, read by permissions/presets/guards */
13
+ scope: RequestScope;
14
+ /**
15
+ * Current user — set by auth adapter (Better Auth, JWT, custom).
16
+ * `undefined` on public routes (`auth: false`) or unauthenticated requests.
17
+ * Guard with `if (request.user)` on routes that allow anonymous access.
18
+ *
19
+ * Kept as required (not `user?`) because `@fastify/jwt` declares it
20
+ * as required — declaration merges must have identical modifiers.
21
+ * The `| undefined` in the type achieves the same DX.
22
+ */
23
+ user: Record<string, unknown> | undefined;
24
+ /** Policy-injected query filters (e.g. ownership, org-scoping) */
25
+ _policyFilters?: Record<string, unknown>;
26
+ /** Field mask — fields to include/exclude in responses */
27
+ fieldMask?: {
28
+ include?: string[];
29
+ exclude?: string[];
30
+ };
31
+ /** Arbitrary policy metadata for downstream consumers */
32
+ policyMetadata?: Record<string, unknown>;
33
+ /** Document loaded by policy middleware for ownership checks */
34
+ document?: unknown;
35
+ /** Ownership check context (field name + user field) */
36
+ _ownershipCheck?: Record<string, unknown>;
37
+ }
38
+ }
39
+ type AnyRecord = Record<string, unknown>;
40
+ /** MongoDB ObjectId — accepts string or any object with a `toString()` (e.g. mongoose ObjectId). */
41
+ type ObjectId = string | {
42
+ toString(): string;
43
+ };
44
+ /**
45
+ * Flexible user type that accepts any object with id/_id properties.
46
+ * The actual user structure is defined by your app's auth system.
47
+ */
48
+ type UserLike = UserBase & {
49
+ /** User email (optional) */email?: string;
50
+ };
51
+ /** Extract user ID from a user object (supports both id and _id). */
52
+ declare function getUserId(user: UserLike | null | undefined): string | undefined;
53
+ interface UserOrganization {
54
+ userId: string;
55
+ organizationId: string;
56
+ [key: string]: unknown;
57
+ }
58
+ interface JWTPayload {
59
+ sub: string;
60
+ [key: string]: unknown;
61
+ }
62
+ /**
63
+ * Standard API response envelope — `{ success, data?, error?, message?, meta? }`.
64
+ * Used by Arc's default response shape.
65
+ */
66
+ interface ApiResponse<T = unknown> {
67
+ success: boolean;
68
+ data?: T;
69
+ error?: string;
70
+ message?: string;
71
+ meta?: Record<string, unknown>;
72
+ }
73
+ /**
74
+ * Typed Fastify request with Arc decorations. Use in `raw: true` handlers
75
+ * instead of `(req as any).user`.
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * import type { ArcRequest } from '@classytic/arc';
80
+ *
81
+ * handler: async (req: ArcRequest, reply) => {
82
+ * req.user?.id; // typed
83
+ * req.scope.organizationId; // typed (when member)
84
+ * req.signal; // AbortSignal (Fastify 5)
85
+ * }
86
+ * ```
87
+ */
88
+ type ArcRequest = FastifyRequest & {
89
+ scope: RequestScope;
90
+ user: Record<string, unknown> | undefined;
91
+ signal: AbortSignal;
92
+ };
93
+ /**
94
+ * Wrap data in Arc's standard `{ success: true, data }` envelope.
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * handler: async (req, reply) => {
99
+ * const data = await getResults();
100
+ * return envelope(data); // → { success: true, data }
101
+ * }
102
+ * ```
103
+ */
104
+ declare function envelope<T>(data: T, meta?: Record<string, unknown>): {
105
+ success: true;
106
+ data: T;
107
+ [key: string]: unknown;
108
+ };
109
+ //#endregion
6
110
  //#region src/hooks/HookSystem.d.ts
7
111
  type HookPhase = "before" | "around" | "after";
8
112
  type HookOperation = "create" | "update" | "delete" | "restore" | "read" | "list";
@@ -254,6 +358,183 @@ declare function beforeDelete<T = AnyRecord>(hooks: HookSystem, resource: string
254
358
  */
255
359
  declare function afterDelete<T = AnyRecord>(hooks: HookSystem, resource: string, handler: HookHandler<T>, priority?: number): () => void;
256
360
  //#endregion
361
+ //#region src/types/query.d.ts
362
+ /**
363
+ * Request-shaped context object passed to controller methods. Apps and
364
+ * adapters extend it freely via the index signature.
365
+ */
366
+ interface RequestContext {
367
+ operation?: string;
368
+ user?: unknown;
369
+ filters?: Record<string, unknown>;
370
+ [key: string]: unknown;
371
+ }
372
+ /**
373
+ * Internal metadata shape injected by Arc's Fastify adapter. Extends
374
+ * RequestContext with known internal fields so controllers can access
375
+ * them without `as AnyRecord` casts.
376
+ */
377
+ interface ArcInternalMetadata extends RequestContext {
378
+ /** Policy filters from permission middleware */
379
+ _policyFilters?: Record<string, unknown>;
380
+ /** Request scope from scope resolution */
381
+ _scope?: RequestScope;
382
+ /** Ownership check config from ownedByUser preset */
383
+ _ownershipCheck?: {
384
+ field: string;
385
+ userId: string;
386
+ };
387
+ /** Arc instance references (hooks, field permissions, etc.) */
388
+ arc?: {
389
+ hooks?: HookSystem;
390
+ fields?: FieldPermissionMap;
391
+ [key: string]: unknown;
392
+ };
393
+ }
394
+ /**
395
+ * Controller-level query options — parsed from request query string.
396
+ * Includes pagination, filtering, populate/lookup, and context data.
397
+ */
398
+ interface ControllerQueryOptions {
399
+ page?: number;
400
+ limit?: number;
401
+ sort?: string | Record<string, 1 | -1>;
402
+ /** Simple populate (comma-separated string or array) */
403
+ populate?: string | string[] | Record<string, unknown>;
404
+ /**
405
+ * Advanced populate options (Mongoose-compatible). When set, takes
406
+ * precedence over simple `populate`.
407
+ */
408
+ populateOptions?: PopulateOption[];
409
+ /**
410
+ * Lookup/join options (database-agnostic). MongoKit maps these to
411
+ * `$lookup`; future SQL adapters would map to JOINs.
412
+ *
413
+ * @example
414
+ * URL: ?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug
415
+ */
416
+ lookups?: LookupOption[];
417
+ select?: string | string[] | Record<string, 0 | 1>;
418
+ filters?: Record<string, unknown>;
419
+ search?: string;
420
+ lean?: boolean;
421
+ after?: string;
422
+ user?: unknown;
423
+ context?: Record<string, unknown>;
424
+ [key: string]: unknown;
425
+ }
426
+ /**
427
+ * Database-agnostic lookup/join option. Parsed from URL:
428
+ * `?lookup[alias][from]=...&lookup[alias][localField]=...&lookup[alias][foreignField]=...`
429
+ */
430
+ interface LookupOption {
431
+ /** Source collection/table to join from */
432
+ from: string;
433
+ /** Local field to match on */
434
+ localField: string;
435
+ /** Foreign field to match on */
436
+ foreignField: string;
437
+ /** Alias for the joined data (defaults to the lookup key) */
438
+ as?: string;
439
+ /** Return a single object instead of array (default: false) */
440
+ single?: boolean;
441
+ /** Field selection on the joined collection */
442
+ select?: string | Record<string, 0 | 1>;
443
+ }
444
+ /**
445
+ * Mongoose-compatible populate option for advanced field selection.
446
+ *
447
+ * @example
448
+ * ```typescript
449
+ * // URL: ?populate[author][select]=name,email
450
+ * // Generates: { path: 'author', select: 'name email' }
451
+ * ```
452
+ */
453
+ interface PopulateOption {
454
+ /** Field path to populate */
455
+ path: string;
456
+ /** Fields to select (space-separated) */
457
+ select?: string;
458
+ /** Filter conditions for populated documents */
459
+ match?: Record<string, unknown>;
460
+ /** Query options (limit, sort, skip) */
461
+ options?: {
462
+ limit?: number;
463
+ sort?: Record<string, 1 | -1>;
464
+ skip?: number;
465
+ };
466
+ /** Nested populate configuration */
467
+ populate?: PopulateOption;
468
+ }
469
+ /**
470
+ * Parsed query result from QueryParser. The index signature lets custom
471
+ * parsers (MongoKit, PrismaKit) add fields without breaking Arc's types.
472
+ */
473
+ interface ParsedQuery {
474
+ filters?: Record<string, unknown>;
475
+ limit?: number;
476
+ sort?: string | Record<string, 1 | -1>;
477
+ populate?: string | string[] | Record<string, unknown>;
478
+ populateOptions?: PopulateOption[];
479
+ lookups?: LookupOption[];
480
+ search?: string;
481
+ page?: number;
482
+ after?: string;
483
+ select?: string | string[] | Record<string, 0 | 1>;
484
+ [key: string]: unknown;
485
+ }
486
+ /**
487
+ * Query Parser interface. Implement to create custom query parsers.
488
+ *
489
+ * @example MongoKit
490
+ * ```typescript
491
+ * import { QueryParser } from '@classytic/mongokit';
492
+ * const queryParser = new QueryParser();
493
+ * ```
494
+ */
495
+ interface QueryParserInterface {
496
+ parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
497
+ /** Optional: Export OpenAPI schema for query parameters. */
498
+ getQuerySchema?(): {
499
+ type: "object";
500
+ properties: Record<string, unknown>;
501
+ required?: string[];
502
+ };
503
+ /**
504
+ * Optional: Allowed filter fields whitelist. MCP auto-derives
505
+ * `filterableFields` from this if `schemaOptions.filterableFields`
506
+ * is not explicitly configured.
507
+ */
508
+ allowedFilterFields?: readonly string[];
509
+ /**
510
+ * Optional: Allowed filter operators whitelist. Used by MCP to enrich
511
+ * list-tool descriptions. Values are human-readable keys: 'eq', 'ne',
512
+ * 'gt', 'gte', 'lt', 'lte', 'in', 'nin', etc.
513
+ */
514
+ allowedOperators?: readonly string[];
515
+ /**
516
+ * Optional: Allowed sort fields whitelist. Used by MCP to describe
517
+ * available sort options in list-tool descriptions.
518
+ */
519
+ allowedSortFields?: readonly string[];
520
+ }
521
+ /** Ownership-check config used by `ownedByUser` preset / middleware. */
522
+ interface OwnershipCheck {
523
+ field: string;
524
+ userField?: string;
525
+ }
526
+ /** Service-layer context — passed to repository / service calls. */
527
+ interface ServiceContext {
528
+ user?: unknown;
529
+ requestId?: string;
530
+ /** Field projection for responses */
531
+ select?: string[] | Record<string, 0 | 1>;
532
+ /** Relations to populate */
533
+ populate?: string | string[];
534
+ /** Return plain objects */
535
+ lean?: boolean;
536
+ }
537
+ //#endregion
257
538
  //#region src/pipeline/types.d.ts
258
539
  /**
259
540
  * Pipeline context passed to guards, transforms, and interceptors.
@@ -316,1701 +597,735 @@ type PipelineConfig = PipelineStep[] | {
316
597
  [operation: string]: PipelineStep[] | undefined;
317
598
  };
318
599
  //#endregion
319
- //#region src/types/repository.d.ts
600
+ //#region src/adapters/interface.d.ts
320
601
  /**
321
- * Repository Interface Database-Agnostic CRUD Contract
322
- *
323
- * This is the canonical contract every arc-compatible repository follows.
324
- * It is intentionally structural: any object matching the shape works,
325
- * including the reference implementation at `@classytic/mongokit` and any
326
- * future `prismakit` / `pgkit` / `sqlitekit` that mirrors it.
327
- *
328
- * ## Design
329
- *
330
- * The interface is tiered so a minimal adapter can ship with five methods
331
- * while a mature one (mongokit 3.6+) can opt into the full surface without
332
- * type assertions:
333
- *
334
- * 1. **Required** — `getAll`, `getById`, `create`, `update`, `delete`.
335
- * Every resource needs these; arc's BaseController assumes they exist.
336
- *
337
- * 2. **Recommended** — `getOne` / `getByQuery`. Used by AccessControl to
338
- * enforce compound filters (idField + org scope + policy). Without them,
339
- * arc falls back to `getById` + post-fetch checks, which is slower and
340
- * produces wrong 404s on custom idFields.
341
- *
342
- * 3. **Optional capabilities** — batch ops, soft delete, aggregation,
343
- * transactions, etc. Declared as optional so kits implement only what
344
- * their underlying DB supports. arc feature-detects at runtime.
345
- *
346
- * All options/results are named types so custom kits can import and
347
- * implement them directly:
348
- *
349
- * ```ts
350
- * import type {
351
- * CrudRepository,
352
- * DeleteOptions,
353
- * DeleteResult,
354
- * PaginationResult,
355
- * UpdateManyResult,
356
- * BulkWriteOperation,
357
- * BulkWriteResult,
358
- * } from '@classytic/arc';
359
- *
360
- * class PgRepository<TDoc> implements CrudRepository<TDoc> { … }
361
- * ```
602
+ * Arc's structural repository contract: the repo-core minimum plus any
603
+ * standard-repo methods a given kit implements. All optional methods are
604
+ * feature-detected at call sites arc never assumes capabilities it
605
+ * hasn't probed.
362
606
  *
363
- * @example Reference implementation
364
607
  * ```ts
365
- * import type { CrudRepository } from '@classytic/arc';
366
- * const userRepo: CrudRepository<UserDocument> = new Repository(UserModel);
608
+ * const adapter: DataAdapter<Product> = {
609
+ * repository: myRepo, // any MinimalRepo<Product> kit-agnostic
610
+ * type: 'drizzle', // or 'mongoose' | 'prisma' | 'custom'
611
+ * name: 'products',
612
+ * };
613
+ * defineResource({ adapter, ... });
367
614
  * ```
368
- *
369
- * ## Contract gotchas (learned from mongokit 3.6 integration)
370
- *
371
- * If you build a custom kit that implements this contract, these are the
372
- * behaviors arc's tests specifically verify. Align your kit here and
373
- * arc's `BaseController` + presets will work out of the box:
374
- *
375
- * 1. **`getById` / `getOne` miss semantics** — MAY return `null` or throw a
376
- * 404-style error whose message contains "not found". Arc handles both.
377
- * Pick one and document it in your kit.
378
- *
379
- * 2. **`deleteMany` with soft-delete** — if your kit intercepts
380
- * `deleteMany` and rewrites it to `updateMany`, the returned
381
- * `deletedCount` may be `0` even when N docs were soft-deleted. The
382
- * authoritative count comes from a follow-up query. Consumers shouldn't
383
- * rely on `deletedCount` reflecting soft-delete work unless your kit
384
- * promises it.
385
- *
386
- * 3. **Lifecycle hooks are shared with plugins** — never use
387
- * `removeAllListeners(event)` to clean up test hooks. That silently
388
- * removes soft-delete, cascade, multi-tenant, and audit plugin
389
- * listeners too, which then makes subsequent operations misbehave
390
- * (e.g. a soft-delete becomes a hard delete). Always use
391
- * `.off(event, fn)` with the specific handler reference you registered.
392
- *
393
- * 4. **Hard-delete mode** — `delete(id, { mode: 'hard' })` and
394
- * `deleteMany(q, { mode: 'hard' })` MUST bypass soft-delete
395
- * interception while still running policy / multi-tenant / cascade /
396
- * audit hooks. Kits without soft-delete should accept and ignore the
397
- * flag.
398
- *
399
- * 5. **Keyset pagination auto-detection** — `getAll({ sort, limit })`
400
- * without `page` SHOULD return a `KeysetPaginatedResult` with
401
- * `method: "keyset"`. Kits that only offer offset pagination can return
402
- * the legacy offset shape; arc's types still satisfy.
403
- *
404
- * 6. **`idField` identity** — kits that key on anything other than `"_id"`
405
- * MUST set `readonly idField` on the repository so arc's BaseController
406
- * passes route params straight through to `update`/`delete`/`restore`
407
- * without translating them.
408
- *
409
- * 7. **`before:restore` / `after:restore` hooks** — if you implement
410
- * `restore`, fire these hooks symmetrically with `before:delete` /
411
- * `after:delete` so hosts can wire cascade-restore flows.
412
- *
413
- * See `tests/core/repository-contract-mongokit.test.ts` for a runnable
414
- * reference against mongokit 3.6. Copy it, swap in your kit's repository,
415
- * and make it pass — if everything's green, arc will work against your
416
- * kit.
417
- */
418
- /**
419
- * Opaque transaction session. Adapters bind this to their own type
420
- * (Mongoose `ClientSession`, Prisma transaction client, `pg.Client`, …).
421
615
  */
422
- type RepositorySession = unknown;
423
- /**
424
- * Query options for read operations. Extended ad-hoc by adapters via the
425
- * index signature — kit authors should namespace custom flags (e.g.
426
- * `__pgHint`) to avoid collisions.
427
- */
428
- interface QueryOptions {
429
- /** Transaction session — adapter-specific concrete type */
430
- session?: RepositorySession;
431
- /** Return plain objects instead of driver documents */
432
- lean?: boolean;
433
- /** Include soft-deleted docs in reads (honored by soft-delete plugin) */
434
- includeDeleted?: boolean;
435
- /** Forwarded to policy/tenant hooks */
436
- user?: Record<string, unknown>;
437
- /** Arc request-scoped metadata (orgId, roles, requestId, …) */
438
- context?: Record<string, unknown>;
616
+ type RepositoryLike<TDoc = unknown> = MinimalRepo<TDoc> & Partial<StandardRepo<TDoc>>;
617
+ interface DataAdapter<TDoc = unknown> {
439
618
  /**
440
- * Adapter-specific escape hatch `select`, `populate`, `populateOptions`,
441
- * `readPreference`, `maxTimeMS`, and every kit's driver-specific flags
442
- * flow through here. Arc intentionally does NOT type these concretely
443
- * because each kit's DB shapes them differently: mongoose uses
444
- * `PopulateOptions[]`, prisma uses `{ include: {...} }`, pgkit uses SQL
445
- * JOIN hints, etc. Typing them as (say) `string | Record<string, unknown>`
446
- * would REJECT the narrower shapes real kits actually expose, breaking
447
- * structural assignability of `Repository<T> → CrudRepository<T>`.
619
+ * Repository implementing CRUD operations. Accepts the typed
620
+ * `StandardRepo<TDoc>` (repo-core's standard contract) or the structural
621
+ * `RepositoryLike` (minimum + optionals). Arc feature-detects optional
622
+ * methods at runtime kits only declare what they support.
448
623
  */
449
- [key: string]: unknown;
450
- }
451
- /**
452
- * Options for write operations (create/update). Superset of QueryOptions
453
- * so callers can pass a single options object.
454
- */
455
- interface WriteOptions extends QueryOptions {
456
- /** Upsert on update/replace operations */
457
- upsert?: boolean;
458
- }
459
- /**
460
- * Options for delete operations.
461
- *
462
- * `mode: 'hard'` opts out of the soft-delete interception when the adapter
463
- * has a soft-delete plugin wired. Policy, cascade, audit, and cache hooks
464
- * still fire — only the soft-delete rewrite is bypassed. Use for GDPR
465
- * erasure or admin purge paths.
466
- */
467
- interface DeleteOptions extends QueryOptions {
624
+ repository: StandardRepo<TDoc> | RepositoryLike<TDoc>;
625
+ /** Adapter identifier for introspection */
626
+ readonly type: "mongoose" | "prisma" | "drizzle" | "typeorm" | "custom";
627
+ /** Human-readable name */
628
+ readonly name: string;
629
+ /**
630
+ * Generate OpenAPI schemas for CRUD operations. Each adapter produces
631
+ * schemas appropriate to its ORM/database (mongoose kits use mongokit's
632
+ * `buildCrudSchemasFromModel`; SQL kits introspect columns).
633
+ *
634
+ * @param options - Schema generation options (field rules, populate hints)
635
+ * @param context - Resource-level context (idField for params schema, name for logs).
636
+ * Adapters should honor `context.idField` when producing the params
637
+ * schema (e.g., skip the ObjectId pattern when idField is a custom
638
+ * string field).
639
+ */
640
+ generateSchemas?(options?: RouteSchemaOptions, context?: AdapterSchemaContext): OpenApiSchemas | Record<string, unknown> | null;
641
+ /** Extract schema metadata for OpenAPI/introspection. */
642
+ getSchemaMetadata?(): SchemaMetadata | null;
643
+ /** Validate data against schema before persistence. */
644
+ validate?(data: unknown): Promise<ValidationResult$1> | ValidationResult$1;
645
+ /** Health check for database connection. */
646
+ healthCheck?(): Promise<boolean>;
468
647
  /**
469
- * Force physical deletion even when soft-delete is active, or force soft
470
- * when the default would be hard. Adapters without soft-delete support
471
- * MUST ignore this flag (it is a hint, not a contract).
648
+ * Custom filter matching for in-memory policy enforcement. Falls back
649
+ * to arc's built-in shallow matcher when omitted. Override for SQL
650
+ * adapters, non-Mongo operators, or kits that compile Filter IR.
472
651
  */
473
- mode?: "hard" | "soft";
652
+ matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
653
+ /** Close / cleanup resources. */
654
+ close?(): Promise<void>;
474
655
  }
475
656
  /**
476
- * Result of a single delete operation.
477
- *
478
- * Matches mongokit's shape. Adapters without soft-delete awareness can omit
479
- * `soft` and `count`. Arc's BaseController uses the `success` flag to decide
480
- * whether to return 200 or 404.
657
+ * Context passed to `adapter.generateSchemas()` so adapters shape output
658
+ * to match resource-level configuration. All fields optional — adapters
659
+ * that ignore this still work; arc applies safety-net normalization.
481
660
  */
482
- interface DeleteResult {
483
- success: boolean;
484
- message: string;
485
- /** Primary key of the removed doc (string form) */
486
- id?: string;
487
- /** True when a soft-delete plugin intercepted the operation */
488
- soft?: boolean;
489
- /** For batch-variant implementations that return the delete count inline */
490
- count?: number;
661
+ interface AdapterSchemaContext {
662
+ /** The idField configured on the resource. Defaults to "_id". */
663
+ idField?: string;
664
+ /** Resource name (for error messages / logging). */
665
+ resourceName?: string;
666
+ }
667
+ interface SchemaMetadata {
668
+ name: string;
669
+ fields: Record<string, FieldMetadata>;
670
+ indexes?: Array<{
671
+ fields: string[];
672
+ unique?: boolean;
673
+ sparse?: boolean;
674
+ }>;
675
+ relations?: Record<string, RelationMetadata>;
676
+ }
677
+ interface FieldMetadata {
678
+ type: "string" | "number" | "boolean" | "date" | "object" | "array" | "objectId" | "enum";
679
+ required?: boolean;
680
+ unique?: boolean;
681
+ default?: unknown;
682
+ enum?: Array<string | number>;
683
+ min?: number;
684
+ max?: number;
685
+ minLength?: number;
686
+ maxLength?: number;
687
+ pattern?: string;
688
+ description?: string;
689
+ ref?: string;
690
+ array?: boolean;
691
+ }
692
+ interface RelationMetadata {
693
+ type: "one-to-one" | "one-to-many" | "many-to-many";
694
+ target: string;
695
+ foreignKey?: string;
696
+ through?: string;
697
+ }
698
+ interface ValidationResult$1 {
699
+ valid: boolean;
700
+ errors?: Array<{
701
+ field: string;
702
+ message: string;
703
+ code?: string;
704
+ }>;
491
705
  }
706
+ type AdapterFactory<TDoc> = (config: unknown) => DataAdapter<TDoc>;
707
+ //#endregion
708
+ //#region src/types/handlers.d.ts
492
709
  /**
493
- * Result of a batch delete (`deleteMany`) distinct from single `delete`
494
- * because MongoDB's driver returns a different shape for batch operations.
495
- *
496
- * **Soft-delete gotcha** — when a soft-delete plugin intercepts
497
- * `deleteMany` by rewriting it to `updateMany` internally (mongokit 3.6
498
- * does this in `before:deleteMany`), the `deletedCount` returned here may
499
- * be `0` because the underlying `Model.deleteMany` was never called. The
500
- * affected-row count lives inside the hook's `updateMany` result and is
501
- * not surfaced to the caller. Consumers that need the exact soft-deleted
502
- * count should run a follow-up query (`repo.count({ deletedAt: { $ne:
503
- * null }, ...filter })`). 3rd-party kits with soft-delete should document
504
- * which convention they follow.
710
+ * Minimal server accessor exposes safe, read-only server decorators.
711
+ * Allows controller handlers to publish events, log, and audit
712
+ * without switching to `raw: true`.
505
713
  */
506
- interface DeleteManyResult {
507
- /** Driver-reported acknowledgement */
508
- acknowledged?: boolean;
509
- /**
510
- * Number of documents removed. May be 0 when soft-delete intercepts;
511
- * see the "Soft-delete gotcha" note above.
512
- */
513
- deletedCount: number;
514
- /** True when a soft-delete plugin intercepted and did `updateMany` instead */
515
- soft?: boolean;
516
- }
517
- /** Result of a bulk update operation. Matches MongoDB driver shape. */
518
- interface UpdateManyResult {
519
- acknowledged?: boolean;
520
- matchedCount: number;
521
- modifiedCount: number;
522
- upsertedCount?: number;
523
- upsertedId?: unknown;
524
- }
525
- /** Shape of a single operation passed to `bulkWrite`. */
526
- type BulkWriteOperation<TDoc = unknown> = {
527
- insertOne: {
528
- document: Partial<TDoc>;
529
- };
530
- } | {
531
- updateOne: {
532
- filter: Record<string, unknown>;
533
- update: Record<string, unknown>;
534
- upsert?: boolean;
535
- };
536
- } | {
537
- updateMany: {
538
- filter: Record<string, unknown>;
539
- update: Record<string, unknown>;
540
- upsert?: boolean;
714
+ interface ServerAccessor {
715
+ /** Event bus — publish domain events from any handler */
716
+ events?: {
717
+ publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
541
718
  };
542
- } | {
543
- deleteOne: {
544
- filter: Record<string, unknown>;
719
+ /** Audit logger — log custom audit entries */
720
+ audit?: {
721
+ create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
722
+ update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
723
+ delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
724
+ custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
545
725
  };
546
- } | {
547
- deleteMany: {
548
- filter: Record<string, unknown>;
726
+ /** Logger — structured logging */
727
+ log?: {
728
+ info: (...args: unknown[]) => void;
729
+ warn: (...args: unknown[]) => void;
730
+ error: (...args: unknown[]) => void;
731
+ debug: (...args: unknown[]) => void;
549
732
  };
550
- } | {
551
- replaceOne: {
552
- filter: Record<string, unknown>;
553
- replacement: Partial<TDoc>;
554
- upsert?: boolean;
733
+ /** QueryCache — stale-while-revalidate data cache */
734
+ queryCache?: {
735
+ get: <T>(key: string) => Promise<{
736
+ data: T;
737
+ status: 'fresh' | 'stale' | 'miss';
738
+ }>;
739
+ set: <T>(key: string, data: T, config: {
740
+ staleTime?: number;
741
+ gcTime?: number;
742
+ tags?: string[];
743
+ }) => Promise<void>;
744
+ getResourceVersion: (resource: string) => Promise<number>;
745
+ bumpResourceVersion: (resource: string) => Promise<void>;
555
746
  };
556
- };
557
- /** Result of a heterogeneous bulk write. */
558
- interface BulkWriteResult {
559
- ok?: number;
560
- insertedCount?: number;
561
- matchedCount?: number;
562
- modifiedCount?: number;
563
- deletedCount?: number;
564
- upsertedCount?: number;
565
- insertedIds?: Record<number, unknown>;
566
- upsertedIds?: Record<number, unknown>;
567
- }
568
- /**
569
- * Pagination parameters for list operations.
570
- *
571
- * Supports three modes, auto-detected by the adapter:
572
- * - **Offset** — pass `page` + `limit`.
573
- * - **Keyset** — pass `sort` + `limit` (+ optional `after` cursor). Required
574
- * for infinite scroll on large collections; O(1) per page.
575
- * - **Raw** — pass neither; adapter returns all matching docs.
576
- */
577
- interface PaginationParams<TDoc = unknown> {
578
- /** Filter criteria */
579
- filters?: Partial<TDoc> & Record<string, unknown>;
580
- /** Sort spec — string (`"-createdAt"`) or object (`{ createdAt: -1 }`) */
581
- sort?: string | Record<string, 1 | -1>;
582
- /** Page number (1-indexed) — triggers offset pagination */
583
- page?: number;
584
- /** Items per page */
585
- limit?: number;
586
- /** Opaque cursor from a prior `next` field — triggers keyset pagination */
587
- after?: string;
588
- /** Allow additional options (select, populate, search, …) */
589
- [key: string]: unknown;
590
747
  }
591
748
  /**
592
- * Offset-based paginated result (the default shape when `page` is provided).
749
+ * Request context passed to controller handlers.
593
750
  *
594
- * `method` is optional so legacy adapters returning the bare `{ docs, page,
595
- * limit, total, pages, hasNext, hasPrev }` shape still satisfy the type.
596
- */
597
- interface OffsetPaginatedResult<TDoc> {
598
- /** Discriminatoromitted or `"offset"` */
599
- method?: "offset";
600
- docs: TDoc[];
601
- page: number;
602
- limit: number;
603
- total: number;
604
- pages: number;
605
- hasNext: boolean;
606
- hasPrev: boolean;
607
- }
608
- /**
609
- * Keyset-based paginated result (returned when `sort` is provided without
610
- * `page`). Ideal for infinite scroll — no `count()` query, O(1) per page.
611
- */
612
- interface KeysetPaginatedResult<TDoc> {
613
- /** Discriminator — always `"keyset"` */
614
- method: "keyset";
615
- docs: TDoc[];
616
- limit: number;
617
- hasMore: boolean;
618
- /** Opaque cursor token for the next page, or `null` at the end */
619
- next: string | null;
620
- }
621
- /**
622
- * Discriminated union of all pagination result shapes.
623
- * Consumers narrow on the `method` discriminator.
751
+ * **Generic parameters** (all default to safe permissive types so existing code keeps working):
752
+ * - `TBody` — request body shape (default: `unknown`)
753
+ * - `TParams` — route param shape (default: `Record<string, string>`)
754
+ * - `TQuery` — query string shape (default: `Record<string, unknown>`)
755
+ * - `TUser` authenticated user shape (default: `UserBase`)
756
+ * - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
757
+ * override with `ArcInternalMetadata` or your own augmentation when you
758
+ * need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
624
759
  *
625
760
  * @example
626
- * ```ts
627
- * const result = await repo.getAll(params);
628
- * if (result.method === "keyset") {
629
- * // result.next, result.hasMore
630
- * } else {
631
- * // result.page, result.total, result.pages
761
+ * ```typescript
762
+ * // Untyped (default) req.body is `unknown`, must be narrowed
763
+ * async create(req: IRequestContext) {
764
+ * const data = req.body as Partial<Product>;
765
+ * return { success: true, data: await productRepo.create(data) };
632
766
  * }
633
- * ```
634
- */
635
- type PaginationResult<TDoc> = OffsetPaginatedResult<TDoc> | KeysetPaginatedResult<TDoc>;
636
- /**
637
- * Legacy alias. Existing code typed as `PaginatedResult<TDoc>` continues
638
- * to work unchanged — it resolves to the offset shape, which is the most
639
- * common. New code should prefer `PaginationResult<TDoc>` for the full
640
- * discriminated union.
641
- */
642
- type PaginatedResult<TDoc> = OffsetPaginatedResult<TDoc>;
643
- /**
644
- * Standard CRUD Repository Interface
645
- *
646
- * The canonical contract arc consumes. Tiered so minimal adapters only
647
- * implement the required five methods; richer kits declare the optional
648
- * capabilities they support.
649
767
  *
650
- * Every optional method is feature-detected at runtime by arc's
651
- * BaseController and presets — implement only what your DB can express.
768
+ * // Typed body — req.body is `CreateProductInput`, narrowing not needed
769
+ * async create(req: IRequestContext<CreateProductInput>) {
770
+ * return { success: true, data: await productRepo.create(req.body) };
771
+ * }
652
772
  *
653
- * @typeParam TDoc - The document/entity type
773
+ * // Fully typed body, route params, query, and metadata
774
+ * async update(
775
+ * req: IRequestContext<
776
+ * Partial<Product>,
777
+ * { id: string },
778
+ * { fields?: string },
779
+ * ArcInternalMetadata
780
+ * >,
781
+ * ) {
782
+ * const fields = req.query.fields?.split(',');
783
+ * const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
784
+ * return { success: true, data: await productRepo.update(req.params.id, req.body) };
785
+ * }
786
+ * ```
654
787
  */
655
- interface CrudRepository<TDoc> {
788
+ interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
789
+ /** Route parameters (e.g., { id: '123' }) */
790
+ params: TParams;
791
+ /** Query string parameters */
792
+ query: TQuery;
793
+ /** Request body */
794
+ body: TBody;
795
+ /** Authenticated user or null */
796
+ user: TUser | null;
797
+ /** Request headers */
798
+ headers: Record<string, string | undefined>;
799
+ /** Organization ID (for multi-tenant apps) */
800
+ organizationId?: string;
801
+ /** Team ID (for team-scoped resources) */
802
+ teamId?: string;
656
803
  /**
657
- * Native primary key field. Defaults to `"_id"` (Mongo convention).
804
+ * Organization/auth context from middleware.
805
+ * Contains orgRoles, orgScope, organizationId, and any custom fields
806
+ * set by the auth adapter or org-scope plugin.
658
807
  *
659
- * Set to match `defineResource({ idField })` for kits that key on a
660
- * custom field (e.g. `"id"`, `"uuid"`, `"slug"`). Arc's BaseController
661
- * reads this to decide whether to pass route params straight through
662
- * to `update`/`delete`/`restore` or to translate them via a fetched
663
- * doc's `_id` first.
808
+ * @example
809
+ * ```typescript
810
+ * async create(req: IRequestContext) {
811
+ * const roles = req.context?.orgRoles ?? [];
812
+ * if (roles.includes('manager')) { ... }
813
+ * }
814
+ * ```
664
815
  */
665
- readonly idField?: string;
816
+ context?: RequestContext;
666
817
  /**
667
- * List documents with pagination. Adapter auto-selects offset vs keyset
668
- * mode based on the presence of `page` or `after` in `params`.
669
- *
670
- * Return shapes (all valid under the contract):
671
- * - `OffsetPaginatedResult<TDoc>` — when `page` is given
672
- * - `KeysetPaginatedResult<TDoc>` — when `sort` + optional `after` are given
673
- * - `TDoc[]` — raw array, when neither `page` nor `sort` drives pagination
674
- *
675
- * Arc's BaseController narrows the union before returning to clients.
818
+ * Internal metadata (includes context + Arc internals like `_policyFilters`,
819
+ * `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
820
+ * built-in fields, or supply your own interface to layer custom fields.
676
821
  */
677
- getAll(params?: PaginationParams<TDoc>, options?: QueryOptions): Promise<PaginationResult<TDoc> | TDoc[]>;
822
+ metadata?: TMetadata;
678
823
  /**
679
- * Fetch a single document by its primary key.
680
- *
681
- * **Miss semantics — kits may EITHER return `null` OR throw a 404-style
682
- * error.** Arc's `BaseController` handles both: `AccessControl.fetchWith­
683
- * AccessControl` catches errors whose message contains "not found" and
684
- * converts them to null. 3rd-party kit authors: pick one convention and
685
- * document it. mongokit 3.6 throws by default; pass
686
- * `{ throwOnNotFound: false }` to get null. A SQL kit that returns null
687
- * directly is equally valid.
688
- */
689
- getById(id: string, options?: QueryOptions): Promise<TDoc | null>;
690
- /** Insert a single document. */
691
- create(data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc>;
692
- /** Update a document by primary key. Returns the updated doc or null. */
693
- update(id: string, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
694
- /**
695
- * Delete a document by primary key. Pass `{ mode: 'hard' }` to bypass
696
- * soft-delete interception.
697
- */
698
- delete(id: string, options?: DeleteOptions): Promise<DeleteResult>;
699
- /**
700
- * Find a single doc by a compound filter. Used by arc's AccessControl to
701
- * combine `idField + orgId + policy` in one query. Without it, arc falls
702
- * back to `getById` + post-fetch scope checks (slower; 404s on custom
703
- * idFields if the doc lives outside the user's scope).
824
+ * Fastify server accessor publish events, log, and audit
825
+ * from any handler without switching to `raw: true`.
704
826
  *
705
- * Miss semantics match `getById` — kits may return null or throw. Arc
706
- * handles both. See the note on `getById` above.
707
- */
708
- getOne?(filter: Record<string, unknown>, options?: QueryOptions): Promise<TDoc | null>;
709
- /** Alias many kits expose alongside `getOne`. Arc checks both. */
710
- getByQuery?(filter: Record<string, unknown>, options?: QueryOptions): Promise<TDoc | null>;
711
- /** Count matching documents. Respects soft-delete when applicable. */
712
- count?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<number>;
713
- /**
714
- * Cheap existence check. Kits may return `boolean` or `{ _id }` — arc
715
- * coerces to boolean at the call site.
716
- */
717
- exists?(filter: Record<string, unknown>, options?: QueryOptions): Promise<boolean | {
718
- _id: unknown;
719
- } | null>;
720
- /** Return the distinct values of a field matching the filter. */
721
- distinct?<T = unknown>(field: string, filter?: Record<string, unknown>, options?: QueryOptions): Promise<T[]>;
722
- /** Return all matching docs as a raw array (no pagination metadata). */
723
- findAll?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<TDoc[]>;
724
- /**
725
- * Atomic "find or create" — return the doc matching the filter, or
726
- * insert `data` and return it if none exists. MAY return `null` when
727
- * neither path produces a document (e.g. race loss + validation error
728
- * handling — mongokit returns null in this window).
729
- */
730
- getOrCreate?(filter: Record<string, unknown>, data: Partial<TDoc>, options?: WriteOptions): Promise<TDoc | null>;
731
- /** Insert multiple documents in one call. */
732
- createMany?(items: Array<Partial<TDoc>>, options?: WriteOptions): Promise<TDoc[]>;
733
- /**
734
- * Update all documents matching `filter`. Should reject empty filters
735
- * to prevent accidental mass updates (mongokit does this).
736
- */
737
- updateMany?(filter: Record<string, unknown>, data: Record<string, unknown>, options?: WriteOptions): Promise<UpdateManyResult>;
738
- /**
739
- * Delete all documents matching `filter`. Soft-deletes when a soft-delete
740
- * plugin is wired; pass `{ mode: 'hard' }` to force physical removal.
827
+ * @example
828
+ * ```typescript
829
+ * async reschedule(req: IRequestContext) {
830
+ * const result = await repo.reschedule(req.params.id, req.body);
831
+ * await req.server?.events?.publish('interview.rescheduled', { data: result });
832
+ * return { success: true, data: result };
833
+ * }
834
+ * ```
741
835
  */
742
- deleteMany?(filter: Record<string, unknown>, options?: DeleteOptions): Promise<DeleteManyResult>;
743
- /**
744
- * Heterogeneous bulk write (insertOne / updateOne / deleteMany / …).
745
- *
746
- * Structurally typed as `unknown` because each kit uses its own operation
747
- * shape — mongoose uses `AnyBulkWriteOperation[]`, prisma builds these
748
- * from its client-extension API, pgkit uses SQL primitives. Arc does
749
- * not call `bulkWrite` internally, so the exact shape is kit-specific.
750
- * See `BulkWriteOperation<TDoc>` (exported from arc) for a reference
751
- * shape you can use when implementing your own kit; mongokit-compatible
752
- * callers should import its own operation types.
753
- */
754
- bulkWrite?: unknown;
755
- /** Restore a soft-deleted document. Should fire `before:restore` hooks. */
756
- restore?(id: string, options?: QueryOptions): Promise<TDoc | null>;
757
- /** Paginated list of soft-deleted documents. */
758
- getDeleted?(params?: PaginationParams<TDoc>, options?: QueryOptions): Promise<PaginationResult<TDoc> | TDoc[]>;
759
- /**
760
- * Run an aggregation pipeline.
761
- *
762
- * Structurally typed as `unknown` because each kit uses a different
763
- * stage type (mongoose's `PipelineStage`, prisma's client-extension
764
- * builders, pgkit's query-builder primitives, …). Arc does not call
765
- * `aggregate` internally — it's a capability consumers use directly on
766
- * the repo. Cast or re-declare at the call site using your kit's types.
767
- */
768
- aggregate?: unknown;
769
- /**
770
- * Paginated aggregation. Same kit-specificity reasoning as `aggregate`
771
- * — structurally `unknown`, type-safe at the call site.
772
- */
773
- aggregatePaginate?: unknown;
774
- /**
775
- * Run `callback` inside a transaction. Adapters should auto-retry on
776
- * transient transaction errors and expose a `session` the callback can
777
- * forward to subsequent repo calls.
778
- */
779
- withTransaction?<T>(callback: (session: RepositorySession) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
780
- /** slugLookup preset — fetch by a business slug. */
781
- getBySlug?(slug: string, options?: QueryOptions): Promise<TDoc | null>;
782
- /** tree preset — return the full hierarchy. */
783
- getTree?(options?: QueryOptions): Promise<TDoc[]>;
784
- /** tree preset — return direct children of a node. */
785
- getChildren?(parentId: string, options?: QueryOptions): Promise<TDoc[]>;
786
- [key: string]: unknown;
836
+ server?: ServerAccessor;
787
837
  }
788
838
  /**
789
- * Extract document type from a repository.
790
- *
791
- * @example
792
- * ```ts
793
- * type UserDoc = InferDoc<typeof userRepository>;
794
- * ```
839
+ * Standard response from controller handlers
795
840
  */
796
- type InferDoc<R> = R extends CrudRepository<infer T> ? T : never;
797
- //#endregion
798
- //#region src/core/defineResource.d.ts
841
+ interface IControllerResponse<T = unknown> {
842
+ /** Operation success status */
843
+ success: boolean;
844
+ /** Response data */
845
+ data?: T;
846
+ /** Error message (when success is false) */
847
+ error?: string;
848
+ /** HTTP status code (default: 200 for success, 400 for error) */
849
+ status?: number;
850
+ /** Additional metadata */
851
+ meta?: Record<string, unknown>;
852
+ /** Error details (for debugging) */
853
+ details?: Record<string, unknown>;
854
+ /** Custom response headers (e.g., X-Total-Count, Link, ETag) */
855
+ headers?: Record<string, string>;
856
+ }
799
857
  /**
800
- * Define a resource with database adapter
858
+ * Controller handler Arc's standard pattern.
801
859
  *
802
- * This is the MAIN entry point for creating Arc resources.
803
- * The adapter provides both repository and schema metadata.
804
- */
805
- declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
806
- interface ResolvedResourceConfig<TDoc = AnyRecord> extends ResourceConfig<TDoc> {
807
- _appliedPresets?: string[];
808
- _controllerOptions?: {
809
- slugField?: string;
810
- parentField?: string;
811
- [key: string]: unknown;
812
- };
813
- _pendingHooks?: Array<{
814
- operation: "create" | "update" | "delete" | "read" | "list";
815
- phase: "before" | "after";
816
- handler: (ctx: AnyRecord) => unknown;
817
- priority: number;
818
- }>;
819
- }
820
- declare class ResourceDefinition<TDoc = AnyRecord> {
821
- readonly name: string;
822
- readonly displayName: string;
823
- readonly tag: string;
824
- readonly prefix: string;
825
- readonly adapter?: DataAdapter<TDoc>;
826
- readonly controller?: IController<TDoc>;
827
- readonly schemaOptions: RouteSchemaOptions;
828
- readonly customSchemas: CrudSchemas;
829
- readonly permissions: ResourcePermissions;
830
- readonly additionalRoutes: AdditionalRoute[];
831
- /**
832
- * Original v2.8 `routes` declaration — retained for downstream consumers
833
- * (OpenAPI, MCP, registry, CLI introspect). Preserves fields dropped during
834
- * normalization to `additionalRoutes` (notably `mcp`, `description`,
835
- * `annotations`). Undefined when the resource was defined with the legacy
836
- * `additionalRoutes` shape.
837
- *
838
- * Added in 2.8.1 — the source-of-truth fix for "canonical resource manifest".
839
- */
840
- readonly routes?: readonly RouteDefinition[];
841
- readonly middlewares: MiddlewareConfig;
842
- readonly routeGuards?: RouteHandlerMethod$1[];
843
- readonly disableDefaultRoutes: boolean;
844
- readonly disabledRoutes: CrudRouteKey[];
845
- readonly actions?: ActionsMap;
846
- readonly actionPermissions?: PermissionCheck;
847
- readonly events: Record<string, EventDefinition>;
848
- readonly rateLimit?: RateLimitConfig | false;
849
- readonly audit?: boolean | {
850
- operations?: ("create" | "update" | "delete")[];
851
- };
852
- readonly updateMethod?: "PUT" | "PATCH" | "both";
853
- readonly pipe?: PipelineConfig;
854
- readonly fields?: FieldPermissionMap;
855
- readonly cache?: ResourceCacheConfig;
856
- readonly skipGlobalPrefix: boolean;
857
- readonly tenantField?: string | false;
858
- readonly idField?: string;
859
- readonly queryParser?: QueryParserInterface;
860
- readonly _appliedPresets: string[];
861
- _pendingHooks: Array<{
862
- operation: "create" | "update" | "delete" | "read" | "list";
863
- phase: "before" | "after";
864
- handler: (ctx: AnyRecord) => unknown;
865
- priority: number;
866
- }>;
867
- _registryMeta?: RegisterOptions;
868
- constructor(config: ResolvedResourceConfig<TDoc>);
869
- /** Get repository from adapter (if available) */
870
- get repository(): RepositoryLike | CrudRepository<TDoc> | undefined;
871
- _validateControllerMethods(): void;
872
- toPlugin(): FastifyPluginAsync;
873
- /**
874
- * Get event definitions for registry
875
- */
876
- getEvents(): Array<{
877
- name: string;
878
- module: string;
879
- schema?: AnyRecord;
880
- description?: string;
881
- }>;
882
- /**
883
- * Get resource metadata
884
- */
885
- getMetadata(): ResourceMetadata;
886
- }
887
- //#endregion
888
- //#region src/registry/ResourceRegistry.d.ts
889
- interface RegisterOptions {
890
- module?: string;
891
- /** Pre-generated OpenAPI schemas */
892
- openApiSchemas?: OpenApiSchemas;
893
- }
894
- declare class ResourceRegistry {
895
- private _resources;
896
- private _frozen;
897
- constructor();
898
- /**
899
- * Register a resource
900
- */
901
- register(resource: ResourceDefinition<unknown>, options?: RegisterOptions): this;
902
- /**
903
- * Get resource by name
904
- */
905
- get(name: string): RegistryEntry | undefined;
906
- /**
907
- * Get all resources
908
- */
909
- getAll(): RegistryEntry[];
910
- /**
911
- * Get resources by module
912
- */
913
- getByModule(moduleName: string): RegistryEntry[];
914
- /**
915
- * Get resources by preset
916
- */
917
- getByPreset(presetName: string): RegistryEntry[];
918
- /**
919
- * Check if resource exists
920
- */
921
- has(name: string): boolean;
922
- /**
923
- * Get registry statistics
924
- */
925
- getStats(): RegistryStats;
926
- /**
927
- * Get full introspection data
928
- */
929
- getIntrospection(): IntrospectionData;
930
- /**
931
- * Freeze registry (prevent further registrations)
932
- */
933
- freeze(): void;
934
- /**
935
- * Check if frozen
936
- */
937
- isFrozen(): boolean;
938
- /**
939
- * Unfreeze registry (allow new registrations)
940
- */
941
- unfreeze(): void;
942
- /**
943
- * Reset registry — clear all resources and unfreeze
944
- */
945
- reset(): void;
946
- /** @internal Alias for unfreeze() */
947
- _unfreeze(): void;
948
- /** @internal Alias for reset() */
949
- _clear(): void;
950
- /**
951
- * Group by key
952
- */
953
- private _groupBy;
954
- }
955
- //#endregion
956
- //#region src/types/handlers.d.ts
957
- /**
958
- * Minimal server accessor — exposes safe, read-only server decorators.
959
- * Allows controller handlers to publish events, log, and audit
960
- * without switching to `raw: true`.
961
- */
962
- interface ServerAccessor {
963
- /** Event bus — publish domain events from any handler */
964
- events?: {
965
- publish: <T>(type: string, payload: T, meta?: Partial<Record<string, unknown>>) => Promise<void>;
966
- };
967
- /** Audit logger — log custom audit entries */
968
- audit?: {
969
- create: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
970
- update: (resource: string, documentId: string, before: Record<string, unknown>, after: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
971
- delete: (resource: string, documentId: string, data: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
972
- custom: (resource: string, documentId: string, action: string, data?: Record<string, unknown>, context?: Record<string, unknown>) => Promise<void>;
973
- };
974
- /** Logger — structured logging */
975
- log?: {
976
- info: (...args: unknown[]) => void;
977
- warn: (...args: unknown[]) => void;
978
- error: (...args: unknown[]) => void;
979
- debug: (...args: unknown[]) => void;
980
- };
981
- /** QueryCache — stale-while-revalidate data cache */
982
- queryCache?: {
983
- get: <T>(key: string) => Promise<{
984
- data: T;
985
- status: 'fresh' | 'stale' | 'miss';
986
- }>;
987
- set: <T>(key: string, data: T, config: {
988
- staleTime?: number;
989
- gcTime?: number;
990
- tags?: string[];
991
- }) => Promise<void>;
992
- getResourceVersion: (resource: string) => Promise<number>;
993
- bumpResourceVersion: (resource: string) => Promise<void>;
994
- };
995
- }
996
- /**
997
- * Request context passed to controller handlers.
998
- *
999
- * **Generic parameters** (all default to safe permissive types so existing code keeps working):
1000
- * - `TBody` — request body shape (default: `unknown`)
1001
- * - `TParams` — route param shape (default: `Record<string, string>`)
1002
- * - `TQuery` — query string shape (default: `Record<string, unknown>`)
1003
- * - `TUser` — authenticated user shape (default: `UserBase`)
1004
- * - `TMetadata` — internal metadata shape (default: `Record<string, unknown>`;
1005
- * override with `ArcInternalMetadata` or your own augmentation when you
1006
- * need typed access to `_scope`, `_policyFilters`, custom hook context, etc.)
1007
- *
1008
- * @example
1009
- * ```typescript
1010
- * // Untyped (default) — req.body is `unknown`, must be narrowed
1011
- * async create(req: IRequestContext) {
1012
- * const data = req.body as Partial<Product>;
1013
- * return { success: true, data: await productRepo.create(data) };
1014
- * }
1015
- *
1016
- * // Typed body — req.body is `CreateProductInput`, narrowing not needed
1017
- * async create(req: IRequestContext<CreateProductInput>) {
1018
- * return { success: true, data: await productRepo.create(req.body) };
1019
- * }
1020
- *
1021
- * // Fully typed — body, route params, query, and metadata
1022
- * async update(
1023
- * req: IRequestContext<
1024
- * Partial<Product>,
1025
- * { id: string },
1026
- * { fields?: string },
1027
- * ArcInternalMetadata
1028
- * >,
1029
- * ) {
1030
- * const fields = req.query.fields?.split(',');
1031
- * const orgId = req.metadata?._scope ? getOrgId(req.metadata._scope) : undefined;
1032
- * return { success: true, data: await productRepo.update(req.params.id, req.body) };
1033
- * }
1034
- * ```
1035
- */
1036
- interface IRequestContext<TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>, TUser extends UserBase = UserBase, TMetadata extends Record<string, unknown> = Record<string, unknown>> {
1037
- /** Route parameters (e.g., { id: '123' }) */
1038
- params: TParams;
1039
- /** Query string parameters */
1040
- query: TQuery;
1041
- /** Request body */
1042
- body: TBody;
1043
- /** Authenticated user or null */
1044
- user: TUser | null;
1045
- /** Request headers */
1046
- headers: Record<string, string | undefined>;
1047
- /** Organization ID (for multi-tenant apps) */
1048
- organizationId?: string;
1049
- /** Team ID (for team-scoped resources) */
1050
- teamId?: string;
1051
- /**
1052
- * Organization/auth context from middleware.
1053
- * Contains orgRoles, orgScope, organizationId, and any custom fields
1054
- * set by the auth adapter or org-scope plugin.
1055
- *
1056
- * @example
1057
- * ```typescript
1058
- * async create(req: IRequestContext) {
1059
- * const roles = req.context?.orgRoles ?? [];
1060
- * if (roles.includes('manager')) { ... }
1061
- * }
1062
- * ```
1063
- */
1064
- context?: RequestContext;
1065
- /**
1066
- * Internal metadata (includes context + Arc internals like `_policyFilters`,
1067
- * `_scope`, `log`). Type as `ArcInternalMetadata` for typed access to Arc's
1068
- * built-in fields, or supply your own interface to layer custom fields.
1069
- */
1070
- metadata?: TMetadata;
1071
- /**
1072
- * Fastify server accessor — publish events, log, and audit
1073
- * from any handler without switching to `raw: true`.
1074
- *
1075
- * @example
1076
- * ```typescript
1077
- * async reschedule(req: IRequestContext) {
1078
- * const result = await repo.reschedule(req.params.id, req.body);
1079
- * await req.server?.events?.publish('interview.rescheduled', { data: result });
1080
- * return { success: true, data: result };
1081
- * }
1082
- * ```
1083
- */
1084
- server?: ServerAccessor;
1085
- }
1086
- /**
1087
- * Standard response from controller handlers
1088
- */
1089
- interface IControllerResponse<T = unknown> {
1090
- /** Operation success status */
1091
- success: boolean;
1092
- /** Response data */
1093
- data?: T;
1094
- /** Error message (when success is false) */
1095
- error?: string;
1096
- /** HTTP status code (default: 200 for success, 400 for error) */
1097
- status?: number;
1098
- /** Additional metadata */
1099
- meta?: Record<string, unknown>;
1100
- /** Error details (for debugging) */
1101
- details?: Record<string, unknown>;
1102
- /** Custom response headers (e.g., X-Total-Count, Link, ETag) */
1103
- headers?: Record<string, string>;
1104
- }
1105
- /**
1106
- * Controller handler — Arc's standard pattern.
1107
- *
1108
- * Receives a request context object, returns IControllerResponse.
1109
- * Use with `raw: false` in routes.
1110
- *
1111
- * **Generic parameters:**
1112
- * - `TResponse` — shape of `IControllerResponse.data` (default: `unknown`)
1113
- * - `TBody` — shape of `req.body` (default: `unknown`)
1114
- * - `TParams` — shape of `req.params` (default: `Record<string, string>`)
1115
- * - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
1116
- *
1117
- * Backward-compatible: `ControllerHandler<Product>` still works (only the
1118
- * response data is typed); add more generics as needed when you want
1119
- * type-safe access to the request body, params, or query string.
1120
- *
1121
- * @example
1122
- * ```typescript
1123
- * // Untyped req — body is unknown, must be narrowed
1124
- * const createProduct: ControllerHandler<Product> = async (req) => {
1125
- * const product = await productRepo.create(req.body as Partial<Product>);
1126
- * return { success: true, data: product, status: 201 };
1127
- * };
1128
- *
1129
- * // Fully typed — body, params, query, and response all inferred
1130
- * const updateProduct: ControllerHandler<
1131
- * Product,
1132
- * Partial<Product>,
1133
- * { id: string },
1134
- * { upsert?: string }
1135
- * > = async (req) => {
1136
- * const upsert = req.query.upsert === "true";
1137
- * const product = await productRepo.update(req.params.id, req.body, { upsert });
1138
- * return { success: true, data: product };
1139
- * };
1140
- *
1141
- * routes: [{
1142
- * method: 'POST',
1143
- * path: '/products',
1144
- * handler: createProduct,
1145
- * permissions: requireAuth(),
1146
- * raw: false, // Arc wraps this into Fastify handler
1147
- * }]
1148
- * ```
1149
- */
1150
- type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
1151
- /**
1152
- * Fastify native handler
1153
- *
1154
- * Standard Fastify request/reply pattern.
1155
- * Use with `raw: true` in routes.
1156
- *
1157
- * @example
1158
- * ```typescript
1159
- * const downloadFile: FastifyHandler = async (request, reply) => {
1160
- * const file = await getFile(request.params.id);
1161
- * reply.header('Content-Type', file.mimeType);
1162
- * return reply.send(file.buffer);
1163
- * };
1164
- *
1165
- * routes: [{
1166
- * method: 'GET',
1167
- * path: '/files/:id/download',
1168
- * handler: downloadFile,
1169
- * permissions: requireAuth(),
1170
- * raw: true, // Use as-is, no wrapping
1171
- * }]
1172
- * ```
1173
- */
1174
- type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
1175
- /**
1176
- * Union type for route handlers
1177
- */
1178
- type RouteHandler = ControllerHandler | FastifyHandler;
1179
- /**
1180
- * Controller interface for CRUD operations (strict)
1181
- */
1182
- interface IController<TDoc = unknown> {
1183
- list(req: IRequestContext): Promise<IControllerResponse<{
1184
- docs: TDoc[];
1185
- total: number;
1186
- }>>;
1187
- get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1188
- create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1189
- update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1190
- delete(req: IRequestContext): Promise<IControllerResponse<{
1191
- message: string;
1192
- }>>;
1193
- }
1194
- /**
1195
- * Flexible controller interface - accepts controllers with any handler style
1196
- * Use this when your controller uses Fastify native handlers
1197
- */
1198
- interface ControllerLike {
1199
- list?: unknown;
1200
- get?: unknown;
1201
- create?: unknown;
1202
- update?: unknown;
1203
- delete?: unknown;
1204
- [key: string]: unknown;
1205
- }
1206
- //#endregion
1207
- //#region src/core/AccessControl.d.ts
1208
- interface AccessControlConfig {
1209
- /** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
1210
- tenantField: string | false;
1211
- /** Primary key field name (default: '_id') */
1212
- idField: string;
1213
- /**
1214
- * Custom filter matching for policy enforcement.
1215
- * Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
1216
- * Falls back to built-in MongoDB-style matching if not provided.
1217
- */
1218
- matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
1219
- }
1220
- /** Minimal repository interface for access-controlled fetch operations */
1221
- interface AccessControlRepository {
1222
- getById(id: string, options?: QueryOptions): Promise<unknown>;
1223
- getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
1224
- }
1225
- declare class AccessControl {
1226
- private readonly tenantField;
1227
- private readonly idField;
1228
- private readonly _adapterMatchesFilter?;
1229
- /** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
1230
- * Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
1231
- private static readonly DANGEROUS_REGEX;
1232
- /** Forbidden paths that could lead to prototype pollution */
1233
- private static readonly FORBIDDEN_PATHS;
1234
- constructor(config: AccessControlConfig);
1235
- /**
1236
- * Build filter for single-item operations (get/update/delete)
1237
- * Combines ID filter with policy/org filters for proper security enforcement
1238
- */
1239
- buildIdFilter(id: string, req: IRequestContext): AnyRecord;
1240
- /**
1241
- * Check if item matches policy filters (for get/update/delete operations)
1242
- * Validates that fetched item satisfies all policy constraints
1243
- *
1244
- * Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
1245
- * otherwise falls back to built-in MongoDB-style matching.
1246
- */
1247
- checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
1248
- /**
1249
- * Check org/tenant scope for a document — uses configurable tenantField.
1250
- *
1251
- * SECURITY: When org scope is active (orgId present), documents that are
1252
- * missing the tenant field are DENIED by default. This prevents legacy or
1253
- * unscoped records from leaking across tenants.
1254
- */
1255
- checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
1256
- /** Check ownership for update/delete (ownedByUser preset) */
1257
- checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
1258
- /**
1259
- * Fetch a single document with full access control enforcement.
1260
- * Combines compound DB filter (ID + org + policy) with post-hoc fallback.
1261
- *
1262
- * Takes repository as a parameter to avoid coupling.
1263
- *
1264
- * Replaces the duplicated pattern in get/update/delete:
1265
- * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
1266
- */
1267
- fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
1268
- /**
1269
- * Post-fetch access control validation for items fetched by non-ID queries
1270
- * (e.g., getBySlug, restore). Applies org scope, policy filters, and
1271
- * ownership checks — the same guarantees as fetchWithAccessControl.
1272
- */
1273
- validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
1274
- /** Extract typed Arc internal metadata from request */
1275
- private _meta;
1276
- /**
1277
- * Check if a value matches a MongoDB query operator
1278
- */
1279
- private matchesOperator;
1280
- /**
1281
- * Check if item matches a single filter condition
1282
- * Supports nested paths (e.g., "owner.id", "metadata.status")
1283
- */
1284
- private matchesFilter;
1285
- /**
1286
- * Built-in MongoDB-style policy filter matching.
1287
- * Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
1288
- */
1289
- private defaultMatchesPolicyFilters;
1290
- /**
1291
- * Get nested value from object using dot notation (e.g., "owner.id")
1292
- * Security: Validates path against forbidden patterns to prevent prototype pollution
1293
- */
1294
- private getNestedValue;
1295
- /**
1296
- * Create a safe RegExp from a string, guarding against ReDoS.
1297
- * Returns null if the pattern is invalid or dangerous.
1298
- */
1299
- private static safeRegex;
1300
- }
1301
- //#endregion
1302
- //#region src/core/BodySanitizer.d.ts
1303
- interface BodySanitizerConfig {
1304
- /** Schema options for field sanitization */
1305
- schemaOptions: RouteSchemaOptions;
1306
- }
1307
- declare class BodySanitizer {
1308
- private schemaOptions;
1309
- constructor(config: BodySanitizerConfig);
1310
- /**
1311
- * Strip readonly and system-managed fields from request body.
1312
- * Prevents clients from overwriting _id, timestamps, __v, etc.
1313
- *
1314
- * Also applies field-level write permissions when the request has
1315
- * field permission metadata.
1316
- */
1317
- sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
1318
- }
1319
- //#endregion
1320
- //#region src/core/QueryResolver.d.ts
1321
- interface QueryResolverConfig {
1322
- /** Query parser instance (default: Arc built-in parser) */
1323
- queryParser?: QueryParserInterface;
1324
- /** Maximum limit for pagination (default: 100) */
1325
- maxLimit?: number;
1326
- /** Default limit for pagination (default: 20) */
1327
- defaultLimit?: number;
1328
- /** Default sort field (default: '-createdAt') */
1329
- defaultSort?: string;
1330
- /** Schema options for field sanitization */
1331
- schemaOptions?: RouteSchemaOptions;
1332
- /** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
1333
- tenantField?: string | false;
1334
- }
1335
- declare class QueryResolver {
1336
- private queryParser;
1337
- private maxLimit;
1338
- private defaultLimit;
1339
- private defaultSort;
1340
- private schemaOptions;
1341
- private tenantField;
1342
- constructor(config?: QueryResolverConfig);
1343
- /**
1344
- * Resolve a request into parsed query options -- ONE parse per request.
1345
- * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
1346
- */
1347
- resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
1348
- /**
1349
- * Sanitize select — preserves the input format (string, array, or object).
1350
- * This is critical for db-agnostic support: MongoKit returns object projections,
1351
- * Mongoose uses space-separated strings, SQL adapters may use arrays.
1352
- */
1353
- private sanitizeSelectAny;
1354
- /** Sanitize populate fields */
1355
- private sanitizePopulate;
1356
- /** Sanitize advanced populate options against allowedPopulate */
1357
- private sanitizePopulateOptions;
1358
- /**
1359
- * Sanitize lookup/join options.
1360
- * If schemaOptions.query.allowedLookups is set, only those collections are allowed.
1361
- * Validates lookup structure to prevent injection.
1362
- */
1363
- private sanitizeLookups;
1364
- /** Get blocked fields from schema options */
1365
- private getBlockedFields;
1366
- }
1367
- //#endregion
1368
- //#region src/core/BaseController.d.ts
1369
- interface BaseControllerOptions {
1370
- /** Schema options for field sanitization */
1371
- schemaOptions?: RouteSchemaOptions;
1372
- /**
1373
- * Query parser instance.
1374
- * Default: Arc built-in query parser (adapter-agnostic).
1375
- * Swap in MongoKit QueryParser, pgkit parser, etc.
1376
- */
1377
- queryParser?: QueryParserInterface;
1378
- /** Maximum limit for pagination (default: 100) */
1379
- maxLimit?: number;
1380
- /** Default limit for pagination (default: 20) */
1381
- defaultLimit?: number;
1382
- /** Default sort field (default: '-createdAt') */
1383
- defaultSort?: string;
1384
- /** Resource name for hook execution (e.g., 'product' -> 'product.created') */
1385
- resourceName?: string;
1386
- /**
1387
- * Field name used for multi-tenant scoping (default: 'organizationId').
1388
- * Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
1389
- * Set to `false` to disable org filtering for platform-universal resources.
1390
- */
1391
- tenantField?: string | false;
1392
- /**
1393
- * Primary key field name (default: '_id').
1394
- *
1395
- * If not set, the controller auto-derives it from the repository's own
1396
- * `idField` property (e.g. MongoKit's `Repository({ idField: 'id' })`),
1397
- * so you only need to configure it in one place.
1398
- *
1399
- * Set explicitly to override the repo's setting (e.g. `'_id'` to opt out
1400
- * of native pass-through and force the slug-translation path).
1401
- *
1402
- * Override for non-MongoDB adapters (e.g., 'id' for SQL databases).
1403
- */
1404
- idField?: string;
1405
- /**
1406
- * Custom filter matching for policy enforcement.
1407
- * Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
1408
- * Falls back to built-in MongoDB-style matching if not provided.
1409
- */
1410
- matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
1411
- /** Cache configuration for the resource */
1412
- cache?: ResourceCacheConfig;
1413
- /** Internal preset fields map (slug, tree, etc.) */
1414
- presetFields?: {
1415
- slugField?: string;
1416
- parentField?: string;
1417
- };
1418
- }
1419
- /**
1420
- * Framework-agnostic base controller implementing IController.
1421
- *
1422
- * Composes AccessControl, BodySanitizer, and QueryResolver for clean
1423
- * separation of concerns. CRUD methods delegate directly to these
1424
- * composed classes — no intermediate wrapper methods.
860
+ * Receives a request context object, returns IControllerResponse.
861
+ * Use with `raw: false` in routes.
1425
862
  *
1426
- * @template TDoc - The document type
1427
- * @template TRepository - The repository type (defaults to RepositoryLike)
1428
- */
1429
- declare class BaseController<TDoc = AnyRecord, TRepository extends RepositoryLike = RepositoryLike> implements IController<TDoc> {
1430
- protected repository: TRepository;
1431
- protected schemaOptions: RouteSchemaOptions;
1432
- protected queryParser: QueryParserInterface;
1433
- protected maxLimit: number;
1434
- protected defaultLimit: number;
1435
- protected defaultSort: string;
1436
- protected resourceName?: string;
1437
- protected tenantField: string | false;
1438
- protected idField: string;
1439
- /** Composable access control (ID filtering, policy checks, org scope, ownership) */
1440
- readonly accessControl: AccessControl;
1441
- /** Composable body sanitization (field permissions, system fields) */
1442
- readonly bodySanitizer: BodySanitizer;
1443
- /** Composable query resolution (parsing, pagination, sort, select/populate) */
1444
- readonly queryResolver: QueryResolver;
1445
- private _matchesFilter?;
1446
- private _presetFields;
1447
- private _cacheConfig?;
1448
- constructor(repository: TRepository, options?: BaseControllerOptions);
1449
- /**
1450
- * Get the tenant field name if multi-tenant scoping is enabled.
1451
- * Returns `undefined` when `tenantField` is `false` (platform-universal mode).
1452
- *
1453
- * Use this in subclass overrides instead of accessing `this.tenantField` directly
1454
- * to avoid TypeScript indexing errors with `string | false`.
1455
- */
1456
- protected getTenantField(): string | undefined;
1457
- /** Extract typed Arc internal metadata from request */
1458
- private meta;
1459
- /** Get hook system from request context (instance-scoped) */
1460
- private getHooks;
1461
- /**
1462
- * Resolve the repository primary key for mutation calls (update/delete/restore).
1463
- *
1464
- * When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
1465
- * the default behavior is to translate the route id → the fetched doc's `_id`
1466
- * because most Mongo repositories key their mutation methods off `_id`.
1467
- *
1468
- * Exception: if the repository itself exposes a matching `idField` property
1469
- * (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
1470
- * repository already knows how to look up by that field — so we pass the
1471
- * route id through unchanged and skip the translation.
1472
- *
1473
- * This makes `defineResource({ idField: 'id' })` work end-to-end with repos
1474
- * that natively support custom primary keys, without breaking the slug-style
1475
- * aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
1476
- */
1477
- private resolveRepoId;
1478
- /** Resolve cache config for a specific operation, merging per-op overrides */
1479
- private resolveCacheConfig;
1480
- /**
1481
- * Extract user/org IDs from request for cache key scoping.
1482
- * Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
1483
- * Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
1484
- */
1485
- private cacheScope;
1486
- list(req: IRequestContext): Promise<IControllerResponse<PaginatedResult<TDoc>>>;
1487
- /** Execute list query through hooks (extracted for cache revalidation) */
1488
- private executeListQuery;
1489
- get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1490
- /** Execute get query through hooks (extracted for cache revalidation) */
1491
- private executeGetQuery;
1492
- create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1493
- update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1494
- delete(req: IRequestContext): Promise<IControllerResponse<{
1495
- message: string;
1496
- id?: string;
1497
- soft?: boolean;
1498
- }>>;
1499
- getBySlug(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1500
- getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
1501
- restore(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
1502
- getTree(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
1503
- getChildren(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
1504
- bulkCreate(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
1505
- /**
1506
- * Build a tenant-scoped filter for bulk update/delete.
1507
- *
1508
- * Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
1509
- * - Always merge `_policyFilters` (from permission middleware)
1510
- * - When `tenantField` is set AND a `member` scope is present, add the
1511
- * org filter so cross-tenant data can't be touched.
1512
- * - When the scope is `elevated` (platform admin), no org filter is
1513
- * applied — admins can bulk-update across orgs intentionally.
1514
- * - When the scope is `public` on a tenant-scoped resource, deny.
1515
- * - When NO scope is present at all (e.g., direct controller calls in
1516
- * unit tests, or app routes without auth middleware), the controller
1517
- * stays lenient — it's the middleware layer's job to fail-close.
1518
- * Apps that want fail-close on bulk routes should run the multi-tenant
1519
- * preset middleware (or equivalent) ahead of these handlers.
1520
- *
1521
- * Returns the merged filter, or `null` when access must be denied.
1522
- */
1523
- private buildBulkFilter;
1524
- /**
1525
- * Sanitize a bulk update data payload through the same write-permission
1526
- * pipeline as single-doc update(). Handles both shapes:
1527
- *
1528
- * - Flat: `{ name: 'x', status: 'y' }`
1529
- * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
1530
- *
1531
- * For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
1532
- * system fields, systemManaged/readonly/immutable rules, AND field-level
1533
- * write permissions are enforced. Without this, a tenant-scoped user could
1534
- * pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
1535
- *
1536
- * Returns the sanitized payload along with the list of stripped fields for
1537
- * audit/error reporting.
1538
- */
1539
- private sanitizeBulkUpdateData;
1540
- bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
1541
- matchedCount: number;
1542
- modifiedCount: number;
1543
- }>>;
1544
- /**
1545
- * Bulk delete by `filter` or `ids`.
1546
- *
1547
- * Body shape (one of):
1548
- * - `{ filter: { status: 'archived' } }` — delete by query filter
1549
- * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
1550
- *
1551
- * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
1552
- * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
1553
- * UUID, etc.). Tenant scope and policy filters are merged in either way,
1554
- * so cross-tenant deletes are blocked at the controller layer.
1555
- *
1556
- * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
1557
- * fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
1558
- * NOT fire for bulk operations; use the single-doc `delete()` if you need
1559
- * them, or subscribe to the bulk lifecycle event from the events plugin.
1560
- */
1561
- bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
1562
- deletedCount: number;
1563
- }>>;
1564
- }
1565
- //#endregion
1566
- //#region src/types/index.d.ts
1567
- declare module 'fastify' {
1568
- interface FastifyRequest {
1569
- /** Request scope — set by auth adapter, read by permissions/presets/guards */
1570
- scope: RequestScope;
1571
- /**
1572
- * Current user — set by auth adapter (Better Auth, JWT, custom).
1573
- * `undefined` on public routes (`auth: false`) or unauthenticated requests.
1574
- * Guard with `if (request.user)` on routes that allow anonymous access.
1575
- *
1576
- * Note: kept as required (not `user?`) because `@fastify/jwt` declares it
1577
- * as required — declaration merges must have identical modifiers.
1578
- * The `| undefined` in the type achieves the same DX: TypeScript will
1579
- * flag unguarded access like `request.user.id` as possibly undefined.
1580
- */
1581
- user: Record<string, unknown> | undefined;
1582
- /** Policy-injected query filters (e.g. ownership, org-scoping) */
1583
- _policyFilters?: Record<string, unknown>;
1584
- /** Field mask — fields to include/exclude in responses */
1585
- fieldMask?: {
1586
- include?: string[];
1587
- exclude?: string[];
1588
- };
1589
- /** Arbitrary policy metadata for downstream consumers */
1590
- policyMetadata?: Record<string, unknown>;
1591
- /** Document loaded by policy middleware for ownership checks */
1592
- document?: unknown;
1593
- /** Ownership check context (field name + user field) */
1594
- _ownershipCheck?: Record<string, unknown>;
1595
- }
1596
- }
1597
- /**
1598
- * Typed Fastify request with Arc decorations.
863
+ * **Generic parameters:**
864
+ * - `TResponse` shape of `IControllerResponse.data` (default: `unknown`)
865
+ * - `TBody` — shape of `req.body` (default: `unknown`)
866
+ * - `TParams` — shape of `req.params` (default: `Record<string, string>`)
867
+ * - `TQuery` — shape of `req.query` (default: `Record<string, unknown>`)
1599
868
  *
1600
- * Use this in `raw: true` handlers instead of `(req as any).user`.
869
+ * Backward-compatible: `ControllerHandler<Product>` still works (only the
870
+ * response data is typed); add more generics as needed when you want
871
+ * type-safe access to the request body, params, or query string.
1601
872
  *
1602
873
  * @example
1603
874
  * ```typescript
1604
- * import type { ArcRequest } from '@classytic/arc';
875
+ * // Untyped req body is unknown, must be narrowed
876
+ * const createProduct: ControllerHandler<Product> = async (req) => {
877
+ * const product = await productRepo.create(req.body as Partial<Product>);
878
+ * return { success: true, data: product, status: 201 };
879
+ * };
1605
880
  *
1606
- * handler: async (req: ArcRequest, reply: FastifyReply) => {
1607
- * req.user?.id; // typed
1608
- * req.scope.organizationId; // typed (when member)
1609
- * req.signal; // AbortSignal (Fastify 5)
1610
- * }
881
+ * // Fully typed — body, params, query, and response all inferred
882
+ * const updateProduct: ControllerHandler<
883
+ * Product,
884
+ * Partial<Product>,
885
+ * { id: string },
886
+ * { upsert?: string }
887
+ * > = async (req) => {
888
+ * const upsert = req.query.upsert === "true";
889
+ * const product = await productRepo.update(req.params.id, req.body, { upsert });
890
+ * return { success: true, data: product };
891
+ * };
892
+ *
893
+ * routes: [{
894
+ * method: 'POST',
895
+ * path: '/products',
896
+ * handler: createProduct,
897
+ * permissions: requireAuth(),
898
+ * raw: false, // Arc wraps this into Fastify handler
899
+ * }]
1611
900
  * ```
1612
901
  */
1613
- type ArcRequest = FastifyRequest & {
1614
- scope: RequestScope;
1615
- user: Record<string, unknown> | undefined;
1616
- signal: AbortSignal;
1617
- };
902
+ type ControllerHandler<TResponse = unknown, TBody = unknown, TParams extends Record<string, string> = Record<string, string>, TQuery extends Record<string, unknown> = Record<string, unknown>> = (req: IRequestContext<TBody, TParams, TQuery>) => Promise<IControllerResponse<TResponse>>;
1618
903
  /**
1619
- * Response envelope helper — wraps data in Arc's standard `{ success, data }` format.
904
+ * Fastify native handler
905
+ *
906
+ * Standard Fastify request/reply pattern.
907
+ * Use with `raw: true` in routes.
1620
908
  *
1621
909
  * @example
1622
910
  * ```typescript
1623
- * import { envelope } from '@classytic/arc';
911
+ * const downloadFile: FastifyHandler = async (request, reply) => {
912
+ * const file = await getFile(request.params.id);
913
+ * reply.header('Content-Type', file.mimeType);
914
+ * return reply.send(file.buffer);
915
+ * };
1624
916
  *
1625
- * handler: async (req, reply) => {
1626
- * const data = await getResults();
1627
- * return envelope(data);
1628
- * // → { success: true, data }
1629
- * }
917
+ * routes: [{
918
+ * method: 'GET',
919
+ * path: '/files/:id/download',
920
+ * handler: downloadFile,
921
+ * permissions: requireAuth(),
922
+ * raw: true, // Use as-is, no wrapping
923
+ * }]
1630
924
  * ```
1631
925
  */
1632
- declare function envelope<T>(data: T, meta?: Record<string, unknown>): {
1633
- success: true;
1634
- data: T;
1635
- [key: string]: unknown;
1636
- };
1637
- type AnyRecord = Record<string, unknown>;
1638
- /** MongoDB ObjectId — accepts string or any object with a `toString()` (e.g. mongoose ObjectId). */
1639
- type ObjectId = string | {
1640
- toString(): string;
1641
- };
926
+ type FastifyHandler<RouteGeneric extends Record<string, unknown> = Record<string, unknown>> = (request: FastifyRequest<RouteGeneric>, reply: FastifyReply) => Promise<unknown> | unknown;
1642
927
  /**
1643
- * Flexible user type that accepts any object with id/ID properties.
1644
- * Use this instead of `any` when dealing with user objects.
1645
- * Re-exports UserBase from permissions module for convenience.
1646
- * The actual user structure is defined by your app's auth system.
928
+ * Union type for route handlers
1647
929
  */
1648
- type UserLike = UserBase & {
1649
- /** User email (optional) */email?: string;
1650
- };
930
+ type RouteHandler = ControllerHandler | FastifyHandler;
1651
931
  /**
1652
- * Extract user ID from a user object (supports both id and _id)
932
+ * Controller interface for CRUD operations (strict)
1653
933
  */
1654
- declare function getUserId(user: UserLike | null | undefined): string | undefined;
1655
- type CrudController<TDoc> = IController<TDoc>;
1656
- interface ApiResponse<T = unknown> {
1657
- success: boolean;
1658
- data?: T;
1659
- error?: string;
1660
- message?: string;
1661
- meta?: Record<string, unknown>;
1662
- }
1663
- interface UserOrganization {
1664
- userId: string;
1665
- organizationId: string;
1666
- [key: string]: unknown;
1667
- }
1668
- interface JWTPayload {
1669
- sub: string;
1670
- [key: string]: unknown;
934
+ interface IController<TDoc = unknown> {
935
+ list(req: IRequestContext): Promise<IControllerResponse<{
936
+ docs: TDoc[];
937
+ total: number;
938
+ }>>;
939
+ get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
940
+ create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
941
+ update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
942
+ delete(req: IRequestContext): Promise<IControllerResponse<{
943
+ message: string;
944
+ }>>;
1671
945
  }
1672
- interface RequestContext {
1673
- operation?: string;
1674
- user?: unknown;
1675
- filters?: Record<string, unknown>;
946
+ /**
947
+ * Flexible controller interface - accepts controllers with any handler style
948
+ * Use this when your controller uses Fastify native handlers
949
+ */
950
+ interface ControllerLike {
951
+ list?: unknown;
952
+ get?: unknown;
953
+ create?: unknown;
954
+ update?: unknown;
955
+ delete?: unknown;
1676
956
  [key: string]: unknown;
1677
957
  }
958
+ //#endregion
959
+ //#region src/types/resource.d.ts
960
+ /** Standard controller type alias for CRUD operations. */
961
+ type CrudController<TDoc> = IController<TDoc>;
1678
962
  /**
1679
- * Internal metadata shape injected by Arc's Fastify adapter.
1680
- * Extends RequestContext with known internal fields so controllers
1681
- * can access them without `as AnyRecord` casts.
963
+ * Per-resource cache configuration for QueryCache. Enables
964
+ * stale-while-revalidate, auto-invalidation on mutations, and
965
+ * cross-resource tag-based invalidation.
1682
966
  */
1683
- interface ArcInternalMetadata extends RequestContext {
1684
- /** Policy filters from permission middleware */
1685
- _policyFilters?: Record<string, unknown>;
1686
- /** Request scope from scope resolution */
1687
- _scope?: RequestScope;
1688
- /** Ownership check config from ownedByUser preset */
1689
- _ownershipCheck?: {
1690
- field: string;
1691
- userId: string;
967
+ interface ResourceCacheConfig {
968
+ /** Seconds data is "fresh" (no revalidation). Default: 0 */
969
+ staleTime?: number;
970
+ /** Seconds stale data stays cached (SWR window). Default: 60 */
971
+ gcTime?: number;
972
+ /** Per-operation overrides */
973
+ list?: {
974
+ staleTime?: number;
975
+ gcTime?: number;
1692
976
  };
1693
- /** Arc instance references (hooks, field permissions, etc.) */
1694
- arc?: {
1695
- hooks?: HookSystem;
1696
- fields?: FieldPermissionMap;
1697
- [key: string]: unknown;
977
+ byId?: {
978
+ staleTime?: number;
979
+ gcTime?: number;
1698
980
  };
981
+ /** Tags for cross-resource invalidation grouping */
982
+ tags?: string[];
983
+ /**
984
+ * Cross-resource invalidation: event pattern → tag targets.
985
+ * @example { 'category.*': ['catalog'] }
986
+ */
987
+ invalidateOn?: Record<string, string[]>;
988
+ /** Disable caching for this resource */
989
+ disabled?: boolean;
1699
990
  }
1700
- /**
1701
- * Controller-level query options - parsed from request query string
1702
- * Includes pagination, filtering, and context data
1703
- */
1704
- interface ControllerQueryOptions {
1705
- page?: number;
1706
- limit?: number;
1707
- sort?: string | Record<string, 1 | -1>;
1708
- /** Simple populate (comma-separated string or array) */
1709
- populate?: string | string[] | Record<string, unknown>;
991
+ interface RateLimitConfig {
992
+ /** Maximum number of requests allowed within the time window */
993
+ max: number;
994
+ /** Time window for rate limiting (e.g., '1 minute', '15 seconds') */
995
+ timeWindow: string;
996
+ }
997
+ interface RouteSchemaOptions {
998
+ hiddenFields?: string[];
999
+ readonlyFields?: string[];
1000
+ requiredFields?: string[];
1001
+ optionalFields?: string[];
1002
+ excludeFields?: string[];
1710
1003
  /**
1711
- * Advanced populate options (Mongoose-compatible)
1712
- * When set, takes precedence over simple `populate`
1004
+ * Fields allowed for filtering in list operations. MCP auto-derives
1005
+ * from `QueryParser.allowedFilterFields` when not set explicitly.
1713
1006
  */
1714
- populateOptions?: PopulateOption[];
1007
+ filterableFields?: string[];
1008
+ fieldRules?: Record<string, {
1009
+ systemManaged?: boolean;
1010
+ hidden?: boolean;
1011
+ immutable?: boolean;
1012
+ immutableAfterCreate?: boolean;
1013
+ optional?: boolean; /** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
1014
+ minLength?: number; /** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
1015
+ maxLength?: number; /** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
1016
+ min?: number; /** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
1017
+ max?: number; /** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
1018
+ pattern?: string; /** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
1019
+ enum?: ReadonlyArray<string | number>; /** Human-readable description — auto-maps to OpenAPI `description` */
1020
+ description?: string;
1021
+ [key: string]: unknown;
1022
+ }>;
1023
+ /** Query parameter schema for OpenAPI */
1024
+ query?: Record<string, unknown>;
1715
1025
  /**
1716
- * Lookup/join options (database-agnostic).
1717
- * MongoKit maps these to $lookup aggregation pipeline stages.
1718
- * Future adapters (PrismaKit, PgKit) would map to SQL JOINs.
1719
- *
1720
- * @example
1721
- * URL: ?lookup[category][from]=categories&lookup[category][localField]=categorySlug&lookup[category][foreignField]=slug
1026
+ * When `true`, emitted CRUD body schemas set `additionalProperties: false`
1027
+ * so AJV rejects unknown fields on create / update. Honored by kit schema
1028
+ * generators that receive this options bag (sqlitekit's
1029
+ * `buildCrudSchemasFromTable`, pgkit's equivalent). Mongoose-based
1030
+ * generators may ignore it — Mongoose schemas are inherently strict at
1031
+ * the model level.
1722
1032
  */
1723
- lookups?: LookupOption[];
1724
- select?: string | string[] | Record<string, 0 | 1>;
1725
- filters?: Record<string, unknown>;
1726
- search?: string;
1727
- lean?: boolean;
1728
- after?: string;
1729
- user?: unknown;
1730
- context?: Record<string, unknown>;
1731
- /** Allow additional options */
1732
- [key: string]: unknown;
1033
+ strictAdditionalProperties?: boolean;
1733
1034
  }
1734
- /**
1735
- * Database-agnostic lookup/join option.
1736
- * Parsed from URL: ?lookup[alias][from]=collection&lookup[alias][localField]=field&lookup[alias][foreignField]=field
1737
- *
1738
- * MongoKit maps this to MongoDB $lookup aggregation.
1739
- * Future adapters would map to SQL JOINs or Prisma includes.
1740
- */
1741
- interface LookupOption {
1742
- /** Source collection/table to join from */
1743
- from: string;
1744
- /** Local field to match on */
1745
- localField: string;
1746
- /** Foreign field to match on */
1747
- foreignField: string;
1748
- /** Alias for the joined data (defaults to the lookup key) */
1749
- as?: string;
1750
- /** Return a single object instead of array (default: false) */
1751
- single?: boolean;
1752
- /** Field selection on the joined collection (comma-separated string or projection object) */
1753
- select?: string | Record<string, 0 | 1>;
1035
+ interface FieldRule {
1036
+ field: string;
1037
+ required?: boolean;
1038
+ readonly?: boolean;
1039
+ hidden?: boolean;
1754
1040
  }
1755
1041
  /**
1756
- * Mongoose-compatible populate option for advanced field selection
1757
- * Used when you need to select specific fields from populated documents
1758
- *
1759
- * @example
1760
- * ```typescript
1761
- * // URL: ?populate[author][select]=name,email
1762
- * // Generates: { path: 'author', select: 'name email' }
1763
- * ```
1042
+ * CRUD route schemas (Fastify native format). Each slot accepts a plain
1043
+ * JSON Schema object **or** a Zod v4 schema Arc's `convertRouteSchema`
1044
+ * feature-detects at runtime. Slot values are typed `unknown` so
1045
+ * class-based Zod schemas assign without casts.
1764
1046
  */
1765
- interface PopulateOption {
1766
- /** Field path to populate */
1767
- path: string;
1768
- /** Fields to select (space-separated) */
1769
- select?: string;
1770
- /** Filter conditions for populated documents */
1771
- match?: Record<string, unknown>;
1772
- /** Query options (limit, sort, skip) */
1773
- options?: {
1774
- limit?: number;
1775
- sort?: Record<string, 1 | -1>;
1776
- skip?: number;
1047
+ interface CrudSchemas {
1048
+ /** GET / list */
1049
+ list?: {
1050
+ querystring?: unknown;
1051
+ response?: Record<number, unknown>;
1052
+ [key: string]: unknown;
1053
+ };
1054
+ /** GET /:id get one */
1055
+ get?: {
1056
+ params?: unknown;
1057
+ response?: Record<number, unknown>;
1058
+ [key: string]: unknown;
1059
+ };
1060
+ /** POST / — create */
1061
+ create?: {
1062
+ body?: unknown;
1063
+ response?: Record<number, unknown>;
1064
+ [key: string]: unknown;
1777
1065
  };
1778
- /** Nested populate configuration */
1779
- populate?: PopulateOption;
1066
+ /** PATCH /:id update */
1067
+ update?: {
1068
+ params?: unknown;
1069
+ body?: unknown;
1070
+ response?: Record<number, unknown>;
1071
+ [key: string]: unknown;
1072
+ };
1073
+ /** DELETE /:id — delete */
1074
+ delete?: {
1075
+ params?: unknown;
1076
+ response?: Record<number, unknown>;
1077
+ [key: string]: unknown;
1078
+ };
1079
+ [key: string]: unknown;
1780
1080
  }
1781
- /**
1782
- * Parsed query result from QueryParser
1783
- * Includes pagination, sorting, filtering, etc.
1784
- *
1785
- * The index signature allows custom query parsers (like MongoKit's QueryParser)
1786
- * to add additional fields without breaking Arc's type system.
1787
- */
1788
- interface ParsedQuery {
1789
- filters?: Record<string, unknown>;
1790
- limit?: number;
1791
- sort?: string | Record<string, 1 | -1>;
1792
- /** Simple populate (comma-separated string or array) */
1793
- populate?: string | string[] | Record<string, unknown>;
1794
- /**
1795
- * Advanced populate options (Mongoose-compatible)
1796
- * When set, takes precedence over simple `populate`
1797
- * @example [{ path: 'author', select: 'name email' }]
1798
- */
1799
- populateOptions?: PopulateOption[];
1081
+ interface OpenApiSchemas {
1082
+ entity?: unknown;
1083
+ createBody?: unknown;
1084
+ updateBody?: unknown;
1085
+ params?: unknown;
1086
+ listQuery?: unknown;
1800
1087
  /**
1801
- * Lookup/join options from MongoKit QueryParser or custom parsers.
1802
- * Maps to $lookup in MongoDB, JOINs in SQL adapters.
1088
+ * Explicit response schema for OpenAPI documentation. Auto-generated
1089
+ * from `createBody` if omitted. Does NOT affect Fastify serialization.
1803
1090
  */
1804
- lookups?: LookupOption[];
1805
- search?: string;
1806
- page?: number;
1807
- after?: string;
1808
- select?: string | string[] | Record<string, 0 | 1>;
1809
- /** Allow additional fields from custom query parsers */
1091
+ response?: unknown;
1810
1092
  [key: string]: unknown;
1811
1093
  }
1094
+ type CrudRouteKey = "list" | "get" | "create" | "update" | "delete";
1095
+ interface MiddlewareConfig {
1096
+ list?: MiddlewareHandler[];
1097
+ get?: MiddlewareHandler[];
1098
+ create?: MiddlewareHandler[];
1099
+ update?: MiddlewareHandler[];
1100
+ delete?: MiddlewareHandler[];
1101
+ [key: string]: MiddlewareHandler[] | undefined;
1102
+ }
1103
+ /** HTTP methods for custom routes. */
1104
+ type RouteMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
1105
+ /** MCP tool configuration for a route or action. */
1106
+ interface RouteMcpConfig {
1107
+ /** Override auto-generated tool description */
1108
+ readonly description?: string;
1109
+ /** MCP tool annotations */
1110
+ readonly annotations?: {
1111
+ readonly readOnlyHint?: boolean;
1112
+ readonly destructiveHint?: boolean;
1113
+ readonly idempotentHint?: boolean;
1114
+ readonly openWorldHint?: boolean;
1115
+ };
1116
+ }
1812
1117
  /**
1813
- * Query Parser Interface
1814
- * Implement this to create custom query parsers
1118
+ * Route definition — single custom-route shape (user-facing + internal).
1815
1119
  *
1816
- * @example MongoKit QueryParser
1817
- * ```typescript
1818
- * import { QueryParser } from '@classytic/mongokit';
1819
- * const queryParser = new QueryParser();
1820
- * ```
1120
+ * - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
1121
+ * - `handler: function` → inline handler → full Arc pipeline + MCP tool
1122
+ * - `raw: true` raw Fastify handler → no pipeline, no MCP by default
1821
1123
  */
1822
- interface QueryParserInterface {
1823
- parse(query: Record<string, unknown> | null | undefined): ParsedQuery;
1124
+ interface RouteDefinition {
1125
+ readonly method: RouteMethod;
1126
+ /** Path relative to resource prefix */
1127
+ readonly path: string;
1824
1128
  /**
1825
- * Optional: Export OpenAPI schema for query parameters
1826
- * Use this to document query parameters in OpenAPI/Swagger
1129
+ * Route handler.
1130
+ * - String: controller method name (Arc pipeline)
1131
+ * - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (Arc pipeline)
1132
+ * - Function with `raw: true`: raw Fastify handler `(request, reply)`
1827
1133
  */
1828
- getQuerySchema?(): {
1829
- type: 'object';
1830
- properties: Record<string, unknown>;
1831
- required?: string[];
1832
- };
1134
+ readonly handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
1135
+ /** Permission check — REQUIRED */
1136
+ readonly permissions: PermissionCheck;
1833
1137
  /**
1834
- * Optional: Allowed filter fields whitelist.
1835
- * When set, MCP auto-derives `filterableFields` from this
1836
- * if `schemaOptions.filterableFields` is not explicitly configured.
1138
+ * Raw mode bypasses Arc pipeline. Handler receives raw Fastify
1139
+ * request/reply. Default: false.
1837
1140
  */
1838
- allowedFilterFields?: readonly string[];
1141
+ readonly raw?: boolean;
1142
+ /** Logical operation name (pipeline keys, MCP tool naming). */
1143
+ readonly operation?: string;
1144
+ /** OpenAPI summary */
1145
+ readonly summary?: string;
1146
+ /** OpenAPI description */
1147
+ readonly description?: string;
1148
+ /** OpenAPI tags */
1149
+ readonly tags?: string[];
1150
+ /** Route-level middleware */
1151
+ readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
1152
+ /** Pre-auth handlers (run before authentication) */
1153
+ readonly preAuth?: RouteHandlerMethod[];
1154
+ /** SSE streaming mode */
1155
+ readonly streamResponse?: boolean;
1839
1156
  /**
1840
- * Optional: Allowed filter operators whitelist.
1841
- * Used by MCP to enrich list tool descriptions with available operators.
1842
- * Values are human-readable keys: 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', etc.
1157
+ * Fastify route schema. Each slot (`body`, `querystring`, `params`,
1158
+ * `headers`, `response[status]`) accepts a plain JSON Schema object
1159
+ * **or** a Zod v4 schema Arc auto-converts via `convertRouteSchema`.
1843
1160
  */
1844
- allowedOperators?: readonly string[];
1161
+ readonly schema?: {
1162
+ body?: unknown;
1163
+ querystring?: unknown;
1164
+ params?: unknown;
1165
+ headers?: unknown;
1166
+ response?: Record<number | string, unknown>;
1167
+ [key: string]: unknown;
1168
+ };
1845
1169
  /**
1846
- * Optional: Allowed sort fields whitelist.
1847
- * Used by MCP to describe available sort options in list tool descriptions.
1170
+ * MCP tool generation:
1171
+ * - omitted/true: auto-generate (non-raw routes only)
1172
+ * - false: skip MCP
1173
+ * - object: explicit config
1848
1174
  */
1849
- allowedSortFields?: readonly string[];
1850
- }
1851
- interface FastifyRequestExtras {
1852
- user?: Record<string, unknown>;
1853
- }
1854
- interface RequestWithExtras extends FastifyRequest {
1175
+ readonly mcp?: boolean | RouteMcpConfig;
1855
1176
  /**
1856
- * Arc metadata - set by createCrudRouter
1857
- * Contains resource configuration and schema options
1177
+ * MCP handler for raw routes — parallel entry point for MCP without
1178
+ * changing the HTTP handler.
1858
1179
  */
1859
- arc?: {
1860
- resourceName?: string;
1861
- schemaOptions?: RouteSchemaOptions;
1862
- permissions?: ResourcePermissions;
1863
- };
1864
- context?: Record<string, unknown>;
1865
- _policyFilters?: Record<string, unknown>;
1866
- fieldMask?: {
1867
- include?: string[];
1868
- exclude?: string[];
1869
- };
1870
- _ownershipCheck?: Record<string, unknown>;
1871
- }
1872
- type FastifyWithAuth = FastifyInstance & {
1873
- authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
1874
- };
1875
- /**
1876
- * Arc core decorator interface
1877
- * Added by arcCorePlugin to provide instance-scoped hooks and registry
1878
- */
1879
- interface ArcDecorator {
1880
- /** Instance-scoped hook system */
1881
- hooks: HookSystem;
1882
- /** Instance-scoped resource registry */
1883
- registry: ResourceRegistry;
1884
- /** Whether event emission is enabled */
1885
- emitEvents: boolean;
1180
+ readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
1181
+ content: Array<{
1182
+ type: string;
1183
+ text: string;
1184
+ }>;
1185
+ isError?: boolean;
1186
+ }>;
1886
1187
  }
1887
1188
  /**
1888
- * Events decorator interface
1889
- * Added by eventPlugin to provide event pub/sub
1189
+ * Action handler function for state transitions. Receives the resource
1190
+ * ID, action-specific data, and the request.
1890
1191
  */
1891
- interface EventsDecorator {
1892
- /** Publish an event */
1893
- publish: <T>(type: string, payload: T, meta?: Partial<{
1894
- id: string;
1895
- timestamp: Date;
1896
- }>) => Promise<void>;
1897
- /** Subscribe to events */
1898
- subscribe: (pattern: string, handler: (event: unknown) => void | Promise<void>) => Promise<() => void>;
1899
- /** Get transport name */
1900
- transportName: string;
1192
+ type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
1193
+ /** Full action configuration with handler, permissions, and schema. */
1194
+ interface ActionDefinition {
1195
+ readonly handler: ActionHandlerFn;
1196
+ /** Per-action permission (overrides resource-level `actionPermissions`) */
1197
+ readonly permissions?: PermissionCheck;
1198
+ /**
1199
+ * JSON Schema or Zod v4 schema for action-specific body fields.
1200
+ * Per-field values are typed `unknown` so Zod class instances assign
1201
+ * without casts.
1202
+ */
1203
+ readonly schema?: Record<string, unknown>;
1204
+ /** Description for OpenAPI docs and MCP tool */
1205
+ readonly description?: string;
1206
+ /**
1207
+ * MCP tool generation:
1208
+ * - omitted/true: auto-generate
1209
+ * - false: skip
1210
+ * - object: explicit config
1211
+ */
1212
+ readonly mcp?: boolean | RouteMcpConfig;
1901
1213
  }
1214
+ /** Action config: bare handler function OR full ActionDefinition. */
1215
+ type ActionEntry = ActionHandlerFn | ActionDefinition;
1216
+ /** Actions configuration map. */
1217
+ type ActionsMap = Record<string, ActionEntry>;
1902
1218
  /**
1903
- * Fastify instance with Arc decorators
1904
- * Arc adds these decorators via plugins/presets
1219
+ * Hook context passed to resource-level hook handlers. Mirrors
1220
+ * HookSystem's HookContext but with a simpler API for inline use.
1905
1221
  */
1906
- type FastifyWithDecorators = FastifyInstance & {
1907
- arc?: ArcDecorator;
1908
- events?: EventsDecorator;
1909
- authenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
1910
- optionalAuthenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
1911
- organizationScoped?: (options?: {
1912
- required?: boolean;
1913
- }) => RouteHandlerMethod;
1914
- [key: string]: unknown;
1915
- };
1916
- interface OwnershipCheck {
1917
- field: string;
1918
- userField?: string;
1222
+ interface ResourceHookContext {
1223
+ /** The document data (create/update body, or existing doc for delete) */
1224
+ data: AnyRecord;
1225
+ /** Authenticated user or null */
1226
+ user?: UserBase;
1227
+ /** Additional metadata (e.g. `{ id, existing }` for update/delete) */
1228
+ meta?: AnyRecord;
1919
1229
  }
1920
1230
  /**
1921
- * Per-resource rate limit configuration.
1922
- *
1923
- * Applied to all routes of the resource when `@fastify/rate-limit` is registered
1924
- * on the Fastify instance. Set to `false` to explicitly disable rate limiting
1925
- * for a resource even when a global rate limit is configured.
1231
+ * Inline lifecycle hooks on a resource definition. Wired into the
1232
+ * HookSystem automatically — same pipeline as presets and app-level hooks.
1926
1233
  *
1927
1234
  * @example
1928
1235
  * ```typescript
1929
1236
  * defineResource({
1930
- * name: 'product',
1931
- * rateLimit: { max: 100, timeWindow: '1 minute' },
1237
+ * name: 'chat',
1238
+ * hooks: {
1239
+ * afterCreate: async (ctx) => { analytics.track('chat.created', { id: ctx.data._id }); },
1240
+ * beforeDelete: async (ctx) => {
1241
+ * if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
1242
+ * },
1243
+ * },
1932
1244
  * });
1933
1245
  * ```
1934
1246
  */
1935
- /**
1936
- * Per-resource cache configuration for QueryCache.
1937
- * Enables stale-while-revalidate, auto-invalidation on mutations,
1938
- * and cross-resource tag-based invalidation.
1939
- */
1940
- interface ResourceCacheConfig {
1941
- /** Seconds data is "fresh" (no revalidation). Default: 0 */
1942
- staleTime?: number;
1943
- /** Seconds stale data stays cached (SWR window). Default: 60 */
1944
- gcTime?: number;
1945
- /** Per-operation overrides */
1946
- list?: {
1947
- staleTime?: number;
1948
- gcTime?: number;
1949
- };
1950
- byId?: {
1951
- staleTime?: number;
1952
- gcTime?: number;
1953
- };
1954
- /** Tags for cross-resource invalidation grouping */
1955
- tags?: string[];
1956
- /**
1957
- * Cross-resource invalidation: event pattern → tag targets.
1958
- * When matched event fires, all caches with those tags are invalidated.
1959
- * @example { 'category.*': ['catalog'] }
1960
- */
1961
- invalidateOn?: Record<string, string[]>;
1962
- /** Disable caching for this resource */
1963
- disabled?: boolean;
1247
+ interface ResourceHooks {
1248
+ beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
1249
+ afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
1250
+ beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
1251
+ afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
1252
+ beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
1253
+ afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
1964
1254
  }
1965
- interface RateLimitConfig {
1966
- /** Maximum number of requests allowed within the time window */
1967
- max: number;
1968
- /** Time window for rate limiting (e.g., '1 minute', '15 seconds', '1 hour') */
1969
- timeWindow: string;
1255
+ interface PresetHook {
1256
+ operation: "create" | "update" | "delete" | "read" | "list";
1257
+ phase: "before" | "after";
1258
+ handler: (ctx: AnyRecord) => void | Promise<void> | AnyRecord | Promise<AnyRecord>;
1259
+ priority?: number;
1260
+ }
1261
+ interface PresetResult {
1262
+ name: string;
1263
+ /** Preset routes — merged into the resource's `routes` array. */
1264
+ routes?: RouteDefinition[] | ((permissions: ResourcePermissions) => RouteDefinition[]);
1265
+ middlewares?: MiddlewareConfig;
1266
+ schemaOptions?: RouteSchemaOptions;
1267
+ controllerOptions?: Record<string, unknown>;
1268
+ hooks?: PresetHook[];
1269
+ }
1270
+ type PresetFunction = (config: ResourceConfig) => PresetResult;
1271
+ interface EventDefinition {
1272
+ name: string;
1273
+ /** Optional handler — events are published via `fastify.events.publish()`. */
1274
+ handler?: (data: unknown) => Promise<void> | void;
1275
+ /** JSON schema for event payload */
1276
+ schema?: Record<string, unknown>;
1277
+ description?: string;
1278
+ }
1279
+ /** Resource-level permissions — only `PermissionCheck` functions allowed. */
1280
+ interface ResourcePermissions {
1281
+ list?: PermissionCheck;
1282
+ get?: PermissionCheck;
1283
+ create?: PermissionCheck;
1284
+ update?: PermissionCheck;
1285
+ delete?: PermissionCheck;
1970
1286
  }
1971
1287
  interface ResourceConfig<TDoc = AnyRecord> {
1972
1288
  name: string;
1973
1289
  displayName?: string;
1974
1290
  tag?: string;
1291
+ /** Defaults to `/${name}s` if not provided. */
1975
1292
  prefix?: string;
1976
1293
  /**
1977
- * Skip the global `resourcePrefix` from `createApp()`.
1978
- * The resource registers at its own `prefix` (or `/${name}s`) directly on root.
1979
- * Useful for webhooks, health, admin routes that shouldn't be under `/api/v1`.
1294
+ * Skip the global `resourcePrefix` from `createApp()`. The resource
1295
+ * registers at its own `prefix` (or `/${name}s`) directly on root.
1296
+ * Useful for webhooks, health, admin routes that shouldn't be under
1297
+ * `/api/v1`.
1980
1298
  *
1981
1299
  * @example
1982
1300
  * ```typescript
1983
1301
  * defineResource({ name: 'webhook', prefix: '/webhooks', skipGlobalPrefix: true })
1984
- * // Registers at /webhooks even when createApp({ resourcePrefix: '/api/v1' })
1985
1302
  * ```
1986
1303
  */
1987
1304
  skipGlobalPrefix?: boolean;
1305
+ /** Optional for service-pattern resources */
1988
1306
  adapter?: DataAdapter<TDoc>;
1989
- /** Controller instance - accepts any object with CRUD methods */
1307
+ /** Controller instance accepts any object with CRUD methods. */
1990
1308
  controller?: IController<TDoc> | ControllerLike;
1991
1309
  queryParser?: unknown;
1992
1310
  permissions?: ResourcePermissions;
1993
1311
  schemaOptions?: RouteSchemaOptions;
1994
1312
  openApiSchemas?: OpenApiSchemas;
1313
+ /** Custom JSON schemas (override Arc-generated). */
1995
1314
  customSchemas?: Partial<CrudSchemas>;
1315
+ /** Preset names, objects, or PresetResult values. */
1996
1316
  presets?: Array<string | PresetResult | {
1997
1317
  name: string;
1998
1318
  [key: string]: unknown;
1999
1319
  }>;
2000
1320
  hooks?: ResourceHooks;
2001
1321
  /**
2002
- * Functional pipeline — guards, transforms, and interceptors.
2003
- * Can be a flat array (all operations) or per-operation map.
1322
+ * Functional pipeline — guards, transforms, interceptors. Flat array
1323
+ * (all operations) or per-operation map.
2004
1324
  *
2005
1325
  * @example
2006
1326
  * ```typescript
2007
- * import { pipe, guard, transform, intercept } from '@classytic/arc';
2008
- *
2009
- * resource('product', {
2010
- * pipe: pipe(isActive, slugify, timing),
2011
- * // OR per-operation:
2012
- * pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
2013
- * });
1327
+ * pipe: pipe(isActive, slugify, timing),
1328
+ * pipe: { create: pipe(isActive, slugify), list: pipe(timing) },
2014
1329
  * ```
2015
1330
  */
2016
1331
  pipe?: PipelineConfig;
@@ -2019,7 +1334,6 @@ interface ResourceConfig<TDoc = AnyRecord> {
2019
1334
  *
2020
1335
  * @example
2021
1336
  * ```typescript
2022
- * import { fields } from '@classytic/arc';
2023
1337
  * fields: {
2024
1338
  * salary: fields.visibleTo(['admin', 'hr']),
2025
1339
  * password: fields.hidden(),
@@ -2027,23 +1341,22 @@ interface ResourceConfig<TDoc = AnyRecord> {
2027
1341
  * ```
2028
1342
  */
2029
1343
  fields?: FieldPermissionMap;
1344
+ /**
1345
+ * Policy for requests that include fields the caller can't write.
1346
+ *
1347
+ * - `'reject'` (default, secure): 403 with the denied field names.
1348
+ * Surfaces misconfigurations and write-side permission violations
1349
+ * instead of silently dropping them.
1350
+ * - `'strip'`: legacy silent-drop behaviour — only opt in when migrating
1351
+ * pre-2.9 code that relied on the permissive default.
1352
+ */
1353
+ onFieldWriteDenied?: "reject" | "strip";
2030
1354
  middlewares?: MiddlewareConfig;
2031
1355
  /**
2032
1356
  * PreHandler guards auto-applied to **every** route on this resource
2033
- * (CRUD + custom `routes` + preset routes). Runs after auth/permissions,
2034
- * before per-route `preHandler`. Use for mode gates, tenant checks,
2035
- * feature flags — anything that applies to every endpoint.
2036
- *
2037
- * @example
2038
- * ```typescript
2039
- * defineResource({
2040
- * routeGuards: [requireFlowMode('standard')],
2041
- * routes: [
2042
- * { method: 'GET', path: '/', raw: true, handler: listHandler },
2043
- * // guard runs automatically — no per-route boilerplate
2044
- * ],
2045
- * });
2046
- * ```
1357
+ * (CRUD + custom + preset). Runs after auth/permissions, before
1358
+ * per-route `preHandler`. Use for mode gates, tenant checks, feature
1359
+ * flags — anything that applies to every endpoint.
2047
1360
  */
2048
1361
  routeGuards?: RouteHandlerMethod[];
2049
1362
  /**
@@ -2059,8 +1372,9 @@ interface ResourceConfig<TDoc = AnyRecord> {
2059
1372
  */
2060
1373
  routes?: RouteDefinition[];
2061
1374
  /**
2062
- * State-transition actions → unified POST /:id/action endpoint.
2063
- * Each action can be a bare handler or full config with permissions + schema.
1375
+ * State-transition actions → unified `POST /:id/action` endpoint.
1376
+ * Each action can be a bare handler or full config with permissions
1377
+ * + schema.
2064
1378
  *
2065
1379
  * @example
2066
1380
  * ```typescript
@@ -2083,72 +1397,59 @@ interface ResourceConfig<TDoc = AnyRecord> {
2083
1397
  actionPermissions?: PermissionCheck;
2084
1398
  disableCrud?: boolean;
2085
1399
  disableDefaultRoutes?: boolean;
1400
+ /** Specific routes to disable */
2086
1401
  disabledRoutes?: CrudRouteKey[];
2087
1402
  /**
2088
1403
  * Field name used for multi-tenant scoping (default: 'organizationId').
2089
- * Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
2090
- * Takes effect when org context is present (via multiTenant preset).
1404
+ * Override to match your schema: 'workspaceId', 'tenantId', etc.
2091
1405
  */
2092
1406
  tenantField?: string | false;
2093
1407
  /**
2094
1408
  * Primary key field name (default: '_id').
2095
1409
  *
2096
- * Type-narrowed to `keyof TDoc` when `defineResource<TDoc>` is called with
2097
- * a typed document interface — gives autocomplete for valid field names
2098
- * while still accepting any string when TDoc is `unknown` / `AnyRecord` so
2099
- * adapters with dynamic shapes still work.
1410
+ * Type-narrowed to `keyof TDoc` when `defineResource<TDoc>` is called
1411
+ * with a typed document interface — autocomplete for valid field names
1412
+ * while still accepting any string when TDoc is `unknown` /
1413
+ * `AnyRecord` so adapters with dynamic shapes still work.
2100
1414
  *
2101
1415
  * @example
2102
1416
  * ```ts
2103
1417
  * defineResource<IJob>({ idField: 'jobId' }) // ← autocompletes from IJob fields
2104
1418
  * defineResource({ idField: 'sku' }) // ← any string allowed
2105
1419
  * ```
2106
- *
2107
- * Override for non-MongoDB adapters (e.g., 'id' for SQL databases) or
2108
- * resources keyed by a business identifier (slug, sku, orderNumber).
2109
1420
  */
2110
1421
  idField?: (keyof TDoc & string) | (string & {});
1422
+ /** For grouping in registry */
2111
1423
  module?: string;
1424
+ /** Domain events */
2112
1425
  events?: Record<string, EventDefinition>;
1426
+ /** Skip schema validation */
2113
1427
  skipValidation?: boolean;
1428
+ /** Don't register in introspection */
2114
1429
  skipRegistry?: boolean;
1430
+ /** Internal: track applied presets */
2115
1431
  _appliedPresets?: string[];
2116
- /**
2117
- * Called during plugin registration with the scoped Fastify instance.
2118
- * Use for wiring singletons, reading decorators, or setting up resource-specific
2119
- * services that need access to the Fastify instance.
2120
- *
2121
- * @example
2122
- * ```typescript
2123
- * defineResource({
2124
- * name: 'notification',
2125
- * onRegister: (fastify) => {
2126
- * setSseManager(fastify.sseManager);
2127
- * },
2128
- * })
2129
- * ```
2130
- */
2131
- onRegister?: (fastify: FastifyInstance) => void | Promise<void>;
2132
1432
  /** HTTP method for update routes. Default: 'PATCH' */
2133
- updateMethod?: 'PUT' | 'PATCH' | 'both';
1433
+ updateMethod?: "PUT" | "PATCH" | "both";
2134
1434
  /**
2135
- * Per-resource rate limiting.
2136
- * Requires `@fastify/rate-limit` to be registered on the Fastify instance.
2137
- * Set to `false` to disable rate limiting for this resource.
1435
+ * Per-resource rate limiting. Requires `@fastify/rate-limit` to be
1436
+ * registered. Set to `false` to disable for this resource.
2138
1437
  */
2139
1438
  rateLimit?: RateLimitConfig | false;
2140
1439
  /**
2141
- * QueryCache configuration for this resource.
2142
- * Enables stale-while-revalidate and auto-invalidation.
2143
- * Requires `queryCachePlugin` to be registered.
1440
+ * QueryCache configuration for this resource. Enables
1441
+ * stale-while-revalidate and auto-invalidation. Requires
1442
+ * `queryCachePlugin` to be registered.
2144
1443
  */
2145
1444
  cache?: ResourceCacheConfig;
2146
1445
  /**
2147
1446
  * Per-resource audit opt-in. When `auditPlugin` is registered with
2148
- * `autoAudit: { perResource: true }`, only resources with this flag are audited.
1447
+ * `autoAudit: { perResource: true }`, only resources with this flag
1448
+ * are audited.
2149
1449
  *
2150
- * The cleanest pattern for apps where most resources don't need auditing —
2151
- * no growing exclude lists, no centralized allowlist to maintain.
1450
+ * The cleanest pattern for apps where most resources don't need
1451
+ * auditing — no growing exclude lists, no centralized allowlist to
1452
+ * maintain.
2152
1453
  *
2153
1454
  * - `true`: Audit create/update/delete on this resource
2154
1455
  * - `{ operations: ['delete'] }`: Audit only specific operations
@@ -2156,15 +1457,8 @@ interface ResourceConfig<TDoc = AnyRecord> {
2156
1457
  *
2157
1458
  * @example
2158
1459
  * ```ts
2159
- * // app.ts
2160
- * await fastify.register(auditPlugin, {
2161
- * autoAudit: { perResource: true },
2162
- * });
2163
- *
2164
- * // order.resource.ts
1460
+ * await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
2165
1461
  * defineResource({ name: 'order', audit: true });
2166
- *
2167
- * // payment.resource.ts
2168
1462
  * defineResource({ name: 'payment', audit: { operations: ['delete'] } });
2169
1463
  * ```
2170
1464
  */
@@ -2172,450 +1466,226 @@ interface ResourceConfig<TDoc = AnyRecord> {
2172
1466
  operations?: ("create" | "update" | "delete")[];
2173
1467
  };
2174
1468
  }
1469
+ //#endregion
1470
+ //#region src/core/defineResource.d.ts
2175
1471
  /**
2176
- * Resource-level permissions
2177
- * ONLY PermissionCheck functions allowed - no string arrays
2178
- */
2179
- interface ResourcePermissions {
2180
- list?: PermissionCheck;
2181
- get?: PermissionCheck;
2182
- create?: PermissionCheck;
2183
- update?: PermissionCheck;
2184
- delete?: PermissionCheck;
2185
- }
2186
- /**
2187
- * Hook context passed to resource-level hook handlers.
2188
- * Mirrors HookSystem's HookContext but with a simpler API for inline use.
2189
- */
2190
- interface ResourceHookContext {
2191
- /** The document data (create/update body, or existing doc for delete) */
2192
- data: AnyRecord;
2193
- /** Authenticated user or null */
2194
- user?: UserBase;
2195
- /** Additional metadata (e.g. `{ id, existing }` for update/delete) */
2196
- meta?: AnyRecord;
2197
- }
2198
- /**
2199
- * Inline lifecycle hooks on a resource definition.
2200
- * These are wired into the HookSystem automatically — same pipeline as presets and app-level hooks.
1472
+ * Define a resource with database adapter
2201
1473
  *
2202
- * @example
2203
- * ```typescript
2204
- * defineResource({
2205
- * name: 'chat',
2206
- * hooks: {
2207
- * afterCreate: async (ctx) => {
2208
- * analytics.track('chat.created', { chatId: ctx.data._id, userId: ctx.user?.id });
2209
- * },
2210
- * beforeDelete: async (ctx) => {
2211
- * if (ctx.data.isProtected) throw new Error('Cannot delete protected chat');
2212
- * },
2213
- * afterDelete: async (ctx) => {
2214
- * await notificationService.send('chat.deleted', { id: ctx.meta?.id });
2215
- * },
2216
- * },
2217
- * });
2218
- * ```
1474
+ * This is the MAIN entry point for creating Arc resources.
1475
+ * The adapter provides both repository and schema metadata.
2219
1476
  */
2220
- interface ResourceHooks {
2221
- /** Runs before create can modify data by returning a new object */
2222
- beforeCreate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
2223
- /** Runs after create — receives the created document */
2224
- afterCreate?: (ctx: ResourceHookContext) => Promise<void> | void;
2225
- /** Runs before update — ctx.meta.id has the resource ID, ctx.meta.existing has the current doc */
2226
- beforeUpdate?: (ctx: ResourceHookContext) => Promise<AnyRecord | void> | AnyRecord | void;
2227
- /** Runs after update — receives the updated document */
2228
- afterUpdate?: (ctx: ResourceHookContext) => Promise<void> | void;
2229
- /** Runs before delete ctx.data is the existing doc, ctx.meta.id has the resource ID */
2230
- beforeDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
2231
- /** Runs after delete — ctx.data is the deleted doc, ctx.meta.id has the resource ID */
2232
- afterDelete?: (ctx: ResourceHookContext) => Promise<void> | void;
1477
+ declare function defineResource<TDoc = AnyRecord>(config: ResourceConfig<TDoc>): ResourceDefinition<TDoc>;
1478
+ interface ResolvedResourceConfig<TDoc = AnyRecord> extends ResourceConfig<TDoc> {
1479
+ _appliedPresets?: string[];
1480
+ _controllerOptions?: {
1481
+ slugField?: string;
1482
+ parentField?: string;
1483
+ [key: string]: unknown;
1484
+ };
1485
+ _pendingHooks?: Array<{
1486
+ operation: "create" | "update" | "delete" | "read" | "list";
1487
+ phase: "before" | "after";
1488
+ handler: (ctx: AnyRecord) => unknown;
1489
+ priority: number;
1490
+ }>;
2233
1491
  }
2234
- /**
2235
- * Additional route definition for custom endpoints
2236
- */
2237
- interface AdditionalRoute {
2238
- /** HTTP method */
2239
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
2240
- /** Route path (relative to resource prefix) */
2241
- path: string;
1492
+ declare class ResourceDefinition<TDoc = AnyRecord> {
1493
+ readonly name: string;
1494
+ readonly displayName: string;
1495
+ readonly tag: string;
1496
+ readonly prefix: string;
1497
+ readonly adapter?: DataAdapter<TDoc>;
1498
+ readonly controller?: IController<TDoc>;
1499
+ readonly schemaOptions: RouteSchemaOptions;
1500
+ readonly customSchemas: CrudSchemas;
1501
+ readonly permissions: ResourcePermissions;
1502
+ readonly routes: readonly RouteDefinition[];
1503
+ readonly middlewares: MiddlewareConfig;
1504
+ readonly routeGuards?: RouteHandlerMethod$1[];
1505
+ readonly disableDefaultRoutes: boolean;
1506
+ readonly disabledRoutes: CrudRouteKey[];
1507
+ readonly actions?: ActionsMap;
1508
+ readonly actionPermissions?: PermissionCheck;
1509
+ readonly events: Record<string, EventDefinition>;
1510
+ readonly rateLimit?: RateLimitConfig | false;
1511
+ readonly audit?: boolean | {
1512
+ operations?: ("create" | "update" | "delete")[];
1513
+ };
1514
+ readonly updateMethod?: "PUT" | "PATCH" | "both";
1515
+ readonly pipe?: PipelineConfig;
1516
+ readonly fields?: FieldPermissionMap;
1517
+ readonly cache?: ResourceCacheConfig;
1518
+ readonly skipGlobalPrefix: boolean;
1519
+ readonly tenantField?: string | false;
1520
+ readonly idField?: string;
1521
+ readonly queryParser?: QueryParserInterface;
1522
+ readonly _appliedPresets: string[];
1523
+ _pendingHooks: Array<{
1524
+ operation: "create" | "update" | "delete" | "read" | "list";
1525
+ phase: "before" | "after";
1526
+ handler: (ctx: AnyRecord) => unknown;
1527
+ priority: number;
1528
+ }>;
1529
+ _registryMeta?: RegisterOptions;
1530
+ constructor(config: ResolvedResourceConfig<TDoc>);
1531
+ /** Get repository from adapter (if available) */
1532
+ get repository(): _$_classytic_repo_core_repository0.StandardRepo<TDoc> | RepositoryLike<TDoc> | undefined;
1533
+ _validateControllerMethods(): void;
1534
+ toPlugin(): FastifyPluginAsync;
2242
1535
  /**
2243
- * Handler - string (controller method name) or function.
2244
- *
2245
- * When `wrapHandler: true`:
2246
- * - `string` — calls controller method by name (e.g., `'approve'`)
2247
- * - `ControllerHandler` — receives `IRequestContext`, returns `IControllerResponse`
2248
- *
2249
- * When `wrapHandler: false`:
2250
- * - Fastify handler `(request, reply) => unknown`
1536
+ * Get event definitions for registry
2251
1537
  */
2252
- handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<any>, reply: FastifyReply) => unknown);
2253
- /** Permission check - REQUIRED */
2254
- permissions: PermissionCheck;
1538
+ getEvents(): Array<{
1539
+ name: string;
1540
+ module: string;
1541
+ schema?: AnyRecord;
1542
+ description?: string;
1543
+ }>;
2255
1544
  /**
2256
- * Handler type - REQUIRED, no auto-detection
2257
- * true = ControllerHandler (receives context object)
2258
- * false = FastifyHandler (receives request, reply)
1545
+ * Get resource metadata
2259
1546
  */
2260
- wrapHandler: boolean;
1547
+ getMetadata(): ResourceMetadata;
1548
+ }
1549
+ //#endregion
1550
+ //#region src/registry/ResourceRegistry.d.ts
1551
+ interface RegisterOptions {
1552
+ module?: string;
1553
+ /** Pre-generated OpenAPI schemas */
1554
+ openApiSchemas?: OpenApiSchemas;
1555
+ }
1556
+ declare class ResourceRegistry {
1557
+ private _resources;
1558
+ private _frozen;
1559
+ constructor();
2261
1560
  /**
2262
- * Logical operation name for pipeline keys and permission actions.
2263
- * Defaults to handler name (string handlers) or method+path slug.
2264
- * Prevents collisions when multiple routes share the same HTTP method.
2265
- *
2266
- * @example
2267
- * operation: 'listDeleted' // Used as pipeline key and permission action
2268
- * operation: 'restore'
1561
+ * Register a resource
2269
1562
  */
2270
- operation?: string;
2271
- /** OpenAPI summary */
2272
- summary?: string;
2273
- /** OpenAPI description */
2274
- description?: string;
2275
- /** OpenAPI tags */
2276
- tags?: string[];
1563
+ register(resource: ResourceDefinition<unknown>, options?: RegisterOptions): this;
2277
1564
  /**
2278
- * Custom route-level middleware
2279
- * Can be an array of handlers or a function that receives fastify and returns handlers
2280
- * @example
2281
- * // Direct array
2282
- * preHandler: [myMiddleware]
2283
- * // Function that receives fastify (for accessing decorators)
2284
- * preHandler: (fastify) => [fastify.customerContext({ required: true })]
1565
+ * Get resource by name
2285
1566
  */
2286
- preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
1567
+ get(name: string): RegistryEntry | undefined;
2287
1568
  /**
2288
- * Pre-auth handlers — run BEFORE authentication middleware.
2289
- * Use for promoting query params to headers (e.g., EventSource ?token= → Authorization).
2290
- *
2291
- * @example
2292
- * ```typescript
2293
- * preAuth: [(req) => {
2294
- * const token = (req.query as Record<string, string>)?.token;
2295
- * if (token) req.headers.authorization = `Bearer ${token}`;
2296
- * }]
2297
- * ```
1569
+ * Get all resources
2298
1570
  */
2299
- preAuth?: RouteHandlerMethod[];
1571
+ getAll(): RegistryEntry[];
2300
1572
  /**
2301
- * Streaming response mode — designed for SSE and AI streaming routes.
2302
- * When `true`:
2303
- * - Forces `wrapHandler: false` (no `{ success, data }` wrapper)
2304
- * - Sets SSE headers: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`
2305
- * - `request.signal` (Fastify 5 built-in) is available for abort-on-disconnect
2306
- *
2307
- * @example
2308
- * ```typescript
2309
- * {
2310
- * method: 'POST',
2311
- * path: '/stream',
2312
- * streamResponse: true,
2313
- * permissions: requireAuth(),
2314
- * handler: async (request, reply) => {
2315
- * const { stream } = await generateStream({ abortSignal: request.signal });
2316
- * return reply.send(stream);
2317
- * },
2318
- * }
2319
- * ```
1573
+ * Get resources by module
2320
1574
  */
2321
- streamResponse?: boolean;
2322
- /** Fastify route schema */
2323
- schema?: Record<string, unknown>;
1575
+ getByModule(moduleName: string): RegistryEntry[];
2324
1576
  /**
2325
- * MCP handler for routes with `wrapHandler: false`.
2326
- * When set, this route becomes an MCP tool without needing `wrapHandler: true`.
2327
- * The HTTP handler stays a plain Fastify handler; MCP gets a parallel entry point.
2328
- *
2329
- * @example
2330
- * ```typescript
2331
- * additionalRoutes: [{
2332
- * method: 'GET',
2333
- * path: '/stats',
2334
- * handler: (req, reply) => reply.send(getStats()),
2335
- * wrapHandler: false,
2336
- * permissions: isAuthenticated,
2337
- * mcpHandler: async (input) => ({
2338
- * content: [{ type: 'text', text: JSON.stringify(await getStats()) }],
2339
- * }),
2340
- * }]
2341
- * ```
1577
+ * Get resources by preset
2342
1578
  */
2343
- mcpHandler?: (input: Record<string, unknown>) => Promise<{
2344
- content: Array<{
2345
- type: string;
2346
- text: string;
2347
- }>;
2348
- isError?: boolean;
2349
- }>;
1579
+ getByPreset(presetName: string): RegistryEntry[];
2350
1580
  /**
2351
- * MCP tool generation config preserved from v2.8 `routes`.
2352
- * - `false`: skip MCP tool generation for this route
2353
- * - `true` / omitted: auto-generate when the route goes through Arc's pipeline
2354
- * - object: explicit description/annotations overrides
2355
- *
2356
- * Added in 2.8.1 — previously dropped during `routes → additionalRoutes`
2357
- * normalization, breaking MCP opt-out and per-route annotations.
2358
- */
2359
- mcp?: boolean | {
2360
- readonly description?: string;
2361
- readonly annotations?: {
2362
- readonly readOnlyHint?: boolean;
2363
- readonly destructiveHint?: boolean;
2364
- readonly idempotentHint?: boolean;
2365
- readonly openWorldHint?: boolean;
2366
- };
2367
- };
2368
- }
2369
- /** HTTP methods for custom routes */
2370
- type RouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
2371
- /** MCP tool configuration for a route or action */
2372
- interface RouteMcpConfig {
2373
- /** Override auto-generated tool description */
2374
- readonly description?: string;
2375
- /** MCP tool annotations */
2376
- readonly annotations?: {
2377
- readonly readOnlyHint?: boolean;
2378
- readonly destructiveHint?: boolean;
2379
- readonly idempotentHint?: boolean;
2380
- readonly openWorldHint?: boolean;
2381
- };
2382
- }
2383
- /**
2384
- * Route definition — replaces additionalRoutes.
2385
- *
2386
- * - `handler: 'string'` → controller method → full Arc pipeline + MCP tool
2387
- * - `handler: function` → inline handler → full Arc pipeline + MCP tool
2388
- * - `raw: true` → raw Fastify handler → no pipeline, no MCP by default
2389
- */
2390
- interface RouteDefinition {
2391
- readonly method: RouteMethod;
2392
- /** Path relative to resource prefix */
2393
- readonly path: string;
1581
+ * Check if resource exists
1582
+ */
1583
+ has(name: string): boolean;
2394
1584
  /**
2395
- * Route handler.
2396
- * - String: controller method name (goes through Arc pipeline)
2397
- * - Function without `raw: true`: receives IRequestContext, returns IControllerResponse (goes through Arc pipeline)
2398
- * - Function with `raw: true`: raw Fastify handler (request, reply)
1585
+ * Get registry statistics
2399
1586
  */
2400
- readonly handler: string | ControllerHandler | RouteHandlerMethod | ((request: FastifyRequest<Record<string, unknown>>, reply: FastifyReply) => unknown);
2401
- /** Permission check — REQUIRED */
2402
- readonly permissions: PermissionCheck;
1587
+ getStats(): RegistryStats;
2403
1588
  /**
2404
- * Raw mode bypasses Arc pipeline. Handler receives raw Fastify request/reply.
2405
- * Default: false (handler goes through Arc pipeline).
1589
+ * Get full introspection data
2406
1590
  */
2407
- readonly raw?: boolean;
2408
- /** Logical operation name (for pipeline keys, MCP tool naming). Defaults to handler name or method+path slug. */
2409
- readonly operation?: string;
2410
- /** OpenAPI summary */
2411
- readonly summary?: string;
2412
- /** OpenAPI description */
2413
- readonly description?: string;
2414
- /** OpenAPI tags */
2415
- readonly tags?: string[];
2416
- /** Route-level middleware */
2417
- readonly preHandler?: RouteHandlerMethod[] | ((fastify: FastifyInstance) => RouteHandlerMethod[]);
2418
- /** Pre-auth handlers (run before authentication) */
2419
- readonly preAuth?: RouteHandlerMethod[];
2420
- /** SSE streaming mode */
2421
- readonly streamResponse?: boolean;
1591
+ getIntrospection(): IntrospectionData;
2422
1592
  /**
2423
- * Fastify route schema. Each slot (`body`, `querystring`, `params`, `headers`,
2424
- * `response[status]`) accepts a plain JSON Schema object **or** a Zod v4 schema —
2425
- * arc auto-converts via `convertRouteSchema` at registration time. Slot values
2426
- * are typed `unknown` so class-based Zod schemas assign without casts.
1593
+ * Freeze registry (prevent further registrations)
2427
1594
  */
2428
- readonly schema?: {
2429
- body?: unknown;
2430
- querystring?: unknown;
2431
- params?: unknown;
2432
- headers?: unknown;
2433
- response?: Record<number | string, unknown>;
2434
- [key: string]: unknown;
2435
- };
1595
+ freeze(): void;
2436
1596
  /**
2437
- * MCP tool generation:
2438
- * - omitted/true: auto-generate (non-raw routes only)
2439
- * - false: skip MCP
2440
- * - object: explicit config
1597
+ * Check if frozen
2441
1598
  */
2442
- readonly mcp?: boolean | RouteMcpConfig;
1599
+ isFrozen(): boolean;
2443
1600
  /**
2444
- * MCP handler for raw routes — parallel entry point for MCP without changing HTTP handler.
1601
+ * Unfreeze registry (allow new registrations)
2445
1602
  */
2446
- readonly mcpHandler?: (input: Record<string, unknown>) => Promise<{
2447
- content: Array<{
2448
- type: string;
2449
- text: string;
2450
- }>;
2451
- isError?: boolean;
2452
- }>;
2453
- }
2454
- /**
2455
- * Action handler function for state transitions.
2456
- * Receives the resource ID, action-specific data, and the request context.
2457
- */
2458
- type ActionHandlerFn = (id: string, data: Record<string, unknown>, req: RequestWithExtras) => Promise<unknown>;
2459
- /**
2460
- * Full action configuration with handler, permissions, and schema.
2461
- */
2462
- interface ActionDefinition {
2463
- /** Action handler */
2464
- readonly handler: ActionHandlerFn;
2465
- /** Per-action permission check (overrides resource-level actionPermissions) */
2466
- readonly permissions?: PermissionCheck;
1603
+ unfreeze(): void;
2467
1604
  /**
2468
- * JSON Schema or Zod v4 schema for action-specific body fields.
2469
- * Per-field values are typed `unknown` so Zod class instances assign without casts.
1605
+ * Reset registry clear all resources and unfreeze
2470
1606
  */
2471
- readonly schema?: Record<string, unknown>;
2472
- /** Description for OpenAPI docs and MCP tool */
2473
- readonly description?: string;
1607
+ reset(): void;
1608
+ /** @internal Alias for unfreeze() */
1609
+ _unfreeze(): void;
1610
+ /** @internal Alias for reset() */
1611
+ _clear(): void;
2474
1612
  /**
2475
- * MCP tool generation:
2476
- * - omitted/true: auto-generate
2477
- * - false: skip
2478
- * - object: explicit config
1613
+ * Group by key
2479
1614
  */
2480
- readonly mcp?: boolean | RouteMcpConfig;
1615
+ private _groupBy;
2481
1616
  }
2482
- /** Action config: bare handler function OR full ActionDefinition */
2483
- type ActionEntry = ActionHandlerFn | ActionDefinition;
2484
- /** Actions configuration map */
2485
- type ActionsMap = Record<string, ActionEntry>;
2486
- interface RouteSchemaOptions {
2487
- hiddenFields?: string[];
2488
- readonlyFields?: string[];
2489
- requiredFields?: string[];
2490
- optionalFields?: string[];
2491
- excludeFields?: string[];
1617
+ //#endregion
1618
+ //#region src/types/fastify.d.ts
1619
+ interface FastifyRequestExtras {
1620
+ user?: Record<string, unknown>;
1621
+ }
1622
+ interface RequestWithExtras extends FastifyRequest {
2492
1623
  /**
2493
- * Fields allowed for filtering in list operations.
2494
- * Used by MCP tool generation to build the list tool's input schema.
2495
- * If not set and using a QueryParser with `allowedFilterFields`, MCP auto-derives from it.
1624
+ * Arc metadata set by createCrudRouter. Contains resource configuration
1625
+ * and schema options.
2496
1626
  */
2497
- filterableFields?: string[];
2498
- fieldRules?: Record<string, {
2499
- systemManaged?: boolean;
2500
- hidden?: boolean;
2501
- immutable?: boolean;
2502
- immutableAfterCreate?: boolean;
2503
- optional?: boolean; /** String minimum length — auto-maps to OpenAPI `minLength` and MCP tool schema */
2504
- minLength?: number; /** String maximum length — auto-maps to OpenAPI `maxLength` and MCP tool schema */
2505
- maxLength?: number; /** Number minimum — auto-maps to OpenAPI `minimum` and MCP tool schema */
2506
- min?: number; /** Number maximum — auto-maps to OpenAPI `maximum` and MCP tool schema */
2507
- max?: number; /** Regex pattern — auto-maps to OpenAPI `pattern` and MCP tool schema */
2508
- pattern?: string; /** Allowed values — auto-maps to OpenAPI `enum` and MCP tool schema */
2509
- enum?: ReadonlyArray<string | number>; /** Human-readable description — auto-maps to OpenAPI `description` */
2510
- description?: string;
2511
- [key: string]: unknown;
2512
- }>;
2513
- query?: Record<string, unknown>;
2514
- }
2515
- interface FieldRule {
2516
- field: string;
2517
- required?: boolean;
2518
- readonly?: boolean;
2519
- hidden?: boolean;
1627
+ arc?: {
1628
+ resourceName?: string;
1629
+ schemaOptions?: RouteSchemaOptions;
1630
+ permissions?: ResourcePermissions;
1631
+ };
1632
+ context?: Record<string, unknown>;
1633
+ _policyFilters?: Record<string, unknown>;
1634
+ fieldMask?: {
1635
+ include?: string[];
1636
+ exclude?: string[];
1637
+ };
1638
+ _ownershipCheck?: Record<string, unknown>;
2520
1639
  }
1640
+ type FastifyWithAuth = FastifyInstance & {
1641
+ authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
1642
+ };
2521
1643
  /**
2522
- * CRUD Route Schemas (Fastify Native Format)
2523
- *
2524
- * Each slot accepts either a plain JSON Schema object **or** a Zod v4 schema —
2525
- * arc's `convertRouteSchema` feature-detects at runtime. The slot values are
2526
- * typed `unknown` (not `Record<string, unknown>`) so class-based Zod schemas
2527
- * assign cleanly without `as unknown as Record<string, unknown>` casts.
2528
- *
2529
- * @example
2530
- * ```ts
2531
- * {
2532
- * list: {
2533
- * querystring: { type: 'object', properties: { page: { type: 'number' } } },
2534
- * response: { 200: z.object({ docs: z.array(EntitySchema) }) }
2535
- * },
2536
- * create: {
2537
- * body: z.object({ name: z.string(), size: z.number().int().positive() }),
2538
- * response: { 201: EntitySchema }
2539
- * }
2540
- * }
2541
- * ```
1644
+ * Arc core decorator added by `arcCorePlugin`. Provides instance-scoped
1645
+ * hooks and resource registry.
2542
1646
  */
2543
- interface CrudSchemas {
2544
- /** GET / - List all resources */
2545
- list?: {
2546
- /** Plain JSON Schema or Zod schema (auto-converted). */querystring?: unknown; /** Map of HTTP status code → JSON Schema or Zod schema. */
2547
- response?: Record<number, unknown>;
2548
- [key: string]: unknown;
2549
- };
2550
- /** GET /:id - Get single resource */
2551
- get?: {
2552
- params?: unknown;
2553
- response?: Record<number, unknown>;
2554
- [key: string]: unknown;
2555
- };
2556
- /** POST / - Create resource */
2557
- create?: {
2558
- body?: unknown;
2559
- response?: Record<number, unknown>;
2560
- [key: string]: unknown;
2561
- };
2562
- /** PATCH /:id - Update resource */
2563
- update?: {
2564
- params?: unknown;
2565
- body?: unknown;
2566
- response?: Record<number, unknown>;
2567
- [key: string]: unknown;
2568
- };
2569
- /** DELETE /:id - Delete resource */
2570
- delete?: {
2571
- params?: unknown;
2572
- response?: Record<number, unknown>;
2573
- [key: string]: unknown;
2574
- };
2575
- [key: string]: unknown;
1647
+ interface ArcDecorator {
1648
+ hooks: HookSystem;
1649
+ registry: ResourceRegistry;
1650
+ /** Whether event emission is enabled */
1651
+ emitEvents: boolean;
2576
1652
  }
2577
- interface OpenApiSchemas {
2578
- entity?: unknown;
2579
- createBody?: unknown;
2580
- updateBody?: unknown;
2581
- params?: unknown;
2582
- listQuery?: unknown;
1653
+ /** Events decorator — added by `eventPlugin`. Provides event pub/sub. */
1654
+ interface EventsDecorator {
1655
+ publish: <T>(type: string, payload: T, meta?: Partial<{
1656
+ id: string;
1657
+ timestamp: Date;
1658
+ }>) => Promise<void>;
2583
1659
  /**
2584
- * Explicit response schema for OpenAPI documentation.
2585
- * If provided, this will be used as-is for the response schema.
2586
- * If not provided, response schema is auto-generated from createBody.
2587
- *
2588
- * Note: This is for OpenAPI docs only - does NOT affect Fastify serialization.
2589
- *
2590
- * @example
2591
- * response: {
2592
- * type: 'object',
2593
- * properties: {
2594
- * _id: { type: 'string' },
2595
- * name: { type: 'string' },
2596
- * email: { type: 'string' },
2597
- * // Exclude password, include virtuals
2598
- * fullName: { type: 'string' },
2599
- * }
2600
- * }
1660
+ * Subscribe to an event pattern. Handler receives the full
1661
+ * `DomainEvent<T> = { type, payload, meta }` envelope destructure
1662
+ * `payload` if you only need the body, or read `meta.correlationId` /
1663
+ * `meta.timestamp` for tracing. See CHANGELOG 2.10 for the migration
1664
+ * note from 2.9.x's loose `unknown` type.
2601
1665
  */
2602
- response?: unknown;
2603
- [key: string]: unknown;
1666
+ subscribe: <T = unknown>(pattern: string, handler: (event: DomainEvent<T>) => void | Promise<void>) => Promise<() => void>;
1667
+ transportName: string;
2604
1668
  }
2605
- /** Handler for middleware functions */
1669
+ /**
1670
+ * Fastify instance with Arc decorators. Arc adds these via plugins/presets.
1671
+ */
1672
+ type FastifyWithDecorators = FastifyInstance & {
1673
+ arc?: ArcDecorator;
1674
+ events?: EventsDecorator;
1675
+ authenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
1676
+ optionalAuthenticate?: (request: FastifyRequest, reply: FastifyReply) => Promise<void>; /** Organization-scoped filtering — from `multiTenant` preset */
1677
+ organizationScoped?: (options?: {
1678
+ required?: boolean;
1679
+ }) => RouteHandlerMethod;
1680
+ [key: string]: unknown;
1681
+ };
1682
+ /** Handler signature for middleware functions. */
2606
1683
  type MiddlewareHandler = (request: RequestWithExtras, reply: FastifyReply) => Promise<unknown>;
2607
- type CrudRouteKey = 'list' | 'get' | 'create' | 'update' | 'delete';
2608
- interface MiddlewareConfig {
2609
- list?: MiddlewareHandler[];
2610
- get?: MiddlewareHandler[];
2611
- create?: MiddlewareHandler[];
2612
- update?: MiddlewareHandler[];
2613
- delete?: MiddlewareHandler[];
2614
- [key: string]: MiddlewareHandler[] | undefined;
2615
- }
1684
+ //#endregion
1685
+ //#region src/types/auth.d.ts
2616
1686
  /**
2617
- * JWT utilities provided to authenticator
2618
- * Arc provides these helpers, app uses them as needed
1687
+ * JWT utilities provided to authenticator. Arc provides the helpers;
1688
+ * apps use them as needed.
2619
1689
  */
2620
1690
  interface JwtContext {
2621
1691
  /** Verify a JWT token and return decoded payload */
@@ -2627,51 +1697,31 @@ interface JwtContext {
2627
1697
  /** Decode without verification (for inspection) */
2628
1698
  decode: <T = Record<string, unknown>>(token: string) => T | null;
2629
1699
  }
2630
- /**
2631
- * Context passed to app's authenticator function
2632
- */
1700
+ /** Context passed to app's authenticator function. */
2633
1701
  interface AuthenticatorContext {
2634
- /** JWT utilities (available if jwt.secret provided) */
1702
+ /** JWT utilities (available if `jwt.secret` provided) */
2635
1703
  jwt: JwtContext | null;
2636
1704
  /** Fastify instance for advanced use cases */
2637
1705
  fastify: FastifyInstance;
2638
1706
  }
2639
1707
  /**
2640
- * App-provided authenticator function
1708
+ * App-provided authenticator function. Arc calls this for every
1709
+ * non-public route. The app has full control over authentication logic.
2641
1710
  *
2642
- * Arc calls this for every non-public route.
2643
- * App has FULL control over authentication logic.
1711
+ * Return a user object to authenticate, `null`/`undefined` to reject.
2644
1712
  *
2645
1713
  * @example
2646
1714
  * ```typescript
2647
- * // Simple JWT auth
2648
1715
  * authenticate: async (request, { jwt }) => {
2649
1716
  * const token = request.headers.authorization?.split(' ')[1];
2650
1717
  * if (!token || !jwt) return null;
2651
1718
  * const decoded = jwt.verify(token);
2652
1719
  * return userRepo.findById(decoded.id);
2653
1720
  * }
2654
- *
2655
- * // Multi-strategy (JWT + API Key)
2656
- * authenticate: async (request, { jwt }) => {
2657
- * const apiKey = request.headers['x-api-key'];
2658
- * if (apiKey) {
2659
- * const result = await apiKeyService.verify(apiKey);
2660
- * if (result) return { _id: result.userId, isApiKey: true };
2661
- * }
2662
- * const token = request.headers.authorization?.split(' ')[1];
2663
- * if (token && jwt) {
2664
- * const decoded = jwt.verify(token);
2665
- * return userRepo.findById(decoded.id);
2666
- * }
2667
- * return null;
2668
- * }
2669
1721
  * ```
2670
1722
  */
2671
1723
  type Authenticator = (request: FastifyRequest, context: AuthenticatorContext) => Promise<unknown | null> | unknown | null;
2672
- /**
2673
- * Token pair returned by issueTokens helper
2674
- */
1724
+ /** Token pair returned by `issueTokens` helper. */
2675
1725
  interface TokenPair {
2676
1726
  /** Access token (JWT) */
2677
1727
  accessToken: string;
@@ -2682,25 +1732,18 @@ interface TokenPair {
2682
1732
  /** Refresh token expiry in seconds */
2683
1733
  refreshExpiresIn?: number;
2684
1734
  /** Token type (always 'Bearer') */
2685
- tokenType: 'Bearer';
1735
+ tokenType: "Bearer";
2686
1736
  }
2687
1737
  /**
2688
- * Auth helpers available on fastify.auth
1738
+ * Auth helpers exposed on `fastify.auth`.
2689
1739
  *
2690
1740
  * @example
2691
1741
  * ```typescript
2692
- * // In login handler
2693
- * const user = await userRepo.findByEmail(email);
2694
- * if (!user || !await bcrypt.compare(password, user.password)) {
2695
- * return reply.code(401).send({ error: 'Invalid credentials' });
2696
- * }
2697
- *
2698
1742
  * const tokens = fastify.auth.issueTokens({
2699
1743
  * id: user._id,
2700
1744
  * email: user.email,
2701
1745
  * role: user.role,
2702
1746
  * });
2703
- *
2704
1747
  * return { success: true, ...tokens, user };
2705
1748
  * ```
2706
1749
  */
@@ -2708,41 +1751,110 @@ interface AuthHelpers {
2708
1751
  /** JWT utilities (if configured) */
2709
1752
  jwt: JwtContext | null;
2710
1753
  /**
2711
- * Issue access + refresh tokens for a user
2712
- * App calls this after validating credentials
1754
+ * Issue access + refresh tokens for a user. App calls this after
1755
+ * validating credentials.
2713
1756
  */
2714
1757
  issueTokens: (payload: Record<string, unknown>, options?: {
2715
1758
  expiresIn?: string;
2716
1759
  refreshExpiresIn?: string;
2717
1760
  }) => TokenPair;
2718
- /**
2719
- * Verify a refresh token and return decoded payload
2720
- */
1761
+ /** Verify a refresh token and return decoded payload. */
2721
1762
  verifyRefreshToken: <T = Record<string, unknown>>(token: string) => T;
2722
1763
  }
2723
- interface ServiceContext {
2724
- user?: unknown;
2725
- requestId?: string;
2726
- select?: string[] | Record<string, 0 | 1>;
2727
- populate?: string | string[];
2728
- lean?: boolean;
2729
- }
2730
- interface PresetHook {
2731
- operation: 'create' | 'update' | 'delete' | 'read' | 'list';
2732
- phase: 'before' | 'after';
2733
- handler: (ctx: AnyRecord) => void | Promise<void> | AnyRecord | Promise<AnyRecord>;
2734
- priority?: number;
2735
- }
2736
- interface PresetResult {
2737
- name: string;
2738
- /** Preset routes — merged into the resource's `routes` array. */
2739
- routes?: RouteDefinition[] | ((permissions: ResourcePermissions) => RouteDefinition[]);
2740
- middlewares?: MiddlewareConfig;
2741
- schemaOptions?: RouteSchemaOptions;
2742
- controllerOptions?: Record<string, unknown>;
2743
- hooks?: PresetHook[];
1764
+ /**
1765
+ * Auth plugin options — clean, minimal configuration.
1766
+ *
1767
+ * Arc provides JWT infrastructure and calls your authenticator. You
1768
+ * control all authentication logic.
1769
+ *
1770
+ * @example
1771
+ * ```typescript
1772
+ * auth: {
1773
+ * jwt: { secret: process.env.JWT_SECRET },
1774
+ * authenticate: async (request, { jwt }) => {
1775
+ * const token = request.headers.authorization?.split(' ')[1];
1776
+ * if (!token) return null;
1777
+ * const decoded = jwt.verify(token);
1778
+ * return userRepo.findById(decoded.id);
1779
+ * },
1780
+ * }
1781
+ * ```
1782
+ */
1783
+ interface AuthPluginOptions {
1784
+ /**
1785
+ * JWT configuration (optional but recommended). If provided, JWT
1786
+ * utilities are available in the authenticator context.
1787
+ */
1788
+ jwt?: {
1789
+ /** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
1790
+ expiresIn?: string; /** Refresh token secret (defaults to main secret) */
1791
+ refreshSecret?: string; /** Refresh token expiry (default: '7d') */
1792
+ refreshExpiresIn?: string; /** Additional `@fastify/jwt` sign options */
1793
+ sign?: Record<string, unknown>; /** Additional `@fastify/jwt` verify options */
1794
+ verify?: Record<string, unknown>;
1795
+ };
1796
+ /**
1797
+ * Custom authenticator function. Arc calls this for non-public routes.
1798
+ * If not provided and `jwt.secret` is set, uses default `jwtVerify`.
1799
+ */
1800
+ authenticate?: Authenticator;
1801
+ /**
1802
+ * Custom auth failure handler. Customize the 401 response when
1803
+ * authentication fails.
1804
+ */
1805
+ onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
1806
+ /**
1807
+ * Expose detailed auth error messages in 401 responses. When `false`
1808
+ * (default), returns generic "Authentication required". Decoupled from
1809
+ * log level — set explicitly per environment.
1810
+ */
1811
+ exposeAuthErrors?: boolean;
1812
+ /** Property name to store user on request (default: 'user') */
1813
+ userProperty?: string;
1814
+ /**
1815
+ * Custom token extractor for the built-in JWT auth path. Defaults to
1816
+ * extracting Bearer token from Authorization header. Use when tokens
1817
+ * are in HttpOnly cookies, custom headers, or query params.
1818
+ *
1819
+ * @example
1820
+ * ```typescript
1821
+ * tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
1822
+ * ```
1823
+ */
1824
+ tokenExtractor?: (request: FastifyRequest) => string | null;
1825
+ /**
1826
+ * Token revocation check — called after JWT verification succeeds.
1827
+ * Return `true` to reject the token (revoked), `false` to allow.
1828
+ *
1829
+ * **Fail-closed**: if the check throws, the token is treated as revoked.
1830
+ *
1831
+ * @example
1832
+ * ```typescript
1833
+ * isRevoked: async (decoded) => {
1834
+ * return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
1835
+ * },
1836
+ * ```
1837
+ */
1838
+ isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
1839
+ /**
1840
+ * Enforce strict JWT `type` claim validation (default: `true`).
1841
+ *
1842
+ * When enabled, `authenticate` requires `decoded.type === "access"`.
1843
+ * Tokens with a missing or unexpected `type` claim are rejected —
1844
+ * defence in depth for apps that reuse the JWT secret to sign other
1845
+ * token kinds (invite links, one-time verification codes).
1846
+ *
1847
+ * Arc's own `issueTokens` always sets `type: "access"` or
1848
+ * `type: "refresh"`, so this default is safe for Arc-generated tokens.
1849
+ *
1850
+ * Set to `false` ONLY when you must accept tokens signed without a
1851
+ * `type` claim (e.g. a legacy issuer you don't control). In that mode
1852
+ * Arc still rejects tokens explicitly marked `type: "refresh"`.
1853
+ */
1854
+ strictTokenType?: boolean;
2744
1855
  }
2745
- type PresetFunction = (config: ResourceConfig) => PresetResult;
1856
+ //#endregion
1857
+ //#region src/types/plugins.d.ts
2746
1858
  interface GracefulShutdownOptions {
2747
1859
  timeout?: number;
2748
1860
  onShutdown?: () => Promise<void> | void;
@@ -2762,488 +1874,628 @@ interface HealthCheck {
2762
1874
  timestamp: string;
2763
1875
  [key: string]: unknown;
2764
1876
  }
1877
+ interface IntrospectionPluginOptions {
1878
+ path?: string;
1879
+ prefix?: string;
1880
+ enabled?: boolean;
1881
+ authRoles?: string[];
1882
+ }
1883
+ interface CrudRouterOptions {
1884
+ /** Route prefix */
1885
+ prefix?: string;
1886
+ /** Permission checks for CRUD operations */
1887
+ permissions?: ResourcePermissions;
1888
+ /** OpenAPI tag for grouping routes */
1889
+ tag?: string;
1890
+ /** JSON schemas for CRUD operations */
1891
+ schemas?: Partial<CrudSchemas>;
1892
+ /** Middlewares for each CRUD operation */
1893
+ middlewares?: MiddlewareConfig;
1894
+ /** Custom routes (from presets or user-defined) */
1895
+ routes?: readonly RouteDefinition[];
1896
+ /** Disable all default CRUD routes */
1897
+ disableDefaultRoutes?: boolean;
1898
+ /** Disable specific CRUD routes */
1899
+ disabledRoutes?: CrudRouteKey[];
1900
+ /** Functional pipeline (guard/transform/intercept) */
1901
+ pipe?: PipelineConfig;
1902
+ /** Resource name for lifecycle hooks */
1903
+ resourceName?: string;
1904
+ /** Schema generation options */
1905
+ schemaOptions?: RouteSchemaOptions;
1906
+ /** Field-level permissions (visibility, writability per role) */
1907
+ fields?: FieldPermissionMap;
1908
+ /** HTTP method for update routes. Default: 'PATCH' */
1909
+ updateMethod?: "PUT" | "PATCH" | "both";
1910
+ /**
1911
+ * Per-resource rate limiting. Requires `@fastify/rate-limit` to be
1912
+ * registered. Set to `false` to disable for this resource.
1913
+ */
1914
+ rateLimit?: RateLimitConfig | false;
1915
+ /** PreHandler guards applied to every route (CRUD + custom + preset). */
1916
+ routeGuards?: RouteHandlerMethod[];
1917
+ }
1918
+ //#endregion
1919
+ //#region src/types/registry.d.ts
1920
+ interface ResourceMetadata {
1921
+ name: string;
1922
+ displayName?: string;
1923
+ tag?: string;
1924
+ prefix: string;
1925
+ module?: string;
1926
+ permissions?: ResourcePermissions;
1927
+ presets: string[];
1928
+ customRoutes?: Array<{
1929
+ method: string;
1930
+ path: string;
1931
+ handler: string;
1932
+ operation?: string;
1933
+ summary?: string;
1934
+ description?: string;
1935
+ permissions?: PermissionCheck;
1936
+ raw?: boolean;
1937
+ schema?: Record<string, unknown>;
1938
+ }>;
1939
+ routes: Array<{
1940
+ method: string;
1941
+ path: string;
1942
+ handler?: string;
1943
+ operation?: string;
1944
+ summary?: string;
1945
+ }>;
1946
+ events?: string[];
1947
+ }
1948
+ interface RegistryEntry extends ResourceMetadata {
1949
+ plugin: unknown;
1950
+ adapter?: {
1951
+ type: string;
1952
+ name: string;
1953
+ } | null;
1954
+ events?: string[];
1955
+ disableDefaultRoutes?: boolean;
1956
+ openApiSchemas?: OpenApiSchemas;
1957
+ registeredAt?: string;
1958
+ /** Field-level permissions metadata (for OpenAPI docs) */
1959
+ fieldPermissions?: Record<string, {
1960
+ type: string;
1961
+ roles?: readonly string[];
1962
+ redactValue?: unknown;
1963
+ }>;
1964
+ /** Pipeline step names (for OpenAPI docs) */
1965
+ pipelineSteps?: Array<{
1966
+ type: string;
1967
+ name: string;
1968
+ operations?: string[];
1969
+ }>;
1970
+ /** Update HTTP method(s) used for this resource */
1971
+ updateMethod?: "PUT" | "PATCH" | "both";
1972
+ /** Routes disabled for this resource */
1973
+ disabledRoutes?: string[];
1974
+ /** Rate limit config */
1975
+ rateLimit?: RateLimitConfig | false;
1976
+ /** Per-resource audit opt-in flag (read by `auditPlugin` perResource mode) */
1977
+ audit?: boolean | {
1978
+ operations?: ("create" | "update" | "delete")[];
1979
+ };
1980
+ /**
1981
+ * v2.8 declarative actions metadata — populated from
1982
+ * `ResourceConfig.actions`. Consumed by OpenAPI generation (renders
1983
+ * `POST /:id/action` with a discriminated body schema) and MCP tool
1984
+ * generation. Added in 2.8.1.
1985
+ */
1986
+ actions?: Array<{
1987
+ readonly name: string;
1988
+ readonly description?: string; /** Raw per-action schema (JSON Schema, Zod v4, or legacy field map) */
1989
+ readonly schema?: Record<string, unknown>; /** Per-action permission check (if different from resource-level `actionPermissions`) */
1990
+ readonly permissions?: PermissionCheck; /** MCP tool generation flag — `false` to skip, object for overrides */
1991
+ readonly mcp?: boolean | {
1992
+ readonly description?: string;
1993
+ readonly annotations?: Record<string, unknown>;
1994
+ };
1995
+ }>;
1996
+ /**
1997
+ * Resource-level fallback permission for actions without per-action
1998
+ * permissions. Used by OpenAPI to determine auth requirements and by
1999
+ * MCP as the fallback in `createActionToolHandler`. Added in 2.8.1.
2000
+ */
2001
+ actionPermissions?: PermissionCheck;
2002
+ }
2003
+ interface RegistryStats {
2004
+ total?: number;
2005
+ totalResources: number;
2006
+ byTag?: Record<string, number>;
2007
+ byModule?: Record<string, number>;
2008
+ presetUsage?: Record<string, number>;
2009
+ totalRoutes?: number;
2010
+ totalEvents?: number;
2011
+ }
2012
+ interface IntrospectionData {
2013
+ resources: ResourceMetadata[];
2014
+ stats: RegistryStats;
2015
+ generatedAt?: string;
2016
+ }
2017
+ //#endregion
2018
+ //#region src/types/validation.d.ts
2019
+ /**
2020
+ * Validation result types — produced by `validateResourceConfig` and
2021
+ * consumed by `assertValidConfig` / `formatValidationErrors`.
2022
+ */
2023
+ interface ConfigError {
2024
+ field: string;
2025
+ message: string;
2026
+ code?: string;
2027
+ }
2028
+ interface ValidationResult {
2029
+ valid: boolean;
2030
+ errors: ConfigError[];
2031
+ }
2032
+ interface ValidateOptions {
2033
+ strict?: boolean;
2034
+ }
2035
+ //#endregion
2036
+ //#region src/types/utility.d.ts
2765
2037
  /**
2766
- * Auth Plugin Options - Clean, Minimal Configuration
2038
+ * Infer document type from a `DataAdapter` or `ResourceConfig`. Smart
2039
+ * inference that works with multiple sources.
2767
2040
  *
2768
- * Arc provides JWT infrastructure and calls your authenticator.
2769
- * You control ALL authentication logic.
2041
+ * @example
2042
+ * ```typescript
2043
+ * type Doc1 = InferDocType<typeof adapter>; // From DataAdapter
2044
+ * type Doc2 = InferDocType<typeof resource>; // From ResourceConfig
2045
+ * ```
2046
+ */
2047
+ type InferDocType<T> = T extends DataAdapter<infer D> ? D : T extends ResourceConfig<infer D> ? D : never;
2048
+ /**
2049
+ * Infer document type from a `DataAdapter`. Falls back to `unknown`
2050
+ * (not `never`) — safe for generic constraints.
2770
2051
  *
2771
2052
  * @example
2772
2053
  * ```typescript
2773
- * // Minimal: just JWT (uses default jwtVerify)
2774
- * auth: {
2775
- * jwt: { secret: process.env.JWT_SECRET },
2776
- * }
2054
+ * const adapter = createMongooseAdapter({ model: ProductModel, repository: productRepo });
2055
+ * type ProductDoc = InferAdapterDoc<typeof adapter>;
2056
+ * ```
2057
+ */
2058
+ type InferAdapterDoc<A> = A extends DataAdapter<infer D> ? D : unknown;
2059
+ type InferResourceDoc<T> = T extends ResourceConfig<infer D> ? D : never;
2060
+ type TypedResourceConfig<TDoc> = ResourceConfig<TDoc>;
2061
+ type TypedController<TDoc> = IController<TDoc>;
2062
+ type TypedRepository<TDoc> = StandardRepo<TDoc>;
2063
+ //#endregion
2064
+ //#region src/types/repository.d.ts
2065
+ /**
2066
+ * Discriminated union of pagination result shapes. Narrow on `method`.
2777
2067
  *
2778
- * // With custom authenticator (recommended)
2779
- * auth: {
2780
- * jwt: { secret: process.env.JWT_SECRET },
2781
- * authenticate: async (request, { jwt }) => {
2782
- * const token = request.headers.authorization?.split(' ')[1];
2783
- * if (!token) return null;
2784
- * const decoded = jwt.verify(token);
2785
- * return userRepo.findById(decoded.id);
2786
- * },
2787
- * }
2068
+ * repo-core ships the individual shapes (`OffsetPaginationResult` /
2069
+ * `KeysetPaginationResult`) but no combined union — arc needs one for the
2070
+ * BaseController's `list` / `getDeleted` return signatures, where either
2071
+ * shape is valid depending on the caller's pagination params.
2788
2072
  *
2789
- * // Multi-strategy (JWT + API Key)
2790
- * auth: {
2791
- * jwt: { secret: process.env.JWT_SECRET },
2792
- * authenticate: async (request, { jwt }) => {
2793
- * // Try API key first (faster)
2794
- * const apiKey = request.headers['x-api-key'];
2795
- * if (apiKey) {
2796
- * const result = await apiKeyService.verify(apiKey);
2797
- * if (result) return { _id: result.userId, isApiKey: true };
2798
- * }
2799
- * // Try JWT
2800
- * const token = request.headers.authorization?.split(' ')[1];
2801
- * if (token) {
2802
- * const decoded = jwt.verify(token);
2803
- * return userRepo.findById(decoded.id);
2804
- * }
2805
- * return null;
2806
- * },
2807
- * onFailure: (request, reply) => {
2808
- * reply.code(401).send({
2809
- * success: false,
2810
- * error: 'Authentication required',
2811
- * message: 'Use Bearer token or X-API-Key header',
2812
- * });
2813
- * },
2073
+ * @example
2074
+ * ```ts
2075
+ * const result = await repo.getAll(params);
2076
+ * if (result.method === 'keyset') {
2077
+ * result.next; // keyset cursor
2078
+ * } else {
2079
+ * result.page; // offset number
2814
2080
  * }
2815
2081
  * ```
2816
2082
  */
2817
- interface AuthPluginOptions {
2083
+ type PaginationResult<TDoc, TExtra extends Record<string, unknown> = Record<string, never>> = OffsetPaginationResult<TDoc, TExtra> | KeysetPaginationResult<TDoc, TExtra>;
2084
+ //#endregion
2085
+ //#region src/core/AccessControl.d.ts
2086
+ /** Denial reason codes returned by `fetchDetailed()`. */
2087
+ type FetchDenialReason = "NOT_FOUND" | "POLICY_FILTERED" | "ORG_SCOPE_DENIED";
2088
+ /** Result of a detailed fetch with access control. */
2089
+ interface FetchResult<TDoc> {
2090
+ /** The document, or null if denied. */
2091
+ doc: TDoc | null;
2092
+ /** Null when the doc was found. A string code when denied. */
2093
+ reason: FetchDenialReason | null;
2094
+ }
2095
+ interface AccessControlConfig {
2096
+ /** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable org filtering. */
2097
+ tenantField: string | false;
2098
+ /** Primary key field name (default: '_id') */
2099
+ idField: string;
2100
+ /**
2101
+ * Custom filter matching for policy enforcement.
2102
+ * Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
2103
+ * Falls back to built-in MongoDB-style matching if not provided.
2104
+ */
2105
+ matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
2106
+ }
2107
+ /** Minimal repository interface for access-controlled fetch operations */
2108
+ interface AccessControlRepository {
2109
+ getById(id: string, options?: QueryOptions): Promise<unknown>;
2110
+ getOne?: (filter: AnyRecord, options?: QueryOptions) => Promise<unknown>;
2111
+ }
2112
+ declare class AccessControl {
2113
+ private readonly tenantField;
2114
+ private readonly idField;
2115
+ private readonly _adapterMatchesFilter?;
2116
+ /** Patterns that indicate dangerous regex (nested quantifiers, excessive backtracking).
2117
+ * Uses [^...] character classes instead of .+ to avoid backtracking in the detector itself. */
2118
+ private static readonly DANGEROUS_REGEX;
2119
+ /** Forbidden paths that could lead to prototype pollution */
2120
+ private static readonly FORBIDDEN_PATHS;
2121
+ constructor(config: AccessControlConfig);
2122
+ /**
2123
+ * Build filter for single-item operations (get/update/delete)
2124
+ * Combines ID filter with policy/org filters for proper security enforcement
2125
+ */
2126
+ buildIdFilter(id: string, req: IRequestContext): AnyRecord;
2127
+ /**
2128
+ * Check if item matches policy filters (for get/update/delete operations)
2129
+ * Validates that fetched item satisfies all policy constraints
2130
+ *
2131
+ * Delegates to adapter-provided matchesFilter if available (for SQL, etc.),
2132
+ * otherwise falls back to built-in MongoDB-style matching.
2133
+ */
2134
+ checkPolicyFilters(item: AnyRecord, req: IRequestContext): boolean;
2135
+ /**
2136
+ * Check org/tenant scope for a document — uses configurable tenantField.
2137
+ *
2138
+ * SECURITY: When org scope is active (orgId present), documents that are
2139
+ * missing the tenant field are DENIED by default. This prevents legacy or
2140
+ * unscoped records from leaking across tenants.
2141
+ */
2142
+ checkOrgScope(item: AnyRecord | null, arcContext: ArcInternalMetadata | RequestContext | undefined): boolean;
2143
+ /** Check ownership for update/delete (ownedByUser preset) */
2144
+ checkOwnership(item: AnyRecord | null, req: IRequestContext): boolean;
2145
+ /**
2146
+ * Fetch a single document with full access control enforcement.
2147
+ * Combines compound DB filter (ID + org + policy) with post-hoc fallback.
2148
+ *
2149
+ * Takes repository as a parameter to avoid coupling.
2150
+ *
2151
+ * Replaces the duplicated pattern in get/update/delete:
2152
+ * buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
2153
+ */
2154
+ fetchWithAccessControl<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<TDoc | null>;
2155
+ /**
2156
+ * Same as `fetchWithAccessControl` but returns a structured result with
2157
+ * a denial reason so callers can distinguish "doc doesn't exist" from
2158
+ * "doc exists but was filtered by policy/org scope" from "repo threw".
2159
+ *
2160
+ * Codes:
2161
+ * - `null` — doc was found, no denial
2162
+ * - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
2163
+ * - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
2164
+ * - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
2165
+ * - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
2166
+ */
2167
+ fetchDetailed<TDoc>(id: string, req: IRequestContext, repository: AccessControlRepository, queryOptions?: QueryOptions): Promise<FetchResult<TDoc>>;
2168
+ /**
2169
+ * Post-fetch access control validation for items fetched by non-ID queries
2170
+ * (e.g., getBySlug, restore). Applies org scope, policy filters, and
2171
+ * ownership checks — the same guarantees as fetchWithAccessControl.
2172
+ */
2173
+ validateItemAccess(item: AnyRecord | null, req: IRequestContext): boolean;
2174
+ /** Extract typed Arc internal metadata from request */
2175
+ private _meta;
2818
2176
  /**
2819
- * JWT configuration (optional but recommended)
2820
- * If provided, jwt utilities are available in authenticator context
2177
+ * Check if a value matches a MongoDB query operator
2821
2178
  */
2822
- jwt?: {
2823
- /** JWT secret (required for JWT features) */secret: string; /** Access token expiry (default: '15m') */
2824
- expiresIn?: string; /** Refresh token secret (defaults to main secret) */
2825
- refreshSecret?: string; /** Refresh token expiry (default: '7d') */
2826
- refreshExpiresIn?: string; /** Additional @fastify/jwt sign options */
2827
- sign?: Record<string, unknown>; /** Additional @fastify/jwt verify options */
2828
- verify?: Record<string, unknown>;
2829
- };
2179
+ private matchesOperator;
2830
2180
  /**
2831
- * Custom authenticator function (recommended)
2832
- *
2833
- * Arc calls this for non-public routes.
2834
- * Return user object to authenticate, null/undefined to reject.
2835
- *
2836
- * If not provided and jwt.secret is set, uses default jwtVerify.
2181
+ * Check if item matches a single filter condition
2182
+ * Supports nested paths (e.g., "owner.id", "metadata.status")
2837
2183
  */
2838
- authenticate?: Authenticator;
2184
+ private matchesFilter;
2839
2185
  /**
2840
- * Custom auth failure handler
2841
- * Customize the 401 response when authentication fails
2186
+ * Built-in MongoDB-style policy filter matching.
2187
+ * Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
2842
2188
  */
2843
- onFailure?: (request: FastifyRequest, reply: FastifyReply, error?: Error) => void | Promise<void>;
2189
+ private defaultMatchesPolicyFilters;
2844
2190
  /**
2845
- * Expose detailed auth error messages in 401 responses.
2846
- * When false (default), returns generic "Authentication required".
2847
- * When true, includes the actual error message for debugging.
2848
- * Decoupled from log level — set explicitly per environment.
2191
+ * Get nested value from object using dot notation (e.g., "owner.id")
2192
+ * Security: Validates path against forbidden patterns to prevent prototype pollution
2849
2193
  */
2850
- exposeAuthErrors?: boolean;
2194
+ private getNestedValue;
2851
2195
  /**
2852
- * Property name to store user on request (default: 'user')
2196
+ * Create a safe RegExp from a string, guarding against ReDoS.
2197
+ * Returns null if the pattern is invalid or dangerous.
2853
2198
  */
2854
- userProperty?: string;
2199
+ private static safeRegex;
2200
+ }
2201
+ //#endregion
2202
+ //#region src/core/BodySanitizer.d.ts
2203
+ /**
2204
+ * Policy for handling fields the caller lacks write permission for.
2205
+ *
2206
+ * - `'reject'` (default, secure): throw 403 listing the denied fields so
2207
+ * misconfigurations and attacks surface instead of silently disappearing.
2208
+ * - `'strip'` (legacy): silently drop the field and continue. Preserved for
2209
+ * apps that relied on the pre-2.9 behaviour — new code should not use it.
2210
+ */
2211
+ type FieldWriteDenialPolicy = "reject" | "strip";
2212
+ interface BodySanitizerConfig {
2213
+ /** Schema options for field sanitization */
2214
+ schemaOptions: RouteSchemaOptions;
2855
2215
  /**
2856
- * Custom token extractor for the built-in JWT auth path.
2857
- * When not provided, defaults to extracting Bearer token from Authorization header.
2858
- * Use this when tokens are in HttpOnly cookies, custom headers, or query params.
2859
- *
2860
- * @example
2861
- * ```typescript
2862
- * // Extract from HttpOnly cookie
2863
- * tokenExtractor: (request) => request.cookies?.['auth-token'] ?? null,
2864
- *
2865
- * // Extract from custom header
2866
- * tokenExtractor: (request) => request.headers['x-api-token'] as string ?? null,
2867
- * ```
2216
+ * What to do when a request contains fields the caller can't write.
2217
+ * Default: `'reject'` surface the misconfiguration as a 403.
2868
2218
  */
2869
- tokenExtractor?: (request: FastifyRequest) => string | null;
2219
+ onFieldWriteDenied?: FieldWriteDenialPolicy;
2220
+ }
2221
+ declare class BodySanitizer {
2222
+ private schemaOptions;
2223
+ private onFieldWriteDenied;
2224
+ constructor(config: BodySanitizerConfig);
2870
2225
  /**
2871
- * Token revocation check called after JWT verification succeeds.
2872
- * Return `true` to reject the token (revoked), `false` to allow.
2873
- *
2874
- * Arc provides this primitive — implement your own store (Redis set,
2875
- * DB lookup, Better Auth session check, etc.)
2876
- *
2877
- * **Fail-closed**: if the check throws, the token is treated as revoked.
2878
- *
2879
- * @example
2880
- * ```typescript
2881
- * // Redis-backed revocation
2882
- * isRevoked: async (decoded) => {
2883
- * return await redis.sismember('revoked-tokens', decoded.jti ?? decoded.id);
2884
- * },
2226
+ * Strip readonly and system-managed fields from request body.
2227
+ * Prevents clients from overwriting _id, timestamps, __v, etc.
2885
2228
  *
2886
- * // DB-backed revocation
2887
- * isRevoked: async (decoded) => {
2888
- * const user = await db.user.findById(decoded.id);
2889
- * return !user || user.bannedAt != null;
2890
- * },
2891
- * ```
2229
+ * Also applies field-level write permissions when the request has
2230
+ * field permission metadata.
2892
2231
  */
2893
- isRevoked?: (decoded: Record<string, unknown>) => boolean | Promise<boolean>;
2894
- }
2895
- interface IntrospectionPluginOptions {
2896
- path?: string;
2897
- prefix?: string;
2898
- enabled?: boolean;
2899
- authRoles?: string[];
2232
+ sanitize(body: AnyRecord, _operation: "create" | "update", req?: IRequestContext, meta?: ArcInternalMetadata): AnyRecord;
2900
2233
  }
2901
- interface CrudRouterOptions {
2902
- /** Route prefix */
2903
- prefix?: string;
2904
- /** Permission checks for CRUD operations */
2905
- permissions?: ResourcePermissions;
2906
- /** OpenAPI tag for grouping routes */
2907
- tag?: string;
2908
- /** JSON schemas for CRUD operations */
2909
- schemas?: Partial<CrudSchemas>;
2910
- /** Middlewares for each CRUD operation */
2911
- middlewares?: MiddlewareConfig;
2912
- /** Additional custom routes (from presets or user-defined) */
2913
- additionalRoutes?: AdditionalRoute[];
2914
- /** Disable all default CRUD routes */
2915
- disableDefaultRoutes?: boolean;
2916
- /** Disable specific CRUD routes */
2917
- disabledRoutes?: CrudRouteKey[];
2918
- /** Functional pipeline (guard/transform/intercept) */
2919
- pipe?: PipelineConfig;
2920
- /** Resource name for lifecycle hooks */
2921
- resourceName?: string;
2922
- /** Schema generation options */
2234
+ //#endregion
2235
+ //#region src/core/QueryResolver.d.ts
2236
+ interface QueryResolverConfig {
2237
+ /** Query parser instance (default: Arc built-in parser) */
2238
+ queryParser?: QueryParserInterface;
2239
+ /** Maximum limit for pagination (default: 100) */
2240
+ maxLimit?: number;
2241
+ /** Default limit for pagination (default: 20) */
2242
+ defaultLimit?: number;
2243
+ /** Default sort field (default: '-createdAt') */
2244
+ defaultSort?: string;
2245
+ /** Schema options for field sanitization */
2923
2246
  schemaOptions?: RouteSchemaOptions;
2924
- /** Field-level permissions (visibility, writability per role) */
2925
- fields?: FieldPermissionMap;
2926
- /** HTTP method for update routes. Default: 'PATCH' */
2927
- updateMethod?: 'PUT' | 'PATCH' | 'both';
2247
+ /** Field name used for multi-tenant scoping (default: 'organizationId'). Set to `false` to disable. */
2248
+ tenantField?: string | false;
2249
+ }
2250
+ declare class QueryResolver {
2251
+ private queryParser;
2252
+ private maxLimit;
2253
+ private defaultLimit;
2254
+ private defaultSort;
2255
+ private schemaOptions;
2256
+ private tenantField;
2257
+ constructor(config?: QueryResolverConfig);
2928
2258
  /**
2929
- * Per-resource rate limiting.
2930
- * Requires `@fastify/rate-limit` to be registered on the Fastify instance.
2931
- * Set to `false` to disable rate limiting for this resource.
2259
+ * Resolve a request into parsed query options -- ONE parse per request.
2260
+ * Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
2932
2261
  */
2933
- rateLimit?: RateLimitConfig | false;
2934
- /** PreHandler guards applied to every route (CRUD + custom + preset). */
2935
- routeGuards?: RouteHandlerMethod[];
2936
- }
2937
- interface ResourceMetadata {
2938
- name: string;
2939
- displayName?: string;
2940
- tag?: string;
2941
- prefix: string;
2942
- module?: string;
2943
- permissions?: ResourcePermissions;
2944
- presets: string[];
2945
- customRoutes?: Array<{
2946
- method: string;
2947
- path: string;
2948
- handler: string;
2949
- operation?: string;
2950
- summary?: string;
2951
- description?: string;
2952
- permissions?: PermissionCheck;
2953
- raw?: boolean;
2954
- schema?: Record<string, unknown>;
2955
- }>;
2956
- routes: Array<{
2957
- method: string;
2958
- path: string;
2959
- handler?: string;
2960
- operation?: string;
2961
- summary?: string;
2962
- }>;
2963
- events?: string[];
2262
+ resolve(req: IRequestContext, meta?: ArcInternalMetadata): ControllerQueryOptions;
2263
+ /**
2264
+ * Sanitize select — preserves the input format (string, array, or object).
2265
+ * This is critical for db-agnostic support: MongoKit returns object projections,
2266
+ * Mongoose uses space-separated strings, SQL adapters may use arrays.
2267
+ */
2268
+ private sanitizeSelectAny;
2269
+ /** Sanitize populate fields */
2270
+ private sanitizePopulate;
2271
+ /** Sanitize advanced populate options against allowedPopulate */
2272
+ private sanitizePopulateOptions;
2273
+ /**
2274
+ * Sanitize lookup/join options.
2275
+ * If schemaOptions.query.allowedLookups is set, only those collections are allowed.
2276
+ * Validates lookup structure to prevent injection.
2277
+ */
2278
+ private sanitizeLookups;
2279
+ /** Get blocked fields from schema options */
2280
+ private getBlockedFields;
2964
2281
  }
2965
- interface RegistryEntry extends ResourceMetadata {
2966
- plugin: unknown;
2967
- adapter?: {
2968
- type: string;
2969
- name: string;
2970
- } | null;
2971
- events?: string[];
2972
- disableDefaultRoutes?: boolean;
2973
- openApiSchemas?: OpenApiSchemas;
2974
- registeredAt?: string;
2975
- /** Field-level permissions metadata (for OpenAPI docs) */
2976
- fieldPermissions?: Record<string, {
2977
- type: string;
2978
- roles?: readonly string[];
2979
- redactValue?: unknown;
2980
- }>;
2981
- /** Pipeline step names (for OpenAPI docs) */
2982
- pipelineSteps?: Array<{
2983
- type: string;
2984
- name: string;
2985
- operations?: string[];
2986
- }>;
2987
- /** Update HTTP method(s) used for this resource */
2988
- updateMethod?: 'PUT' | 'PATCH' | 'both';
2989
- /** Routes disabled for this resource */
2990
- disabledRoutes?: string[];
2991
- /** Rate limit config */
2992
- rateLimit?: RateLimitConfig | false;
2993
- /** Per-resource audit opt-in flag (read by auditPlugin perResource mode) */
2994
- audit?: boolean | {
2995
- operations?: ("create" | "update" | "delete")[];
2996
- };
2282
+ //#endregion
2283
+ //#region src/core/BaseController.d.ts
2284
+ interface BaseControllerOptions {
2285
+ /** Schema options for field sanitization */
2286
+ schemaOptions?: RouteSchemaOptions;
2287
+ /**
2288
+ * Query parser instance.
2289
+ * Default: Arc built-in query parser (adapter-agnostic).
2290
+ * Swap in MongoKit QueryParser, pgkit parser, etc.
2291
+ */
2292
+ queryParser?: QueryParserInterface;
2293
+ /** Maximum limit for pagination (default: 100) */
2294
+ maxLimit?: number;
2295
+ /** Default limit for pagination (default: 20) */
2296
+ defaultLimit?: number;
2297
+ /** Default sort field (default: '-createdAt') */
2298
+ defaultSort?: string;
2299
+ /** Resource name for hook execution (e.g., 'product' -> 'product.created') */
2300
+ resourceName?: string;
2997
2301
  /**
2998
- * v2.8 declarative actions metadata populated from `ResourceConfig.actions`.
2302
+ * Field name used for multi-tenant scoping (default: 'organizationId').
2303
+ * Override to match your schema: 'workspaceId', 'tenantId', 'teamId', etc.
2304
+ * Set to `false` to disable org filtering for platform-universal resources.
2305
+ */
2306
+ tenantField?: string | false;
2307
+ /**
2308
+ * Primary key field name (default: '_id').
2309
+ *
2310
+ * If not set, the controller auto-derives it from the repository's own
2311
+ * `idField` property (e.g. MongoKit's `Repository({ idField: 'id' })`),
2312
+ * so you only need to configure it in one place.
2999
2313
  *
3000
- * Consumed by OpenAPI generation (renders `POST /:id/action` with a
3001
- * discriminated body schema) and MCP tool generation.
2314
+ * Set explicitly to override the repo's setting (e.g. `'_id'` to opt out
2315
+ * of native pass-through and force the slug-translation path).
3002
2316
  *
3003
- * Added in 2.8.1.
2317
+ * Override for non-MongoDB adapters (e.g., 'id' for SQL databases).
3004
2318
  */
3005
- actions?: Array<{
3006
- readonly name: string;
3007
- readonly description?: string; /** Raw per-action schema (JSON Schema, Zod v4, or legacy field map) */
3008
- readonly schema?: Record<string, unknown>; /** Per-action permission check (if different from resource-level `actionPermissions`) */
3009
- readonly permissions?: PermissionCheck; /** MCP tool generation flag `false` to skip, object for overrides */
3010
- readonly mcp?: boolean | {
3011
- readonly description?: string;
3012
- readonly annotations?: Record<string, unknown>;
3013
- };
3014
- }>;
2319
+ idField?: string;
2320
+ /**
2321
+ * Custom filter matching for policy enforcement.
2322
+ * Provided by the DataAdapter for non-MongoDB databases (SQL, etc.).
2323
+ * Falls back to built-in MongoDB-style matching if not provided.
2324
+ */
2325
+ matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
2326
+ /** Cache configuration for the resource */
2327
+ cache?: ResourceCacheConfig;
2328
+ /** Internal preset fields map (slug, tree, etc.) */
2329
+ presetFields?: {
2330
+ slugField?: string;
2331
+ parentField?: string;
2332
+ };
3015
2333
  /**
3016
- * Resource-level fallback permission for actions without per-action
3017
- * permissions. Used by OpenAPI to determine auth requirements and by MCP
3018
- * as the fallback in `createActionToolHandler`.
2334
+ * Policy for requests that include fields the caller can't write.
3019
2335
  *
3020
- * Added in 2.8.1 previously not surfaced to downstream consumers,
3021
- * causing OpenAPI to mark action endpoints as public when runtime required auth.
2336
+ * - `'reject'` (default): 403 with the denied field names. Surfaces
2337
+ * misconfigurations and attempts to set protected fields instead of
2338
+ * silently dropping them.
2339
+ * - `'strip'`: legacy silent-drop behaviour. Only opt in when migrating
2340
+ * code that relied on the pre-2.9 permissive default.
3022
2341
  */
3023
- actionPermissions?: PermissionCheck;
3024
- }
3025
- interface RegistryStats {
3026
- total?: number;
3027
- totalResources: number;
3028
- byTag?: Record<string, number>;
3029
- byModule?: Record<string, number>;
3030
- presetUsage?: Record<string, number>;
3031
- totalRoutes?: number;
3032
- totalEvents?: number;
3033
- }
3034
- interface IntrospectionData {
3035
- resources: ResourceMetadata[];
3036
- stats: RegistryStats;
3037
- generatedAt?: string;
3038
- }
3039
- interface EventDefinition {
3040
- name: string;
3041
- /** Optional handler — events are published via fastify.events.publish(), not invoked through resource definitions */
3042
- handler?: (data: unknown) => Promise<void> | void;
3043
- schema?: Record<string, unknown>;
3044
- description?: string;
3045
- }
3046
- interface ConfigError {
3047
- field: string;
3048
- message: string;
3049
- code?: string;
3050
- }
3051
- interface ValidationResult$1 {
3052
- valid: boolean;
3053
- errors: ConfigError[];
3054
- }
3055
- interface ValidateOptions {
3056
- strict?: boolean;
2342
+ onFieldWriteDenied?: FieldWriteDenialPolicy;
3057
2343
  }
3058
2344
  /**
3059
- * Infer document type from DataAdapter or ResourceConfig
3060
- */
3061
- type InferDocType<T> = T extends DataAdapter<infer D> ? D : T extends ResourceConfig<infer D> ? D : never;
3062
- /**
3063
- * Infer document type from a DataAdapter.
3064
- * Falls back to `unknown` (not `never`) — safe for generic constraints.
3065
- *
3066
- * @example
3067
- * ```typescript
3068
- * const adapter = createMongooseAdapter({ model: ProductModel, repository: productRepo });
3069
- * type ProductDoc = InferAdapterDoc<typeof adapter>;
3070
- * // ProductDoc = the document type inferred from the adapter
3071
- * ```
3072
- */
3073
- type InferAdapterDoc<A> = A extends DataAdapter<infer D> ? D : unknown;
3074
- type InferResourceDoc<T> = T extends ResourceConfig<infer D> ? D : never;
3075
- type TypedResourceConfig<TDoc> = ResourceConfig<TDoc>;
3076
- type TypedController<TDoc> = IController<TDoc>;
3077
- type TypedRepository<TDoc> = CrudRepository<TDoc>;
3078
- //#endregion
3079
- //#region src/adapters/interface.d.ts
3080
- /**
3081
- * Minimal structural repository shape for flexible adapter compatibility.
3082
- *
3083
- * `RepositoryLike` is the **loose** variant of `CrudRepository<TDoc>` — it
3084
- * uses `unknown` for document payloads so any object with the right method
3085
- * names satisfies it without type assertions. Prefer `CrudRepository<TDoc>`
3086
- * for kits you own; use `RepositoryLike` when wrapping third-party repos.
3087
- *
3088
- * Both interfaces declare the same tiered capabilities:
2345
+ * Framework-agnostic base controller implementing IController.
3089
2346
  *
3090
- * - **Required** `getAll`, `getById`, `create`, `update`, `delete`
3091
- * - **Recommended** `getOne` / `getByQuery` (used by AccessControl for
3092
- * compound filters like `idField + orgId + policy`)
3093
- * - **Optional** — feature-detected at runtime by presets and the
3094
- * BaseController. Declare only what your DB supports.
2347
+ * Composes AccessControl, BodySanitizer, and QueryResolver for clean
2348
+ * separation of concerns. CRUD methods delegate directly to these
2349
+ * composed classes no intermediate wrapper methods.
3095
2350
  *
3096
- * See [CrudRepository](../types/repository.ts) for full prose-level docs
3097
- * on each method and the design rationale behind the tiering.
2351
+ * @template TDoc - The document type
2352
+ * @template TRepository - The repository type (defaults to RepositoryLike)
3098
2353
  */
3099
- interface RepositoryLike {
2354
+ declare class BaseController<TDoc = AnyRecord, TRepository extends RepositoryLike = RepositoryLike> implements IController<TDoc> {
2355
+ protected repository: TRepository;
2356
+ protected schemaOptions: RouteSchemaOptions;
2357
+ protected queryParser: QueryParserInterface;
2358
+ protected maxLimit: number;
2359
+ protected defaultLimit: number;
2360
+ protected defaultSort: string;
2361
+ protected resourceName?: string;
2362
+ protected tenantField: string | false;
2363
+ protected idField: string;
2364
+ /** Composable access control (ID filtering, policy checks, org scope, ownership) */
2365
+ readonly accessControl: AccessControl;
2366
+ /** Composable body sanitization (field permissions, system fields) */
2367
+ readonly bodySanitizer: BodySanitizer;
2368
+ /** Composable query resolution (parsing, pagination, sort, select/populate) */
2369
+ readonly queryResolver: QueryResolver;
2370
+ private _matchesFilter?;
2371
+ private _presetFields;
2372
+ private _cacheConfig?;
2373
+ constructor(repository: TRepository, options?: BaseControllerOptions);
2374
+ /**
2375
+ * Get the tenant field name if multi-tenant scoping is enabled.
2376
+ * Returns `undefined` when `tenantField` is `false` (platform-universal mode).
2377
+ *
2378
+ * Use this in subclass overrides instead of accessing `this.tenantField` directly
2379
+ * to avoid TypeScript indexing errors with `string | false`.
2380
+ */
2381
+ protected getTenantField(): string | undefined;
2382
+ /** Extract typed Arc internal metadata from request */
2383
+ private meta;
2384
+ /** Get hook system from request context (instance-scoped) */
2385
+ private getHooks;
3100
2386
  /**
3101
- * The repository's native primary key field. When set, arc's BaseController
3102
- * passes route params through to `update()`/`delete()`/`restore()` calls
3103
- * unchanged instead of translating them to `_id`.
2387
+ * Resolve the repository primary key for mutation calls (update/delete/restore).
2388
+ *
2389
+ * When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
2390
+ * the default behavior is to translate the route id → the fetched doc's `_id`
2391
+ * because most Mongo repositories key their mutation methods off `_id`.
3104
2392
  *
3105
- * Match this to your `defineResource({ idField })` for repositories that
3106
- * natively look up by a custom field (e.g. mongokit's
3107
- * `new Repository(Model, [], {}, { idField: 'id' })`). Without it, arc
3108
- * will try to translate route ids fetched doc's `_id`, which 404s on
3109
- * repos that don't key on `_id`.
2393
+ * Exception: if the repository itself exposes a matching `idField` property
2394
+ * (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
2395
+ * repository already knows how to look up by that field — so we pass the
2396
+ * route id through unchanged and skip the translation.
3110
2397
  *
3111
- * Defaults to `'_id'` (Mongo). Kits that always use `_id` may omit it.
2398
+ * This makes `defineResource({ idField: 'id' })` work end-to-end with repos
2399
+ * that natively support custom primary keys, without breaking the slug-style
2400
+ * aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
3112
2401
  */
3113
- readonly idField?: string;
3114
- getAll(params?: PaginationParams, options?: QueryOptions): Promise<unknown>;
3115
- getById(id: string, options?: QueryOptions): Promise<unknown>;
3116
- create(data: unknown, options?: WriteOptions): Promise<unknown>;
3117
- update(id: string, data: unknown, options?: WriteOptions): Promise<unknown>;
3118
- /**
3119
- * Delete by primary key. Pass `{ mode: 'hard' }` to bypass soft-delete
3120
- * interception (required by arc's hard-delete flow — `?hard=true` on
3121
- * the DELETE route forwards this option).
3122
- */
3123
- delete(id: string, options?: DeleteOptions): Promise<unknown>;
3124
- /**
3125
- * Find a single doc by compound filter. Used by AccessControl for
3126
- * `idField + org + policy` scoping. Without this, arc falls back to
3127
- * `getById` + post-fetch security checks (slower, and 404s on custom
3128
- * idFields that live outside the user's scope).
3129
- */
3130
- getOne?(filter: Record<string, unknown>, options?: QueryOptions): Promise<unknown>;
3131
- /** Alias many kits expose alongside `getOne`. Arc checks both. */
3132
- getByQuery?(filter: Record<string, unknown>, options?: QueryOptions): Promise<unknown>;
3133
- count?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<number>;
3134
- exists?(filter: Record<string, unknown>, options?: QueryOptions): Promise<boolean | {
3135
- _id: unknown;
3136
- } | null>;
3137
- distinct?<T = unknown>(field: string, filter?: Record<string, unknown>, options?: QueryOptions): Promise<T[]>;
3138
- findAll?(filter?: Record<string, unknown>, options?: QueryOptions): Promise<unknown[]>;
3139
- getOrCreate?(filter: Record<string, unknown>, data: unknown, options?: WriteOptions): Promise<unknown>;
3140
- createMany?(items: unknown[], options?: WriteOptions): Promise<unknown[]>;
3141
- updateMany?(filter: Record<string, unknown>, data: Record<string, unknown>, options?: WriteOptions): Promise<UpdateManyResult>;
3142
- deleteMany?(filter: Record<string, unknown>, options?: DeleteOptions): Promise<DeleteManyResult>;
3143
- bulkWrite?: unknown;
3144
- restore?(id: string, options?: QueryOptions): Promise<unknown>;
3145
- getDeleted?(params?: PaginationParams, options?: QueryOptions): Promise<PaginationResult<unknown> | unknown[]>;
3146
- aggregate?: unknown;
3147
- aggregatePaginate?: unknown;
3148
- withTransaction?<T>(callback: (session: RepositorySession) => Promise<T>, options?: Record<string, unknown>): Promise<T>;
3149
- getBySlug?(slug: string, options?: QueryOptions): Promise<unknown>;
3150
- getTree?(options?: QueryOptions): Promise<unknown>;
3151
- getChildren?(parentId: string, options?: QueryOptions): Promise<unknown>;
3152
- [key: string]: unknown;
3153
- }
3154
- interface DataAdapter<TDoc = unknown> {
2402
+ private resolveRepoId;
3155
2403
  /**
3156
- * Repository implementing CRUD operations. Accepts the typed
3157
- * `CrudRepository<TDoc>` or the loose `RepositoryLike` arc checks
3158
- * capabilities at runtime via feature detection.
2404
+ * Centralized 404 response builder. Maps the denial reason from
2405
+ * `fetchDetailed()` into a structured `details.code` so consumers can
2406
+ * programmatically distinguish "doc doesn't exist" from "doc filtered
2407
+ * by policy/org scope" without parsing error strings.
2408
+ *
2409
+ * Error messages are intentionally vague in the `error` field (don't
2410
+ * leak whether the doc exists) — the detail is in `details.code` only.
3159
2411
  */
3160
- repository: CrudRepository<TDoc> | RepositoryLike;
3161
- /** Adapter identifier for introspection */
3162
- readonly type: "mongoose" | "prisma" | "drizzle" | "typeorm" | "custom";
3163
- /** Human-readable name */
3164
- readonly name: string;
2412
+ private notFoundResponse;
2413
+ /** Resolve cache config for a specific operation, merging per-op overrides */
2414
+ private resolveCacheConfig;
2415
+ /**
2416
+ * Extract user/org IDs from request for cache key scoping.
2417
+ * Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
2418
+ * Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
2419
+ */
2420
+ private cacheScope;
2421
+ list(req: IRequestContext): Promise<IControllerResponse<OffsetPaginationResult<TDoc>>>;
2422
+ /** Execute list query through hooks (extracted for cache revalidation) */
2423
+ private executeListQuery;
2424
+ get(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
2425
+ /** Execute get query through hooks (extracted for cache revalidation) */
2426
+ private executeGetQuery;
2427
+ create(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
2428
+ update(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
2429
+ delete(req: IRequestContext): Promise<IControllerResponse<{
2430
+ message: string;
2431
+ id?: string;
2432
+ soft?: boolean;
2433
+ }>>;
2434
+ getBySlug(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
2435
+ getDeleted(req: IRequestContext): Promise<IControllerResponse<PaginationResult<TDoc>>>;
2436
+ restore(req: IRequestContext): Promise<IControllerResponse<TDoc>>;
2437
+ getTree(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
2438
+ getChildren(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
2439
+ bulkCreate(req: IRequestContext): Promise<IControllerResponse<TDoc[]>>;
3165
2440
  /**
3166
- * Generate OpenAPI schemas for CRUD operations
2441
+ * Build a tenant-scoped filter for bulk update/delete.
3167
2442
  *
3168
- * This method allows each adapter to generate schemas specific to its ORM/database.
3169
- * For example, Mongoose adapter can use mongokit to generate schemas from Mongoose models.
2443
+ * Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
2444
+ * - Always merge `_policyFilters` (from permission middleware)
2445
+ * - When `tenantField` is set AND a `member` scope is present, add the
2446
+ * org filter so cross-tenant data can't be touched.
2447
+ * - When the scope is `elevated` (platform admin), no org filter is
2448
+ * applied — admins can bulk-update across orgs intentionally.
2449
+ * - When the scope is `public` on a tenant-scoped resource, deny.
2450
+ * - When NO scope is present at all (e.g., direct controller calls in
2451
+ * unit tests, or app routes without auth middleware), the controller
2452
+ * stays lenient — it's the middleware layer's job to fail-close.
2453
+ * Apps that want fail-close on bulk routes should run the multi-tenant
2454
+ * preset middleware (or equivalent) ahead of these handlers.
3170
2455
  *
3171
- * @param options - Schema generation options (field rules, populate settings, etc.)
3172
- * @param context - Resource-level context: idField (for params schema), resourceName.
3173
- * Adapters should honor `context.idField` when producing the params
3174
- * schema — e.g. skip the ObjectId pattern when idField is a custom
3175
- * string field. Backwards compatible: legacy adapters ignoring the
3176
- * context still work because Arc strips the mismatched pattern as
3177
- * a safety net.
3178
- * @returns OpenAPI schemas for CRUD operations or null if not supported
2456
+ * Returns the merged filter, or `null` when access must be denied.
3179
2457
  */
3180
- generateSchemas?(options?: RouteSchemaOptions, context?: AdapterSchemaContext): OpenApiSchemas | Record<string, unknown> | null;
3181
- /** Extract schema metadata for OpenAPI/introspection */
3182
- getSchemaMetadata?(): SchemaMetadata | null;
3183
- /** Validate data against schema before persistence */
3184
- validate?(data: unknown): Promise<ValidationResult> | ValidationResult;
3185
- /** Health check for database connection */
3186
- healthCheck?(): Promise<boolean>;
2458
+ private buildBulkFilter;
3187
2459
  /**
3188
- * Custom filter matching for policy enforcement.
3189
- * Falls back to built-in MongoDB-style matching if not provided.
3190
- * Override this for SQL adapters or non-MongoDB query operators.
2460
+ * Sanitize a bulk update data payload through the same write-permission
2461
+ * pipeline as single-doc update(). Handles both shapes:
2462
+ *
2463
+ * - Flat: `{ name: 'x', status: 'y' }`
2464
+ * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
2465
+ *
2466
+ * For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
2467
+ * system fields, systemManaged/readonly/immutable rules, AND field-level
2468
+ * write permissions are enforced. Without this, a tenant-scoped user could
2469
+ * pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
2470
+ *
2471
+ * Returns the sanitized payload along with the list of stripped fields for
2472
+ * audit/error reporting.
3191
2473
  */
3192
- matchesFilter?: (item: unknown, filters: Record<string, unknown>) => boolean;
3193
- /** Close/cleanup resources */
3194
- close?(): Promise<void>;
3195
- }
3196
- /**
3197
- * Context passed to `adapter.generateSchemas()` so adapters can shape the
3198
- * output to match resource-level configuration (idField overrides, etc).
3199
- * All fields are optional — adapters are free to ignore this argument, in
3200
- * which case Arc applies safety-net normalization to the generated schemas.
3201
- */
3202
- interface AdapterSchemaContext {
3203
- /** The idField configured on the resource. Defaults to "_id". */
3204
- idField?: string;
3205
- /** Resource name (for error messages / logging). */
3206
- resourceName?: string;
3207
- }
3208
- interface SchemaMetadata {
3209
- name: string;
3210
- fields: Record<string, FieldMetadata>;
3211
- indexes?: Array<{
3212
- fields: string[];
3213
- unique?: boolean;
3214
- sparse?: boolean;
3215
- }>;
3216
- relations?: Record<string, RelationMetadata>;
3217
- }
3218
- interface FieldMetadata {
3219
- type: "string" | "number" | "boolean" | "date" | "object" | "array" | "objectId" | "enum";
3220
- required?: boolean;
3221
- unique?: boolean;
3222
- default?: unknown;
3223
- enum?: Array<string | number>;
3224
- min?: number;
3225
- max?: number;
3226
- minLength?: number;
3227
- maxLength?: number;
3228
- pattern?: string;
3229
- description?: string;
3230
- ref?: string;
3231
- array?: boolean;
3232
- }
3233
- interface RelationMetadata {
3234
- type: "one-to-one" | "one-to-many" | "many-to-many";
3235
- target: string;
3236
- foreignKey?: string;
3237
- through?: string;
3238
- }
3239
- interface ValidationResult {
3240
- valid: boolean;
3241
- errors?: Array<{
3242
- field: string;
3243
- message: string;
3244
- code?: string;
3245
- }>;
2474
+ private sanitizeBulkUpdateData;
2475
+ bulkUpdate(req: IRequestContext): Promise<IControllerResponse<{
2476
+ matchedCount: number;
2477
+ modifiedCount: number;
2478
+ }>>;
2479
+ /**
2480
+ * Bulk delete by `filter` or `ids`.
2481
+ *
2482
+ * Body shape (one of):
2483
+ * - `{ filter: { status: 'archived' } }` — delete by query filter
2484
+ * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
2485
+ *
2486
+ * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
2487
+ * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
2488
+ * UUID, etc.). Tenant scope and policy filters are merged in either way,
2489
+ * so cross-tenant deletes are blocked at the controller layer.
2490
+ *
2491
+ * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
2492
+ * fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
2493
+ * NOT fire for bulk operations; use the single-doc `delete()` if you need
2494
+ * them, or subscribe to the bulk lifecycle event from the events plugin.
2495
+ */
2496
+ bulkDelete(req: IRequestContext): Promise<IControllerResponse<{
2497
+ deletedCount: number;
2498
+ }>>;
3246
2499
  }
3247
- type AdapterFactory<TDoc> = (config: unknown) => DataAdapter<TDoc>;
3248
2500
  //#endregion
3249
- export { PresetFunction as $, DeleteOptions as $t, EventsDecorator as A, beforeCreate as An, BaseController as At, InferResourceDoc as B, FastifyHandler as Bt, ConfigError as C, HookPhase as Cn, TypedResourceConfig as Ct, CrudRouterOptions as D, afterCreate as Dn, ValidationResult$1 as Dt, CrudRouteKey as E, HookSystemOptions as En, ValidateOptions as Et, GracefulShutdownOptions as F, BodySanitizerConfig as Ft, LookupOption as G, RegisterOptions as Gt, IntrospectionPluginOptions as H, IControllerResponse as Ht, HealthCheck as I, AccessControl as It, ObjectId as J, defineResource as Jt, MiddlewareConfig as K, ResourceRegistry as Kt, HealthOptions as L, AccessControlConfig as Lt, FastifyWithAuth as M, beforeUpdate as Mn, QueryResolver as Mt, FastifyWithDecorators as N, createHookSystem as Nn, QueryResolverConfig as Nt, CrudSchemas as O, afterDelete as On, envelope as Ot, FieldRule as P, defineHook as Pn, BodySanitizer as Pt, PopulateOption as Q, DeleteManyResult as Qt, InferAdapterDoc as R, ControllerHandler as Rt, AuthenticatorContext as S, HookOperation as Sn, TypedRepository as St, CrudController as T, HookSystem as Tn, UserOrganization as Tt, JWTPayload as U, IRequestContext as Ut, IntrospectionData as V, IController as Vt, JwtContext as W, RouteHandler as Wt, OwnershipCheck as X, BulkWriteResult as Xt, OpenApiSchemas as Y, BulkWriteOperation as Yt, ParsedQuery as Z, CrudRepository as Zt, ArcInternalMetadata as _, PipelineStep as _n, RouteMcpConfig as _t, RelationMetadata as a, PaginationParams as an, RegistryStats as at, AuthPluginOptions as b, HookContext as bn, TokenPair as bt, ValidationResult as c, RepositorySession as cn, RequestWithExtras as ct, ActionHandlerFn as d, Guard as dn, ResourceHookContext as dt, DeleteResult as en, PresetHook as et, ActionsMap as f, Interceptor as fn, ResourceHooks as ft, ArcDecorator as g, PipelineContext as gn, RouteHandlerMethod$1 as gt, ApiResponse as h, PipelineConfig as hn, RouteDefinition as ht, FieldMetadata as i, PaginatedResult as in, RegistryEntry as it, FastifyRequestExtras as j, beforeDelete as jn, BaseControllerOptions as jt, EventDefinition as k, afterUpdate as kn, getUserId as kt, ActionDefinition as l, UpdateManyResult as ln, ResourceCacheConfig as lt, AnyRecord as m, OperationFilter as mn, ResourcePermissions as mt, AdapterSchemaContext as n, KeysetPaginatedResult as nn, QueryParserInterface as nt, RepositoryLike as o, PaginationResult as on, RequestContext as ot, AdditionalRoute as p, NextFunction as pn, ResourceMetadata as pt, MiddlewareHandler as q, ResourceDefinition as qt, DataAdapter as r, OffsetPaginatedResult as rn, RateLimitConfig as rt, SchemaMetadata as s, QueryOptions as sn, RequestIdOptions as st, AdapterFactory as t, InferDoc as tn, PresetResult as tt, ActionEntry as u, WriteOptions as un, ResourceConfig as ut, ArcRequest as v, Transform as vn, RouteSchemaOptions as vt, ControllerQueryOptions as w, HookRegistration as wn, UserLike as wt, Authenticator as x, HookHandler as xn, TypedController as xt, AuthHelpers as y, DefineHookOptions as yn, ServiceContext as yt, InferDocType as z, ControllerLike as zt };
2501
+ export { CrudSchemas as $, HookPhase as $t, AuthHelpers as A, SchemaMetadata as At, FastifyWithDecorators as B, ArcInternalMetadata as Bt, ResourceMetadata as C, RouteHandler as Ct, HealthOptions as D, FieldMetadata as Dt, HealthCheck as E, DataAdapter as Et, TokenPair as F, OperationFilter as Ft, ResourceDefinition as G, PopulateOption as Gt, RequestWithExtras as H, LookupOption as Ht, ArcDecorator as I, PipelineConfig as It, ActionEntry as J, ServiceContext as Jt, defineResource as K, QueryParserInterface as Kt, EventsDecorator as L, PipelineContext as Lt, Authenticator as M, Guard as Mt, AuthenticatorContext as N, Interceptor as Nt, IntrospectionPluginOptions as O, RelationMetadata as Ot, JwtContext as P, NextFunction as Pt, CrudRouteKey as Q, HookOperation as Qt, FastifyRequestExtras as R, PipelineStep as Rt, RegistryStats as S, IRequestContext as St, GracefulShutdownOptions as T, AdapterSchemaContext as Tt, RegisterOptions as U, OwnershipCheck as Ut, MiddlewareHandler as V, ControllerQueryOptions as Vt, ResourceRegistry as W, ParsedQuery as Wt, ActionsMap as X, HookContext as Xt, ActionHandlerFn as Y, DefineHookOptions as Yt, CrudController as Z, HookHandler as Zt, ConfigError as _, UserOrganization as _n, ControllerHandler as _t, QueryResolverConfig as a, afterUpdate as an, PresetHook as at, IntrospectionData as b, IController as bt, AccessControl as c, beforeUpdate as cn, ResourceCacheConfig as ct, InferAdapterDoc as d, AnyRecord as dn, ResourceHooks as dt, HookRegistration as en, EventDefinition as et, InferDocType as f, ApiResponse as fn, ResourcePermissions as ft, TypedResourceConfig as g, UserLike as gn, RouteSchemaOptions as gt, TypedRepository as h, ObjectId as hn, RouteMethod as ht, QueryResolver as i, afterDelete as in, PresetFunction as it, AuthPluginOptions as j, ValidationResult$1 as jt, RequestIdOptions as k, RepositoryLike as kt, AccessControlConfig as l, createHookSystem as ln, ResourceConfig as lt, TypedController as m, JWTPayload as mn, RouteMcpConfig as mt, BaseController as n, HookSystemOptions as nn, MiddlewareConfig as nt, BodySanitizer as o, beforeCreate as on, PresetResult as ot, InferResourceDoc as p, ArcRequest as pn, RouteDefinition as pt, ActionDefinition as q, RequestContext as qt, BaseControllerOptions as r, afterCreate as rn, OpenApiSchemas as rt, BodySanitizerConfig as s, beforeDelete as sn, RateLimitConfig as st, RouteHandlerMethod$1 as t, HookSystem as tn, FieldRule as tt, PaginationResult as u, defineHook as un, ResourceHookContext as ut, ValidateOptions as v, envelope as vn, ControllerLike as vt, CrudRouterOptions as w, AdapterFactory as wt, RegistryEntry as x, IControllerResponse as xt, ValidationResult as y, getUserId as yn, FastifyHandler as yt, FastifyWithAuth as z, Transform as zt };