@classytic/arc 2.11.3 → 2.13.1

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 (185) hide show
  1. package/README.md +27 -18
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  7. package/dist/audit/index.d.mts +2 -2
  8. package/dist/audit/index.mjs +1 -1
  9. package/dist/auth/audit.d.mts +199 -0
  10. package/dist/auth/audit.mjs +288 -0
  11. package/dist/auth/index.d.mts +5 -5
  12. package/dist/auth/index.mjs +117 -191
  13. package/dist/auth/redis-session.d.mts +1 -1
  14. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  15. package/dist/buildHandler-olo-gt94.mjs +610 -0
  16. package/dist/cache/index.d.mts +3 -3
  17. package/dist/cache/index.mjs +3 -3
  18. package/dist/cli/commands/describe.d.mts +89 -13
  19. package/dist/cli/commands/describe.mjs +56 -2
  20. package/dist/cli/commands/docs.mjs +2 -2
  21. package/dist/cli/commands/generate.mjs +147 -48
  22. package/dist/cli/commands/init.d.mts +13 -0
  23. package/dist/cli/commands/init.mjs +237 -112
  24. package/dist/cli/commands/introspect.mjs +8 -1
  25. package/dist/context/index.mjs +1 -1
  26. package/dist/core/index.d.mts +3 -3
  27. package/dist/core/index.mjs +5 -5
  28. package/dist/core-D72ia0EH.mjs +1399 -0
  29. package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  30. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  31. package/dist/{createApp-BFxtdKy6.mjs → createApp-XX2-N0Yd.mjs} +31 -27
  32. package/dist/defineEvent-D5h7EvAx.mjs +188 -0
  33. package/dist/docs/index.d.mts +2 -2
  34. package/dist/docs/index.mjs +2 -2
  35. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  36. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  37. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  38. package/dist/errors-j4aJm1Wg.mjs +184 -0
  39. package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-CaKTYkYM.mjs} +35 -137
  40. package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-qXpqTebY.d.mts} +57 -7
  41. package/dist/events/index.d.mts +164 -5
  42. package/dist/events/index.mjs +133 -209
  43. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  44. package/dist/events/transports/redis-stream-entry.mjs +204 -31
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +2 -2
  47. package/dist/factory/index.mjs +2 -2
  48. package/dist/{fields-C8Y0XLAu.d.mts → fields-COhcH3fk.d.mts} +23 -2
  49. package/dist/hooks/index.d.mts +1 -1
  50. package/dist/hooks/index.mjs +1 -1
  51. package/dist/idempotency/index.d.mts +3 -3
  52. package/dist/idempotency/index.mjs +1 -20
  53. package/dist/idempotency/redis.d.mts +1 -1
  54. package/dist/idempotency/redis.mjs +1 -1
  55. package/dist/{index-BYCqHCVu.d.mts → index-BTqLEvhu.d.mts} +164 -4
  56. package/dist/{index-6u4_Gg6G.d.mts → index-BtW7qYwa.d.mts} +661 -281
  57. package/dist/{index-BdXnTPRj.d.mts → index-Ds61mrJE.d.mts} +50 -4
  58. package/dist/{index-DdQ3O9Pg.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  59. package/dist/index.d.mts +6 -7
  60. package/dist/index.mjs +9 -10
  61. package/dist/integrations/event-gateway.d.mts +2 -2
  62. package/dist/integrations/event-gateway.mjs +1 -1
  63. package/dist/integrations/index.d.mts +2 -2
  64. package/dist/integrations/mcp/index.d.mts +2 -2
  65. package/dist/integrations/mcp/index.mjs +1 -1
  66. package/dist/integrations/mcp/testing.d.mts +1 -1
  67. package/dist/integrations/mcp/testing.mjs +1 -1
  68. package/dist/integrations/streamline.d.mts +60 -11
  69. package/dist/integrations/streamline.mjs +75 -85
  70. package/dist/integrations/websocket-redis.d.mts +1 -1
  71. package/dist/integrations/websocket.d.mts +1 -1
  72. package/dist/integrations/websocket.mjs +2 -8
  73. package/dist/middleware/index.d.mts +1 -1
  74. package/dist/middleware/index.mjs +2 -2
  75. package/dist/migrations/index.d.mts +23 -3
  76. package/dist/migrations/index.mjs +0 -7
  77. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  78. package/dist/{openapi-BGUn7Ki1.mjs → openapi-CiOMVW1p.mjs} +143 -13
  79. package/dist/org/index.d.mts +2 -2
  80. package/dist/org/index.mjs +1 -1
  81. package/dist/permissions/index.d.mts +3 -3
  82. package/dist/permissions/index.mjs +3 -3
  83. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  84. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  85. package/dist/pipeline/index.d.mts +1 -1
  86. package/dist/pipeline/index.mjs +1 -1
  87. package/dist/plugins/index.d.mts +18 -33
  88. package/dist/plugins/index.mjs +33 -13
  89. package/dist/plugins/response-cache.mjs +1 -1
  90. package/dist/plugins/tracing-entry.d.mts +1 -1
  91. package/dist/plugins/tracing-entry.mjs +1 -1
  92. package/dist/presets/filesUpload.d.mts +5 -5
  93. package/dist/presets/filesUpload.mjs +6 -9
  94. package/dist/presets/index.d.mts +1 -1
  95. package/dist/presets/index.mjs +1 -1
  96. package/dist/presets/multiTenant.d.mts +1 -1
  97. package/dist/presets/multiTenant.mjs +2 -2
  98. package/dist/presets/search.d.mts +2 -2
  99. package/dist/presets/search.mjs +6 -8
  100. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  101. package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
  102. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  103. package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
  104. package/dist/redis-stream-D6HzR1Z_.d.mts +232 -0
  105. package/dist/registry/index.d.mts +1 -1
  106. package/dist/registry/index.mjs +2 -2
  107. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  108. package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  109. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  110. package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  111. package/dist/schemas/index.d.mts +100 -30
  112. package/dist/schemas/index.mjs +86 -29
  113. package/dist/scim/index.d.mts +264 -0
  114. package/dist/scim/index.mjs +963 -0
  115. package/dist/scope/index.d.mts +3 -3
  116. package/dist/scope/index.mjs +4 -4
  117. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  118. package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  119. package/dist/testing/index.d.mts +2 -8
  120. package/dist/testing/index.mjs +16 -24
  121. package/dist/testing/storageContract.d.mts +1 -1
  122. package/dist/types/index.d.mts +4 -4
  123. package/dist/types/storage.d.mts +1 -1
  124. package/dist/{types-BH7dEGvU.d.mts → types-BvqwCCSx.d.mts} +77 -29
  125. package/dist/{types-tgR4Pt8F.d.mts → types-CTYvcwHe.d.mts} +195 -1
  126. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  127. package/dist/{types-9beEMe25.d.mts → types-DQHFc8PM.d.mts} +1 -1
  128. package/dist/utils/index.d.mts +2 -2
  129. package/dist/utils/index.mjs +5 -5
  130. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  131. package/dist/{versioning-M9lNLhO8.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  132. package/package.json +24 -34
  133. package/skills/arc/SKILL.md +521 -785
  134. package/skills/arc/references/agent-auth.md +238 -0
  135. package/skills/arc/references/api-reference.md +187 -0
  136. package/skills/arc/references/auth.md +354 -7
  137. package/skills/arc/references/enterprise-auth.md +94 -0
  138. package/skills/arc/references/events.md +8 -6
  139. package/skills/arc/references/mcp.md +2 -2
  140. package/skills/arc/references/multi-tenancy.md +11 -2
  141. package/skills/arc/references/production.md +10 -9
  142. package/skills/arc/references/scim.md +247 -0
  143. package/skills/arc/references/testing.md +1 -1
  144. package/skills/arc-code-review/SKILL.md +141 -0
  145. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  146. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  147. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  148. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  149. package/skills/arc-code-review/references/scaffolding.md +230 -0
  150. package/skills/arc-code-review/references/severity.md +127 -0
  151. package/dist/EventTransport-CfVEGaEl.d.mts +0 -293
  152. package/dist/adapters/index.d.mts +0 -3
  153. package/dist/adapters/index.mjs +0 -2
  154. package/dist/adapters-D0tT2Tyo.mjs +0 -949
  155. package/dist/auth/mongoose.d.mts +0 -191
  156. package/dist/auth/mongoose.mjs +0 -73
  157. package/dist/core-DnUsRpuX.mjs +0 -1049
  158. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  159. package/dist/errorHandler-Co3lnVmJ.d.mts +0 -114
  160. package/dist/errors-D5c-5BJL.mjs +0 -232
  161. package/dist/index-BbMrcvGp.d.mts +0 -362
  162. package/dist/redis-stream-CM8TXTix.d.mts +0 -110
  163. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  164. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  165. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  166. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  167. /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  168. /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
  169. /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
  170. /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
  171. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  172. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  173. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  174. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  175. /package/dist/{pluralize-BneOJkpi.mjs → pluralize-DQgqgifU.mjs} +0 -0
  176. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  177. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  178. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  179. /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
  180. /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
  181. /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
  182. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  183. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  184. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
  185. /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
@@ -8,11 +8,9 @@ description: |
8
8
  Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
9
9
  arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
10
10
  arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
11
- version: 2.11.1
12
11
  license: MIT
13
12
  metadata:
14
13
  author: Classytic
15
- version: "2.11.1"
16
14
  tags:
17
15
  - fastify
18
16
  - rest-api
@@ -27,48 +25,58 @@ tags:
27
25
  - openapi
28
26
  progressive_disclosure:
29
27
  entry_point:
30
- summary: "Resource-oriented Fastify framework: defineResource(), presets, permissions, QueryCache, events, multi-tenant, OpenAPI"
28
+ summary: "Resource-oriented Fastify framework: defineResource(), presets, permissions, QueryCache, events, multi-tenant, OpenAPI, MCP"
31
29
  when_to_use: "Building REST APIs with Fastify, resource CRUD, authentication, presets, caching, events, or production deployment"
32
- quick_start: "1. npm install @classytic/arc fastify 2. createApp({ preset: 'production', auth: { type: 'jwt', jwt: { secret } } }) 3. defineResource({ name, adapter, presets, permissions })"
33
- context_limit: 700
30
+ quick_start: "1. arc init my-api --mongokit --jwt --ts 2. defineResource({ name, adapter, presets, permissions }) 3. createApp({ preset: 'production', resources, auth })"
34
31
  ---
35
32
 
36
33
  # @classytic/arc
37
34
 
38
- Resource-oriented backend framework for Fastify. Database-agnostic, tree-shakable, production-ready.
35
+ Resource-oriented backend framework for Fastify. **Fastify ≥5.8.5 · Node ≥22 · ESM only.**
39
36
 
40
- **Requires:** Fastify `^5.7.4` | Node.js `>=22` | ESM only
37
+ One `defineResource()` call → REST + auth + permissions + events + cache + OpenAPI + MCP. Database-agnostic (Mongoose, Drizzle/sqlitekit, Prisma, custom).
41
38
 
42
- ## Install
39
+ ## Scaffold a project
43
40
 
44
41
  ```bash
45
- # Install the Arc agent skill (Claude Code / AI agents)
46
- npx skills add classytic/arc
47
-
48
- # Install the npm package
49
- npm install @classytic/arc fastify
50
- npm install @classytic/mongokit mongoose # MongoDB adapter
42
+ npx @classytic/arc@latest init my-api --mongokit --jwt --ts
43
+ cd my-api && npm install && npm run dev
51
44
  ```
52
45
 
53
- ## Quick Start
46
+ Flags: `--mongokit | --custom`, `--jwt | --better-auth`, `--single | --multi`, `--ts | --js`. The scaffold seeds full `dependencies` + `devDependencies` so `npm install` works without the CLI's pre-pass.
47
+
48
+ ## createApp()
54
49
 
55
50
  ```typescript
56
51
  import { createApp } from '@classytic/arc/factory';
57
- import mongoose from 'mongoose';
58
-
59
- await mongoose.connect(process.env.DB_URI);
60
52
 
61
53
  const app = await createApp({
62
- preset: 'production',
54
+ preset: 'production', // production | development | testing | edge
55
+ runtime: 'memory', // memory (default) | distributed
63
56
  auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
64
- cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') },
65
- resources: [productResource, orderResource], // canonical path — factory registers in the right lifecycle slot
57
+ cors: { origin: ['https://myapp.com'] },
58
+ helmet: true, // false to disable
59
+ rateLimit: { max: 100 }, // false to disable
60
+ ajv: { keywords: ['x-internal'] },
61
+ resources: [productResource, orderResource], // canonical: factory wires them in the right slot
62
+ arcPlugins: {
63
+ events: true, // default true — disables CRUD event emission if false
64
+ queryCache: true, // default false
65
+ sse: true, // default false
66
+ caching: true, // ETag + Cache-Control
67
+ },
68
+ stores: { // required when runtime: 'distributed'
69
+ events: new RedisEventTransport({ client: redis }),
70
+ queryCache: new RedisCacheStore({ client: redis }),
71
+ },
66
72
  });
67
73
 
68
74
  await app.listen({ port: 8040, host: '0.0.0.0' });
69
75
  ```
70
76
 
71
- For async-booted engines (repository wired in `bootstrap[]`), use the factory form:
77
+ **Boot sequence:** `plugins` `bootstrap[]` `resources` (factory form runs here) → `afterResources` → `onReady`.
78
+
79
+ **Async-booted engines** — use the factory form for `resources` so it runs after `bootstrap[]`:
72
80
 
73
81
  ```typescript
74
82
  resources: async (fastify) => {
@@ -77,32 +85,35 @@ resources: async (fastify) => {
77
85
  }
78
86
  ```
79
87
 
80
- Advanced escape hatch: `await app.register(productResource.toPlugin())` registers a single resource directly. Use only when you need manual control over the scope/prefix — the `resources` factory option is preferred.
88
+ **Auto-discover resources:**
81
89
 
82
- ## Security defaults (active in 2.11)
90
+ ```typescript
91
+ import { loadResources } from '@classytic/arc/factory';
83
92
 
84
- - **Field-write perms: `reject`** requests carrying non-writable fields get 403 with denied-field list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
85
- - **multiTenant injects org on UPDATE** body-supplied `organizationId` overwritten with caller's scope. Closes tenant-hop vector.
86
- - **Elevation emits `arc.scope.elevated`** — audit via `fastify.events.subscribe(...)`.
87
- - **`verifySignature(body, ...)`** throws on parsed body — pass `req.rawBody`.
88
- - **Upload `sanitizeFilename`** strict by default. Pass `false` / `'*'` / custom fn to relax.
89
- - **Idempotency `namespace`** option for shared-store prod+canary deployments.
90
- - **`systemManaged` fields auto-strip from `required[]`** — framework-injected fields (tenant, audit) removed from create/update `required[]` so Fastify preValidation doesn't reject before arc's injection runs.
93
+ resources: await loadResources(import.meta.url), // discovers *.resource.ts
94
+ resources: await loadResources(import.meta.url, { context: { engine } }), // threads ctx into (ctx) => defineResource(...)
95
+ ```
91
96
 
92
- For removed APIs and version-by-version breaking changes, see [CHANGELOG.md](../../CHANGELOG.md). For the full tenant-pipeline walkthrough, see [docs/production-ops/tenant-pipeline.mdx](../../docs/production-ops/tenant-pipeline.mdx).
97
+ Pass `import.meta.url` for dev/prod parity (resolves `src/` in dev, `dist/` in prod). Discovers `default` export, `export const resource`, OR any named export with `.toPlugin()`. Works with relative imports + Node `#` subpath imports — **NOT** tsconfig path aliases (`@/*` are compile-time only).
93
98
 
94
99
  ## defineResource()
95
100
 
96
- Single API to define a full REST resource:
97
-
98
101
  ```typescript
99
- import { defineResource, createMongooseAdapter, allowPublic, requireRoles } from '@classytic/arc';
102
+ import { defineResource, allowPublic, requireRoles } from '@classytic/arc';
103
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
104
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
100
105
 
101
106
  const productResource = defineResource({
102
107
  name: 'product',
103
- adapter: createMongooseAdapter({ model: ProductModel, repository: productRepo }),
104
- controller: productController, // optional — auto-created if omitted
108
+ adapter: createMongooseAdapter({
109
+ model: ProductModel,
110
+ repository: productRepo,
111
+ schemaGenerator: buildCrudSchemasFromModel, // required (no built-in fallback)
112
+ }),
113
+ controller: productController, // optional — auto-built if omitted
114
+
105
115
  presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'orgId' }],
116
+
106
117
  permissions: {
107
118
  list: allowPublic(),
108
119
  get: allowPublic(),
@@ -110,31 +121,27 @@ const productResource = defineResource({
110
121
  update: requireRoles(['admin']),
111
122
  delete: requireRoles(['admin']),
112
123
  },
124
+
113
125
  cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
114
126
 
115
- // routeGuards auto-apply to ALL routes (CRUD + custom + preset)
116
- routeGuards: [modeGuard, orgGuard.preHandler],
127
+ routeGuards: [orgGuard.preHandler], // applied to ALL routes (CRUD + custom + preset)
117
128
 
118
- // fieldRules — portable constraints + framework-injection hints
119
129
  schemaOptions: {
120
130
  fieldRules: {
121
- name: { minLength: 2, maxLength: 200, description: 'Product name' },
122
- price: { min: 0, max: 100000 },
131
+ name: { minLength: 2, maxLength: 200 },
123
132
  sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
124
133
  status: { enum: ['draft', 'active', 'archived'] },
125
- deletedAt: { systemManaged: true }, // arc stamps it strip from body + required[]
126
- priceMode: { nullable: true }, // Zod .nullable() lost through Mongoose — widen to accept null
134
+ deletedAt: { systemManaged: true }, // arc stamps it; strip from body + required[]
135
+ priceMode: { nullable: true }, // widen JSON-Schema type to accept null
127
136
  },
128
137
  },
129
138
 
130
- // Custom routes (compose with presets — softDelete adds /deleted, /:id/restore)
131
139
  routes: [
132
- { method: 'GET', path: '/stats', handler: 'getStats', permissions: requireAuth() },
140
+ { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
133
141
  { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: requireAuth() },
134
142
  ],
135
143
 
136
- // Actions — single POST /:id/action endpoint, discriminated on `action` body field
137
- actions: {
144
+ actions: { // single POST /:id/action endpoint, discriminated on `action`
138
145
  approve: async (id, data, req) => service.approve(id, req.user._id),
139
146
  cancel: {
140
147
  handler: async (id, data, req) => service.cancel(id, data.reason, req.user._id),
@@ -142,309 +149,145 @@ const productResource = defineResource({
142
149
  schema: { reason: { type: 'string' } },
143
150
  },
144
151
  },
145
- actionPermissions: requireAuth(),
146
- });
147
-
148
- // Register via createApp — canonical path:
149
- // createApp({ resources: [productResource] })
150
- // Auto-generates: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id
151
- // + softDelete preset adds: GET /deleted, POST /:id/restore
152
- ```
153
-
154
- ## routeGuards + defineGuard
155
-
156
- Resource-level guards that apply to **every** route (CRUD + custom + preset):
157
-
158
- ```typescript
159
- import { defineGuard } from '@classytic/arc/utils';
160
- import type { RouteHandlerMethod } from '@classytic/arc';
161
-
162
- // Simple guard — reject if condition fails
163
- const modeGuard: RouteHandlerMethod = async (req, reply) => {
164
- if (!req.headers['x-mode']) {
165
- reply.code(403).send({ error: 'Mode header required' });
166
- }
167
- };
168
-
169
- // Typed guard — resolve context once, extract anywhere
170
- const orgGuard = defineGuard({
171
- name: 'org',
172
- resolve: (req) => {
173
- const orgId = req.headers['x-org-id'] as string;
174
- if (!orgId) throw new Error('Missing x-org-id');
175
- return { orgId, actorId: req.user?.id ?? 'system' };
176
- },
177
- });
178
-
179
- defineResource({
180
- name: 'procurement',
181
- routeGuards: [modeGuard, orgGuard.preHandler], // all routes protected
182
- routes: [{
183
- method: 'GET', path: '/summary', raw: true, permissions: requireAuth(),
184
- handler: async (req, reply) => {
185
- const { orgId } = orgGuard.from(req); // typed, no re-computation
186
- reply.send({ orgId, count: await Model.countDocuments() });
187
- },
188
- }],
189
- // ...
152
+ actionPermissions: requireAuth(), // fallback gate for actions without per-action perm
190
153
  });
191
154
  ```
192
155
 
193
- **Execution order:** auth permissions cache/idempotency `routeGuards` per-route `preHandler`
194
-
195
- ## fieldRules → OpenAPI + AJV
196
-
197
- One definition, two outputs — constraints auto-map to OpenAPI schema + Fastify AJV validation. Extends repo-core's `FieldRule` floor with arc extensions.
198
-
199
- ```typescript
200
- schemaOptions: {
201
- fieldRules: {
202
- name: { minLength: 2, maxLength: 200, description: 'Product name' },
203
- price: { min: 0, max: 100000 },
204
- sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
205
- status: { enum: ['draft', 'active', 'archived'] },
206
- password: { hidden: true }, // blocked from select + OpenAPI
207
- deletedAt: { systemManaged: true }, // blocked from input schemas; framework stamps it
208
- slug: { immutable: true }, // excluded from update body
209
- priceMode: { nullable: true }, // widen JSON-Schema type to include null
210
- organizationId: { systemManaged: true, preserveForElevated: true }, // tenant field (auto-injected)
211
- },
212
- },
213
- ```
214
-
215
- | Flag | Effect |
216
- |---|---|
217
- | `systemManaged` | Strip from body on ingest, drop from `required[]`. Framework stamps the value (tenant, audit, engine-derived slug). |
218
- | `preserveForElevated` | Elevated admins keep the field on ingest (platform-level cross-tenant writes). |
219
- | `immutable` / `immutableAfterCreate` | Omit from update body. Inheritance: repo-core floor. |
220
- | `optional` | Strip from `required[]` without touching `properties`. |
221
- | `nullable` | Widen JSON-Schema `type` to include null (+ appends `null` to `enum` if present). |
222
- | `hidden` | Block from response projection + OpenAPI. |
223
- | `minLength` / `maxLength` / `min` / `max` / `pattern` / `enum` | Map to AJV validators + OpenAPI constraints. |
224
- | `description` | Maps to OpenAPI `description`. |
156
+ **Generated routes:** `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id`. Presets add `/deleted` + `/:id/restore` (softDelete), `/slug/:slug` (slugLookup), etc.
225
157
 
226
- Mongoose model-level constraints (`minlength`, `maxlength`, `min`, `max`, `enum`) take precedence. `fieldRules` supplements what the model doesn't declare. Kit schema generators see only the repo-core floor; arc's extensions apply post-kit via `mergeFieldRuleConstraints`.
158
+ ## Active behavior to know about
227
159
 
228
- See [docs/framework-extension/custom-adapters.mdx Field Rules](../../docs/framework-extension/custom-adapters.mdx#field-rules--shaping-kit-generated-schemas) for the `systemManaged` decision table.
160
+ - **Field-write reject (default).** Requests carrying non-writable fields 403 with denied list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
161
+ - **multiTenant injects org on UPDATE.** Body `organizationId` is overwritten with caller's scope (closes tenant-hop).
162
+ - **MCP tools fail-closed.** A resource action without per-action perm + no `actionPermissions` + no `permissions.update` fallback → throws at tool generation. Declare `allowPublic()` to opt into unauthenticated.
163
+ - **`systemManaged` fields** auto-strip from `required[]` so AJV doesn't reject before arc's framework injection runs.
164
+ - **`request.user`** is `Record<string, unknown> | undefined` — guard with `if (req.user)` on public routes.
165
+ - **Arc's permission engine reads singular `user.role`** (string, comma-separated, or array). Don't use plural `roles` on the model.
166
+ - **`verifySignature(body, ...)`** throws on parsed body — pass `req.rawBody`.
167
+ - **Upload `sanitizeFilename`** strict by default; pass `false` / `'*'` / fn to relax.
229
168
 
230
169
  ## Authentication
231
170
 
232
- Auth uses a **discriminated union** with `type` field:
171
+ Discriminated union on `type`:
233
172
 
234
173
  ```typescript
235
- // Arc JWT (with optional token extraction and revocation)
174
+ // Arc JWT (with optional revocation + custom token extractor)
236
175
  auth: {
237
176
  type: 'jwt',
238
177
  jwt: { secret, expiresIn: '15m', refreshSecret, refreshExpiresIn: '7d' },
239
- tokenExtractor: (req) => req.cookies?.['auth-token'] ?? null, // optional
240
- isRevoked: async (decoded) => redis.sismember('revoked', decoded.jti), // optional
178
+ tokenExtractor: (req) => req.cookies?.['auth-token'] ?? null,
179
+ isRevoked: async (decoded) => redis.sismember('revoked', decoded.jti),
241
180
  }
242
181
 
243
182
  // Better Auth (recommended for SaaS with orgs)
244
183
  import { createBetterAuthAdapter } from '@classytic/arc/auth';
245
184
  auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth, orgContext: true }) }
246
185
 
247
- // Custom Fastify plugin (must decorate fastify.authenticate)
186
+ // Custom Fastify plugin / function
248
187
  auth: { type: 'custom', plugin: myAuthPlugin }
249
-
250
- // Custom function (decorates fastify.authenticate directly)
251
188
  auth: { type: 'authenticator', authenticate: async (req, reply) => { ... } }
252
189
 
253
190
  // Disabled
254
191
  auth: false
255
192
  ```
256
193
 
257
- **Decorates:** `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`
258
-
259
- ### Better Auth + Mongoose populate bridge (`@classytic/arc/auth/mongoose`)
194
+ Decorates `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`.
260
195
 
261
- When BA uses `@better-auth/mongo-adapter`, it writes via the native `mongodb` driver and never registers Mongoose models. arc resources doing `Schema({ userId: { ref: 'user' } })` then throw `MissingSchemaError` on `.populate()`.
196
+ **Better Auth arc is plugin-agnostic.** `auth.$context.tables` introspection lets the kit overlays read whatever plugins you've enabled no per-plugin code in arc/mongokit/sqlitekit. Tested combinations: `organization`, `twoFactor`, `admin`, `bearer` (built-in), plus `apiKey` from the **separate** `@better-auth/api-key` package.
262
197
 
263
198
  ```typescript
264
- import mongoose from 'mongoose';
265
- import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
266
-
267
- // Default: core only (user/session/account/verification). Plugins are opt-in.
268
- registerBetterAuthMongooseModels(mongoose, {
269
- plugins: ['organization', 'organization-teams', 'mcp'],
270
- // For separate @better-auth/* packages (passkey, sso, api-key):
271
- extraCollections: ['passkey', 'ssoProvider'],
272
- // Optional:
273
- usePlural: false, // matches mongodbAdapter({ usePlural })
274
- modelOverrides: { user: 'profile' }, // for custom user.modelName configs
199
+ import { betterAuth } from 'better-auth';
200
+ import { mongodbAdapter } from '@better-auth/mongo-adapter';
201
+ import { organization, twoFactor, admin, bearer } from 'better-auth/plugins';
202
+ import { apiKey } from '@better-auth/api-key'; // separate npm package
203
+ import { createBetterAuthOverlay, registerBetterAuthStubs } from '@classytic/mongokit/better-auth';
204
+
205
+ const auth = betterAuth({
206
+ database: mongodbAdapter(mongoose.connection.getClient().db()),
207
+ emailAndPassword: { enabled: true },
208
+ plugins: [organization(), twoFactor(), admin(), bearer(), apiKey({ enableSessionForAPIKeys: true })],
275
209
  });
210
+
211
+ // Bulk-register populate() stubs. extraCollections covers tables not in the plugin map (apikey, passkey, …).
212
+ registerBetterAuthStubs(mongoose, { plugins: ['organization'], extraCollections: ['apikey'] });
213
+
214
+ // Per-resource overlay — DataAdapter ready for defineResource. Async (reads BA's resolved schema once at boot).
215
+ const orgAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'organization' });
216
+ const apiKeyAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'apikey' });
217
+
218
+ // Sqlitekit is symmetric: { auth, db, collection } + additionalColumns instead of additionalFields.
276
219
  ```
277
220
 
278
- **Plugin keys** (core BA only separate packages use `extraCollections`):
279
- - `organization` → `organization`, `member`, `invitation`
280
- - `organization-teams` `team`, `teamMember`
281
- - `twoFactor` → `twoFactor`
282
- - `jwt` → `jwks`
283
- - `oidcProvider` / `oauthProvider` (alias) → `oauthApplication`, `oauthAccessToken`, `oauthConsent`
284
- - `mcp` → reuses oidcProvider schema (per BA docs)
285
- - `deviceAuthorization` → `deviceCode`
221
+ **Multi-role members**: BA stores `member.role = "admin,recruiter,viewer"` (comma-separated string). Arc splits it into `scope.orgRoles = ['admin', 'recruiter', 'viewer']`; `requireOrgRole('admin')` matches. Filtering by exact `?role=admin` will NOT match — use `role[like]=admin`.
222
+
223
+ **API key flow**: client sends `x-api-key: ak_live_...` + `x-organization-id: org_abc` (header required because API-key sessions have no `activeOrganizationId`). Arc adds `apiKeyAuth` to OpenAPI security only when the plugin is active.
286
224
 
287
- **Field-only plugins** (admin, username, phoneNumber, magicLink, emailOtp, anonymous, bearer, multiSession, siwe, lastLoginMethod, genericOAuth) need NO entry`strict: false` stubs round-trip extra fields automatically.
225
+ **Bearer plugin** (`bearer()` from `better-auth/plugins`): SPA / mobile clients use `Authorization: Bearer <session>` instead of cookiessame `auth.api.getSession()` path, no arc config change. Enable both for hybrid apps.
288
226
 
289
- Lives at a dedicated subpath so non-Mongoose users (Prisma/Drizzle/Kysely) never get Mongoose pulled into their bundle. Idempotent + de-dupes overlapping plugin sets, so `plugins: ['mcp', 'oidcProvider']` won't crash.
227
+ Full overlay recipes (Tier 1 hand-roll vs Tier 2 factory), plugin matrix, `registerBetterAuthStubs` options, multi-plugin merge, write path, and CLI scaffolding flags [references/auth.md](references/auth.md). Live end-to-end smoke: [`playground/better-auth/mongo/`](../../playground/better-auth/mongo/) · [`playground/better-auth/sqlite/`](../../playground/better-auth/sqlite/).
290
228
 
291
229
  ## Permissions
292
230
 
293
- Function-based. A `PermissionCheck` returns `boolean | { granted, reason?, filters?, scope? }`:
231
+ A `PermissionCheck` returns `boolean | { granted, reason?, filters?, scope? }`. `filters` propagate into the repo query (row-level ABAC). `scope` stamps attributes downstream.
294
232
 
295
233
  ```typescript
296
234
  import {
297
- // Core
298
235
  allowPublic, requireAuth, requireRoles, requireOwnership,
299
- // Org-bound
300
236
  requireOrgMembership, requireOrgRole, requireTeamMembership,
301
- // Service / API key (OAuth-style)
302
- requireServiceScope,
303
- // App-defined scope dimensions (branch, project, region, …)
304
- requireScopeContext,
305
- // Parent-child org hierarchy
306
- requireOrgInScope,
307
- // Combinators
237
+ requireServiceScope, // OAuth-style API-key scopes
238
+ requireScopeContext, // app-defined dimensions (branch, project, region)
239
+ requireOrgInScope, // parent-child org hierarchy
308
240
  allOf, anyOf, when, denyAll,
309
- // Dynamic ACL
310
- createDynamicPermissionMatrix,
241
+ createDynamicPermissionMatrix, // DB-managed ACL
311
242
  } from '@classytic/arc';
312
243
 
313
244
  permissions: {
314
245
  list: allowPublic(),
315
- get: requireAuth(),
316
246
  create: requireRoles(['admin', 'editor']),
317
247
  update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
318
248
  delete: allOf(requireAuth(), requireRoles(['admin'])),
249
+
250
+ // Mixed human + machine
251
+ bulkImport: anyOf(requireOrgRole('admin'), requireServiceScope('jobs:bulk-write')),
319
252
  }
320
253
  ```
321
254
 
322
- **Mixed human + machine routes** — accept both an org admin and an API key:
255
+ **Custom check:**
323
256
 
324
257
  ```typescript
325
- import { requireServiceScope } from '@classytic/arc';
326
-
327
- permissions: {
328
- // Human admins OR API keys with the right OAuth scope
329
- create: anyOf(
330
- requireOrgRole('admin'),
331
- requireServiceScope('jobs:write'),
332
- ),
333
-
334
- // Org-bound API key with a specific scope (no human path)
335
- bulkImport: allOf(
336
- requireOrgMembership(), // accepts member, service, elevated
337
- requireServiceScope('jobs:bulk-write'), // OAuth-style scope check
338
- ),
339
- }
258
+ const requirePro = (): PermissionCheck => async (ctx) => {
259
+ if (!ctx.user) return { granted: false, reason: 'Auth required' };
260
+ return { granted: ctx.user.plan === 'pro' };
261
+ };
340
262
  ```
341
263
 
342
- **Multi-level tenancy** — for app-defined scope dimensions beyond org/team
343
- (branch, project, region, workspace, department, …):
264
+ **Field-level:**
344
265
 
345
266
  ```typescript
346
- import { requireScopeContext } from '@classytic/arc';
347
- import { multiTenantPreset } from '@classytic/arc/presets';
348
-
349
- // 1. Populate scope.context in your auth function (from headers, JWT claims,
350
- // BA session fields — arc takes no position on the source).
351
- authFn: async (request) => {
352
- const session = await myAuth.getSession(request);
353
- request.scope = {
354
- kind: 'member',
355
- userId: session.userId,
356
- userRoles: session.userRoles,
357
- organizationId: session.orgId,
358
- orgRoles: session.orgRoles,
359
- context: {
360
- branchId: request.headers['x-branch-id'],
361
- projectId: request.headers['x-project-id'],
362
- },
363
- };
364
- }
365
-
366
- // 2. Gate routes by context dimensions
367
- permissions: {
368
- branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
369
- euOnly: requireScopeContext('region', 'eu'),
370
- projectEdit: requireScopeContext({ projectId: undefined, region: 'eu' }),
267
+ import { fields } from '@classytic/arc';
268
+ fields: {
269
+ password: fields.hidden(),
270
+ salary: fields.visibleTo(['admin', 'hr']),
271
+ role: fields.writableBy(['admin']),
272
+ email: fields.redactFor(['viewer'], '***'),
371
273
  }
372
-
373
- // 3. Auto-filter resource queries across all dimensions in lockstep
374
- defineResource({
375
- name: 'job',
376
- presets: [
377
- multiTenantPreset({
378
- tenantFields: [
379
- { field: 'organizationId', type: 'org' },
380
- { field: 'branchId', contextKey: 'branchId' },
381
- { field: 'projectId', contextKey: 'projectId' },
382
- ],
383
- }),
384
- ],
385
- });
386
274
  ```
387
275
 
388
- Fail-closed: missing dimensions → 403 with the specific missing field name.
389
- Elevated scopes (platform admins) apply whatever resolves and skip the rest
390
- (cross-context bypass).
391
-
392
- **Parent-child org hierarchy** — for holding companies, MSPs managing
393
- multiple tenants, white-label parent → child accounts. Arc takes no position
394
- on the source: your auth function loads the chain from your own org table.
276
+ **Dynamic ACL (DB-backed):**
395
277
 
396
278
  ```typescript
397
- import { requireOrgInScope } from '@classytic/arc';
398
-
399
- // 1. Auth function loads ancestorOrgIds from your org table.
400
- // Order is closest-first (immediate parent → root).
401
- authFn: async (request) => {
402
- const session = await myAuth.getSession(request);
403
- const ancestors = await orgRepo.findAncestors(session.orgId);
404
- request.scope = {
405
- kind: 'member',
406
- userId: session.userId,
407
- userRoles: session.userRoles,
408
- organizationId: session.orgId,
409
- orgRoles: session.orgRoles,
410
- ancestorOrgIds: ancestors.map(a => a.id), // ['acme-eu', 'acme-holding']
411
- };
412
- }
413
-
414
- // 2. Gate routes — accepts current org or any ancestor in the chain
415
- permissions: {
416
- // GET /orgs/:orgId/jobs — caller can act on any org in their hierarchy
417
- list: requireOrgInScope((ctx) => ctx.request.params.orgId),
418
-
419
- // Static target (rare): one route, one specific org
420
- holdingDashboard: requireOrgInScope('acme-holding'),
421
-
422
- // Composed: must be admin AND target must be in hierarchy
423
- childAdmin: allOf(
424
- requireOrgRole('admin'),
425
- requireOrgInScope((ctx) => ctx.request.params.orgId),
426
- ),
427
- }
279
+ const acl = createDynamicPermissionMatrix({
280
+ resolveRolePermissions: async (ctx) => aclService.getRoleMatrix(ctx.user.orgId),
281
+ cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
282
+ });
283
+ permissions: { list: acl.canAction('product', 'read') }
428
284
  ```
429
285
 
430
- **No automatic inheritance** every check is explicit. `multiTenantPreset`
431
- does NOT auto-include ancestor data (would be a footgun). Sibling
432
- subsidiaries naturally don't see each other's data because they aren't in
433
- each other's chain. Elevated bypass still applies on the permission helper.
286
+ `requireRoles()` checks BOTH platform roles (`user.role`) and org roles (`scope.orgRoles`). `requireOrgRole()` is human-only — use `anyOf(requireOrgRole(...), requireServiceScope(...))` for mixed routes.
434
287
 
435
- **Auth source agnostic** `requireRoles()` checks platform roles
436
- (`user.role`) AND org roles (`scope.orgRoles`) by default, so it works
437
- identically with arc JWT, Better Auth user roles, and Better Auth org plugin.
438
- `requireOrgMembership()` accepts `member`, `service` (API key), and
439
- `elevated` scopes. `requireOrgRole()` is human-only by design — use
440
- `anyOf(requireOrgRole(...), requireServiceScope(...))` for mixed routes.
441
- `scope.context` and `scope.ancestorOrgIds` are populated by your own auth
442
- function or adapter — arc doesn't bake in any specific dimension or transport.
288
+ ### RequestScopethe auth context
443
289
 
444
- ### RequestScope (quick reference)
445
-
446
- Five kinds, all opt-in. Always read via accessors from `@classytic/arc/scope`,
447
- never via direct property access.
290
+ Five kinds, populated by your auth function. **Always read via accessors from `@classytic/arc/scope`, never direct property access.**
448
291
 
449
292
  ```typescript
450
293
  type RequestScope =
@@ -455,192 +298,171 @@ type RequestScope =
455
298
  | { kind: 'elevated'; userId?; organizationId?; elevatedBy; context?; ancestorOrgIds? };
456
299
  ```
457
300
 
458
- | Kind | Identity | Org context | Set by |
459
- |---|---|---|---|
460
- | `public` | none | none | Default for anonymous requests |
461
- | `authenticated` | userId, userRoles | none | Logged in, no active org |
462
- | `member` | userId, userRoles | organizationId + orgRoles (+ teamId, context, ancestorOrgIds) | BA org plugin / JWT custom auth |
463
- | `service` | clientId, scopes | organizationId (required) | API key via `PermissionResult.scope` |
464
- | `elevated` | userId | organizationId optional | Elevation plugin via `x-arc-scope: platform` header |
465
-
466
301
  | Helper | `member` | `service` | `elevated` |
467
302
  |---|---|---|---|
468
303
  | `requireOrgMembership()` | ✅ | ✅ | ✅ |
469
- | `requireOrgRole(roles)` | If role matches | ❌ deny w/ guidance | ✅ bypass |
470
- | `requireServiceScope(scopes)` | ❌ | If scope matches | ✅ bypass |
471
- | `requireScopeContext(...)` | If keys match | If keys match | ✅ bypass |
472
- | `requireTeamMembership()` | If `teamId` set | (n/a) | ✅ bypass |
473
- | `requireOrgInScope(target)` | If target in chain | If target in chain | ✅ bypass |
304
+ | `requireOrgRole(roles)` | role match | ❌ deny | ✅ bypass |
305
+ | `requireServiceScope(scopes)` | ❌ | scope match | ✅ bypass |
306
+ | `requireScopeContext(...)` | key match | key match | ✅ bypass |
307
+ | `requireTeamMembership()` | `teamId` set | n/a | ✅ bypass |
308
+ | `requireOrgInScope(target)` | target in chain | target in chain | ✅ bypass |
474
309
 
475
310
  ```typescript
476
311
  import {
477
312
  isMember, isService, isElevated, hasOrgAccess,
478
- getOrgId, getUserId, getOrgRoles, getServiceScopes,
313
+ getOrgId, getUserId, getUserRoles, getOrgRoles, getServiceScopes,
479
314
  getScopeContext, getAncestorOrgIds, isOrgInScope,
480
315
  } from '@classytic/arc/scope';
481
316
 
482
- if (hasOrgAccess(scope)) // member | service | elevated
483
- if (isService(scope)) // narrows to API key
484
- const orgId = getOrgId(scope); // member | service | elevated
485
- const branch = getScopeContext(scope, 'branchId'); // custom dimension
486
- isOrgInScope(scope, 'acme-holding'); // pure predicate (no elevated bypass)
317
+ if (hasOrgAccess(scope)) // member | service | elevated
318
+ const branch = getScopeContext(scope, 'branchId');
319
+ isOrgInScope(scope, 'acme-holding'); // pure predicate (no elevated bypass)
487
320
  ```
488
321
 
489
- **Custom permission:**
490
-
491
- ```typescript
492
- const requirePro = (): PermissionCheck => async (ctx) => {
493
- if (!ctx.user) return { granted: false, reason: 'Auth required' };
494
- return { granted: ctx.user.plan === 'pro' };
495
- };
496
- ```
322
+ ### Multi-level tenancy + parent-child org hierarchy
497
323
 
498
- **Field-level permissions:**
324
+ Populate `scope.context` and `scope.ancestorOrgIds` in your auth function (arc takes no position on the source — load from headers / JWT / BA session / your org table):
499
325
 
500
326
  ```typescript
501
- import { fields } from '@classytic/arc';
502
-
503
- fields: {
504
- password: fields.hidden(),
505
- salary: fields.visibleTo(['admin', 'hr']),
506
- role: fields.writableBy(['admin']),
507
- email: fields.redactFor(['viewer'], '***'),
327
+ authFn: async (request) => {
328
+ const session = await myAuth.getSession(request);
329
+ const ancestors = await orgRepo.findAncestors(session.orgId); // closest-first
330
+ request.scope = {
331
+ kind: 'member',
332
+ userId: session.userId,
333
+ userRoles: session.userRoles,
334
+ organizationId: session.orgId,
335
+ orgRoles: session.orgRoles,
336
+ context: { branchId: request.headers['x-branch-id'], projectId: request.headers['x-project-id'] },
337
+ ancestorOrgIds: ancestors.map(a => a.id), // ['acme-eu', 'acme-holding']
338
+ };
508
339
  }
509
- ```
510
340
 
511
- **Dynamic ACL (DB-managed):**
341
+ // Gate routes by context dimensions / org hierarchy
342
+ permissions: {
343
+ branchAdmin: allOf(requireOrgRole('admin'), requireScopeContext('branchId')),
344
+ euOnly: requireScopeContext('region', 'eu'),
345
+ list: requireOrgInScope((ctx) => ctx.request.params.orgId),
346
+ }
512
347
 
513
- ```typescript
514
- const acl = createDynamicPermissionMatrix({
515
- resolveRolePermissions: async (ctx) => aclService.getRoleMatrix(orgId),
516
- cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
348
+ // Auto-filter resource queries across all dimensions
349
+ defineResource({
350
+ name: 'job',
351
+ presets: [multiTenantPreset({
352
+ tenantFields: [
353
+ { field: 'organizationId', type: 'org' },
354
+ { field: 'branchId', contextKey: 'branchId' },
355
+ { field: 'projectId', contextKey: 'projectId' },
356
+ ],
357
+ })],
517
358
  });
518
- permissions: { list: acl.canAction('product', 'read') }
519
359
  ```
520
360
 
521
- ## Presets
522
-
523
- | Preset | Routes Added | Controller Interface | Config |
524
- |--------|-------------|---------------------|--------|
525
- | `softDelete` | GET /deleted, POST /:id/restore | `ISoftDeleteController` | `{ deletedField }` |
526
- | `slugLookup` | GET /slug/:slug | `ISlugLookupController` | `{ slugField }` |
527
- | `tree` | GET /tree, GET /:parent/children | `ITreeController` | `{ parentField }` |
528
- | `ownedByUser` | none (middleware) | — | `{ ownerField }` |
529
- | `multiTenant` | none (middleware) | — | `{ tenantField }` OR `{ tenantFields: TenantFieldSpec[] }` (2.7.1+) |
530
- | `audited` | none (middleware) | — | — |
531
- | `bulk` | POST/PATCH/DELETE /bulk | — | `{ operations?, maxCreateItems? }` |
532
- | `filesUpload` | POST /upload, GET /:id, DELETE /:id | — (uses `Storage` adapter) | `{ storage, sanitizeFilename?, allowedMimeTypes?, maxFileSize? }` |
533
- | `search` | POST /search, /search-similar, /embed (opt-in) | — | `{ repository?, search?, similar?, embed?, routes? }` |
361
+ Fail-closed: missing dimensions → 403 with the specific missing field name. **No automatic ancestor inheritance** — sibling subsidiaries don't see each other's data naturally.
534
362
 
535
- ```typescript
536
- // Single-field (default, backwards compatible)
537
- presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
363
+ Full multi-tenancy guide → [references/multi-tenancy.md](references/multi-tenancy.md).
538
364
 
539
- // Multi-fieldorg + branch + project in lockstep (2.7.1+)
540
- presets: [
541
- multiTenantPreset({
542
- tenantFields: [
543
- { field: 'organizationId', type: 'org' }, // → getOrgId(scope)
544
- { field: 'teamId', type: 'team' }, // → getTeamId(scope)
545
- { field: 'branchId', contextKey: 'branchId' }, // → scope.context.branchId
546
- { field: 'projectId', contextKey: 'projectId' },
547
- ],
548
- }),
549
- ]
365
+ ## fieldRulesOpenAPI + AJV in one place
550
366
 
551
- // Bulk: presets: ['bulk'] or bulkPreset({ operations: ['createMany', 'updateMany'] })
552
- ```
367
+ | Flag | Effect |
368
+ |---|---|
369
+ | `systemManaged` | Strip from body, drop from `required[]`. Framework stamps the value. |
370
+ | `preserveForElevated` | Elevated admins keep the field on ingest (cross-tenant writes). |
371
+ | `immutable` / `immutableAfterCreate` | Omit from update body. |
372
+ | `optional` | Strip from `required[]` without touching `properties`. |
373
+ | `nullable` | Widen JSON-Schema `type` to include null. |
374
+ | `hidden` | Block from response projection + OpenAPI. |
375
+ | `minLength` / `maxLength` / `min` / `max` / `pattern` / `enum` | Map to AJV + OpenAPI. |
376
+ | `description` | OpenAPI `description`. |
553
377
 
554
- `multiTenant` recognizes `member`, `service` (API key), and `elevated`
555
- scopes uniformly via `hasOrgAccess()`. Multi-field uses fail-closed
556
- semantics: missing dimensions → 403 with the specific missing field name.
557
- Elevated scopes apply whatever resolves and skip the rest.
378
+ Mongoose model-level constraints take precedence; `fieldRules` supplements what the model doesn't declare.
558
379
 
559
- ### tenantField When to Use and When to Disable
380
+ ## routeGuards + defineGuard
560
381
 
561
- Arc defaults `tenantField` to `'organizationId'` on BaseController. This silently adds `{ organizationId: scope.organizationId }` to every query when the user has an org context. Correct for per-org resources, wrong for company-wide resources.
382
+ Apply guards to **every** route on a resource:
562
383
 
563
384
  ```typescript
564
- // Per-org resource (default) each org sees only its own data
565
- defineResource({ name: 'invoice', ... });
566
- // → queries auto-scoped: { organizationId: 'org-123' }
385
+ import { defineGuard } from '@classytic/arc/utils';
567
386
 
568
- // Company-wide resourceALL orgs share the same data
569
- defineResource({ name: 'account-type', tenantField: false, ... });
570
- // → no org filter applied, all users see all records
387
+ // Typed guardresolve once, extract anywhere
388
+ const orgGuard = defineGuard({
389
+ name: 'org',
390
+ resolve: (req) => {
391
+ const orgId = req.headers['x-org-id'] as string;
392
+ if (!orgId) throw new Error('Missing x-org-id');
393
+ return { orgId, actorId: req.user?.id ?? 'system' };
394
+ },
395
+ });
571
396
 
572
- // Custom tenant field — your schema uses a different name
573
- defineResource({ name: 'workspace-item', tenantField: 'workspaceId', ... });
574
- // → queries scoped by workspaceId instead of organizationId
397
+ defineResource({
398
+ name: 'procurement',
399
+ routeGuards: [orgGuard.preHandler],
400
+ routes: [{
401
+ method: 'GET', path: '/summary', raw: true, permissions: requireAuth(),
402
+ handler: async (req, reply) => {
403
+ const { orgId } = orgGuard.from(req); // typed, no re-computation
404
+ reply.send({ orgId });
405
+ },
406
+ }],
407
+ });
575
408
  ```
576
409
 
577
- When to use `tenantField: false`:
578
- - Lookup tables (account types, categories, currencies)
579
- - Platform-wide settings or config
580
- - Cross-org reports or analytics
581
- - Single-tenant apps where org scoping isn't needed
410
+ **Order:** auth permissions → cache/idempotency → `routeGuards` → per-route `preHandler`.
582
411
 
583
- ### idField — Custom Primary Key
412
+ ## Presets
584
413
 
585
- Default is `'_id'`. Override for resources keyed by a business identifier (UUID, slug, `ORD-2026-0001`, `job-5219f346-a4d`, etc.).
414
+ | Preset | Routes added | Config |
415
+ |---|---|---|
416
+ | `softDelete` | `GET /deleted`, `POST /:id/restore` | `{ deletedField }` |
417
+ | `slugLookup` | `GET /slug/:slug` | `{ slugField }` |
418
+ | `tree` | `GET /tree`, `GET /:parent/children` | `{ parentField }` |
419
+ | `ownedByUser` | none (middleware) | `{ ownerField }` |
420
+ | `multiTenant` | none (middleware) | `{ tenantField }` OR `{ tenantFields: TenantFieldSpec[] }` |
421
+ | `audited` | none (middleware) | — |
422
+ | `bulk` | `POST/PATCH/DELETE /bulk` | `{ operations?, maxCreateItems? }` |
423
+ | `filesUpload` | `POST /upload`, `GET /:id`, `DELETE /:id` | `{ storage, sanitizeFilename?, allowedMimeTypes?, maxFileSize? }` |
424
+ | `search` | `POST /search`, `/search-similar`, `/embed` | `{ repository?, search?, similar?, embed?, routes? }` |
586
425
 
587
426
  ```typescript
588
- defineResource({
589
- name: 'job',
590
- adapter: createMongooseAdapter(JobModel, jobRepository),
591
- idField: 'jobId', // ← one line (or omit and auto-derive from repository.idField — see below)
592
- });
593
-
594
- // GET /jobs/job-5219f346-a4d → controller runs { jobId: 'job-5219f346-a4d' }
595
- // GET /jobs/<uuid> → accepted (no ObjectId pattern enforcement)
427
+ presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
596
428
  ```
597
429
 
598
- Changes all three layers:
599
- - **Fastify AJV** — strips any ObjectId pattern from `params.id` so custom formats aren't pre-rejected
600
- - **BaseController** — `get`/`update`/`delete` query by `{ [idField]: id }` (merged with tenant + policy filters)
601
- - **OpenAPI docs** — `spec.paths['/jobs/{id}']` emits a plain string `id` with description
602
- - **MCP tools** — auto-generated CRUD tools use `idField` transparently
603
-
604
- **Auto-derive from repository** (2.7.x+). If you don't set `idField` on `defineResource` but your `adapter.repository` exposes one (e.g. `new Repository(Model, [], {}, { idField: 'slug' })`), Arc picks it up automatically. Configure in one place, not two.
430
+ ### tenantField when to use, when to disable
605
431
 
606
- **URL path segment is ALWAYS `:id` and `req.params.id` is ALWAYS named `id`** — regardless of what `idField` is set to. `idField` controls the *lookup field*, not the *URL parameter name*. This is the same convention Stripe / GitHub / most REST APIs use:
432
+ Default `'organizationId'` silently scopes queries to the caller's org. Correct for per-org resources, **wrong** for company-wide:
607
433
 
608
434
  ```typescript
609
- // idField: 'slug'
610
- // Client sends: POST /agents/sadman/action
611
- // In handler: req.params.id === 'sadman' always keyed 'id'
612
- // repo.update(id, data) ← mongokit resolves by slug via repo.idField
435
+ defineResource({ name: 'invoice' }); // → { organizationId: scope.orgId }
436
+ defineResource({ name: 'account-type', tenantField: false }); // company-wide lookup
437
+ defineResource({ name: 'workspace-item', tenantField: 'workspaceId' });
613
438
  ```
614
439
 
615
- This applies to CRUD routes (`GET/PATCH/DELETE /:id`), action routes (`POST /:id/action`), and any `routes: [...]` entries that use `:id`.
440
+ Use `tenantField: false` for lookup tables, platform settings, cross-org reports, single-tenant apps. **2.12 auto-inference:** if the Mongoose model has no `organizationId` path (and no other tenant field is configured), arc auto-infers `tenantField: false` instead of generating queries that filter on a non-existent column.
616
441
 
617
- **Common confusion pattern.** A 404 on `PATCH /agents/sadman` when `GET /agents/sadman` returns 200 looks like an `idField` bug but usually isn't. Check whether your **update permission** returns `filters` arc merges those into the compound DB lookup (`{ slug: 'sadman', ...filters }`), and a filter that excludes the doc is what's returning null. Fix is in the permission, not `idField`.
442
+ ### idField — custom primary key
618
443
 
619
- User-provided `openApiSchemas.params` still overrides everything.
444
+ Default `'_id'`. Override for business identifiers (UUID, slug, `ORD-2026-0001`):
620
445
 
621
- For custom adapters, honor the new `AdapterSchemaContext` passed to `generateSchemas(options, context?)` to emit the right `params.id` pattern from the start. Legacy adapters still work — Arc's safety net strips mismatched ObjectId patterns automatically.
446
+ ```typescript
447
+ defineResource({ name: 'job', adapter, idField: 'jobId' });
448
+ // GET /jobs/job-5219f346-a4d → controller queries { jobId: 'job-5219f346-a4d' }
449
+ ```
450
+
451
+ Auto-derived from `repository.idField` if your kit declares one. URL segment is **always** `:id` and `req.params.id` is **always** named `id` — `idField` controls the *lookup field*, not the URL parameter (Stripe / GitHub convention).
452
+
453
+ **404 confusion pattern.** A 404 on `PATCH /agents/sadman` when `GET /agents/sadman` works isn't usually an `idField` bug — check whether your update permission returns `filters`. Arc merges those into the lookup (`{ slug: 'sadman', ...filters }`); an excluding filter returns null.
622
454
 
623
- ## searchPreset (text + vector + embed)
455
+ ### searchPreset (text + vector + embed)
624
456
 
625
- Backend-agnostic routes for Elasticsearch / OpenSearch / Algolia / Typesense / Atlas `$vectorSearch` / Pinecone / Qdrant / Milvus. Opt-in per section; `mcp: false` skips per path.
457
+ Backend-agnostic for Elasticsearch / OpenSearch / Algolia / Typesense / Atlas `$vectorSearch` / Pinecone / Qdrant.
626
458
 
627
459
  ```typescript
628
460
  import { searchPreset } from '@classytic/arc/presets/search';
629
461
 
630
462
  // A — auto-wire from a repo with search/searchSimilar/embed methods
631
- // (mongokit's elasticSearchPlugin + vectorPlugin register exactly these).
632
- // Each method's native calling convention is honoured:
633
- // search(query, options) — positional (elasticSearchPlugin)
634
- // searchSimilar(VectorSearchParams) — single object (vectorPlugin)
635
- // embed(input) — single arg (vectorPlugin)
636
- searchPreset({
637
- repository: productRepo,
638
- search: true, // POST /search → repo.search(body.query, body)
639
- similar: true, // POST /search-similar → repo.searchSimilar(body)
640
- // embed omitted → /embed not mounted
641
- })
463
+ searchPreset({ repository: productRepo, search: true, similar: true })
642
464
 
643
- // B — external backends + custom path + Zod schema
465
+ // B — external backends
644
466
  searchPreset({
645
467
  search: {
646
468
  path: '/full-text',
@@ -648,179 +470,101 @@ searchPreset({
648
470
  handler: (req) => elastic.search({ index: 'products', q: req.body.q }),
649
471
  },
650
472
  similar: { handler: (req) => pinecone.query({ vector: req.body.vector, topK: 10 }), mcp: false },
651
- routes: [ // bespoke paths
652
- { method: 'GET', path: '/autocomplete', permissions: allowPublic(),
653
- handler: (req) => algolia.suggest((req.query as { q: string }).q) },
654
- ],
655
473
  })
656
474
  ```
657
475
 
658
- **Defaults:** search/similar POST, permissions fall back to resource `list` → `allowPublic()`. Embed → POST + `requireAuth()`. Every field (`path`, `method`, `schema`, `permissions`, `mcp`, `summary`, `tags`, `operation`) is overridable per section. Zod v4 schemas auto-convert to JSON Schema for both Fastify validation and OpenAPI.
659
-
660
- **MCP namespacing:** tool names are `{op}_{resource}` — many resources can register their own searchPreset under one `mcpPlugin` endpoint without colliding (`product_search`, `order_search`, …).
476
+ Defaults: search/similar inherit `list` perms → `allowPublic()`. Embed → `requireAuth()`. Zod v4 schemas auto-convert. MCP tools namespaced as `{op}_{resource}`.
661
477
 
662
- **When to use `routes` directly instead:** one-off search endpoints, or when you want full control without the preset's defaults.
663
-
664
- ## QueryCache
478
+ ## Adapters
665
479
 
666
- TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
480
+ In arc 2.12 the cross-framework adapter contract lives in `@classytic/repo-core/adapter`. Every kit-specific adapter ships from its kit's `/adapter` subpath; arc has zero kit-bound adapters in `src/`.
667
481
 
668
482
  ```typescript
669
- // Enable globally
670
- const app = await createApp({ arcPlugins: { queryCache: true } });
483
+ // Mongoose — from mongokit
484
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
485
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
671
486
 
672
- // Per-resource config
673
- defineResource({
674
- name: 'product',
675
- cache: {
676
- staleTime: 30, // seconds fresh (no revalidation)
677
- gcTime: 300, // seconds stale data kept (SWR window)
678
- tags: ['catalog'], // cross-resource grouping
679
- invalidateOn: { 'category.*': ['catalog'] }, // event pattern → tag targets
680
- list: { staleTime: 60 }, // per-operation override
681
- byId: { staleTime: 10 },
682
- },
487
+ const adapter = createMongooseAdapter({
488
+ model: ProductModel,
489
+ repository: productRepo,
490
+ schemaGenerator: buildCrudSchemasFromModel, // no cast needed
683
491
  });
492
+
493
+ // Drizzle — from sqlitekit
494
+ import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter';
495
+ import { buildCrudSchemasFromTable } from '@classytic/sqlitekit';
496
+
497
+ createDrizzleAdapter({ table, repository, schemaGenerator: buildCrudSchemasFromTable });
498
+
499
+ // Prisma — from prismakit
500
+ import { createPrismaAdapter } from '@classytic/prismakit/adapter';
684
501
  ```
685
502
 
686
- **Auto-invalidation:** POST/PATCH/DELETE bumps resource version. Old cached queries expire naturally via TTL.
503
+ Custom kits implementing `DataAdapter<TDoc>` from `@classytic/repo-core/adapter` plug in identically. Kit factories accept `AdapterRepositoryInput<TDoc>` kit-native repos plug in **without** `as RepositoryLike` casts.
687
504
 
688
- **Runtime modes:** `memory` (default, zero-config) | `distributed` (requires `stores.queryCache: RedisCacheStore`)
505
+ **Custom adapter** — implement `DataAdapter` / `MinimalRepo` from `@classytic/repo-core/adapter`:
689
506
 
690
- **Response header:** `x-cache: HIT | STALE | MISS`
507
+ ```typescript
508
+ import type {
509
+ DataAdapter, RepositoryLike, AdapterRepositoryInput,
510
+ } from '@classytic/repo-core/adapter';
511
+ import type { MinimalRepo } from '@classytic/repo-core/repository';
512
+ // MinimalRepo<TDoc> = 5-method floor (getAll, getById, create, update, delete)
513
+ // StandardRepo<TDoc> = MinimalRepo + optional batch ops, CAS, soft-delete, …
514
+ // Arc feature-detects optional methods at call sites.
515
+ ```
691
516
 
692
- ## Controllers (v2.11 mixin split)
517
+ ## Controllers
693
518
 
694
- `BaseController` was split in 2.11 from a 1,589-LOC god class into a mixin composition. `extends BaseController<Product>` still works exactly the same — a declaration-merged interface threads `TDoc` through every CRUD + preset method.
519
+ `BaseController` is mixin-composed; declaration-merged interfaces thread `TDoc` through every CRUD + preset method.
695
520
 
696
521
  ```typescript
697
522
  import { BaseController } from '@classytic/arc';
698
523
  import type { IRequestContext, IControllerResponse } from '@classytic/arc';
699
524
 
700
- // Full surface: CRUD + SoftDelete + Tree + Slug + Bulk
701
525
  class ProductController extends BaseController<Product> {
702
- constructor() { super(productRepo); }
526
+ // When you pass your own controller, arc CANNOT thread tenantField /
527
+ // schemaOptions / idField / cache / onFieldWriteDenied into it. Forward
528
+ // them via super() and pass them to defineResource() too:
529
+ constructor(opts: { tenantField?: string | false; idField?: string } = {}) {
530
+ super(productRepo, { resourceName: 'product', ...opts });
531
+ }
703
532
 
704
533
  async getFeatured(req: IRequestContext): Promise<IControllerResponse<Product[]>> {
705
534
  const products = await this.repository.getAll({ filters: { isFeatured: true } });
706
- return { success: true, data: products };
535
+ return { data: products };
707
536
  }
708
537
  }
709
- ```
710
538
 
711
- **Slim CRUD-only surface** (869 LOC instead of 1,650):
712
-
713
- ```typescript
714
- import { BaseCrudController } from '@classytic/arc';
715
- class ReportController extends BaseCrudController<Report> {}
539
+ defineResource({ name: 'product', controller: new ProductController({ tenantField: '_id' }), tenantField: '_id' });
716
540
  ```
717
541
 
718
- **Pick specific mixins:**
542
+ Presets that inject controller fields (slugLookup → slugField, softDelete, tree) only reach arc's auto-built `BaseController`. With a custom controller + such a preset, drop the preset OR extend `BaseController` so arc auto-builds it.
543
+
544
+ **Slim CRUD-only base** (no soft-delete/tree/slug/bulk):
719
545
 
720
546
  ```typescript
721
547
  import { BaseCrudController, SoftDeleteMixin, BulkMixin } from '@classytic/arc';
548
+ class ReportController extends BaseCrudController<Report> {}
722
549
  class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController)) {}
723
- // → list/get/create/update/delete + getDeleted/restore + bulkCreate/bulkUpdate/bulkDelete
724
- ```
725
-
726
- **Mixin surface:** `SoftDeleteMixin` (`getDeleted`, `restore`) · `TreeMixin` (`getTree`, `getChildren`) · `SlugMixin` (`getBySlug`) · `BulkMixin` (`bulkCreate`, `bulkUpdate`, `bulkDelete`). Each exported from `@classytic/arc` and `@classytic/arc/core`.
727
-
728
- **Shared helpers** (protected on `BaseCrudController` so mixins can extend): `meta(req)`, `getHooks(req)`, `tenantRepoOptions(req)`, `resolveRepoId(id, existing)`, `notFoundResponse(reason)`, `resolveCacheConfig(op)`, `cacheScope(req)`.
729
-
730
- **IRequestContext:** `{ params, query, body, user, headers, context, metadata, server }` — `user` is `Record<string, unknown> | undefined` (guard with `if (req.user)` on public routes)
731
-
732
- **IControllerResponse:** `{ success, data?, error?, status?, meta?, headers? }`
733
-
734
- ## Adapters (Database-Agnostic)
735
-
736
- ```typescript
737
- // Mongoose — canonical arc factory
738
- import { createMongooseAdapter } from '@classytic/arc';
739
- import { buildCrudSchemasFromModel } from '@classytic/mongokit';
740
-
741
- const adapter = createMongooseAdapter({
742
- model: ProductModel,
743
- repository: productRepo,
744
- schemaGenerator: buildCrudSchemasFromModel, // ← no cast; RouteSchemaOptions extends SchemaBuilderOptions
745
- });
746
-
747
- // Custom adapter — implement MinimalRepo from @classytic/repo-core/repository:
748
- import type { MinimalRepo } from '@classytic/repo-core/repository';
749
- // MinimalRepo<TDoc> = five-method floor (getAll, getById, create, update, delete)
750
- // StandardRepo<TDoc> = MinimalRepo + optional batch ops, CAS, soft-delete, etc.
751
- // Arc feature-detects optional methods at call sites.
752
- ```
753
-
754
- - `createMongooseAdapter` is the **canonical arc export**. Use directly — no cast on `schemaGenerator` (arc's `RouteSchemaOptions extends SchemaBuilderOptions`; `ArcFieldRule extends FieldRule`).
755
- - `createAdapter` is a **CLI-scaffolded host wrapper** (`src/lib/adapter.ts`). Keep for scaffolded apps; hand-built apps should import `createMongooseAdapter` directly.
756
- - Built-in mongoose fallback detects `{ default: null }` on schema paths and widens the emitted JSON-Schema type automatically — no `fieldRules` entry needed for that case.
757
-
758
- ## Events
759
-
760
- The factory auto-registers `eventPlugin` — no manual setup needed:
761
-
762
- ```typescript
763
- // createApp() registers eventPlugin automatically (default: MemoryEventTransport)
764
- const app = await createApp({
765
- stores: { events: new RedisEventTransport(redis) }, // optional, defaults to memory
766
- arcPlugins: {
767
- events: { // event plugin config (default: true, false to disable)
768
- logEvents: true,
769
- retry: { maxRetries: 3, backoffMs: 1000 },
770
- },
771
- },
772
- });
773
-
774
- await app.events.publish('order.created', { orderId: '123' });
775
- await app.events.subscribe('order.*', async (event) => { ... });
776
550
  ```
777
551
 
778
- CRUD events auto-emit: `{resource}.created`, `{resource}.updated`, `{resource}.deleted`.
779
-
780
- **Transports:** Memory (default) | Redis Pub/Sub (fire-and-forget) | Redis Streams (durable, at-least-once, consumer groups, DLQ)
781
-
782
- **Event Outbox** — at-least-once delivery via transactional outbox pattern. Pass `repository: RepositoryLike` (mongokit / prismakit / custom) for production, or `store: MemoryOutboxStore()` for dev. Arc adapts the repo to the `OutboxStore` contract internally — `create` / `findAll` / `deleteMany` / `findOneAndUpdate` cover save, claim, ack, fail, DLQ.
783
-
784
- **Event contract (v2.9):** `EventMeta` = `id`, `timestamp`, optional `schemaVersion`, `correlationId`, `causationId`, `partitionKey`, `source`, `idempotencyKey`, `resource`, `resourceId`, `userId`, `organizationId`, `aggregate: { type, id }`. `createChildEvent(parent, ...)` inherits correlation/causation/source/idempotencyKey; aggregate stays explicit. `DeadLetteredEvent<T>` + optional `transport.deadLetter()` for typed DLQ. `withRetry({ transport })` auto-routes exhausted events — no custom plumbing for Kafka/SQS. `@classytic/primitives` mirrors these shapes — arc is source of truth.
552
+ Mixin surface: `SoftDeleteMixin` · `TreeMixin` · `SlugMixin` · `BulkMixin`. Protected helpers on `BaseCrudController`: `meta(req)`, `getHooks(req)`, `tenantRepoOptions(req)`, `resolveRepoId(id, existing)`, `notFoundResponse(reason)`, `resolveCacheConfig(op)`, `cacheScope(req)`.
785
553
 
786
- **Outbox (v2.9):** `EventOutbox.store()` auto-maps `meta.idempotencyKey` → `dedupeKey`. `new EventOutbox({ failurePolicy: ({ attempts }) => ({ retryAt, deadLetter }) })` centralises retry/DLQ. `outbox.getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`. `RelayResult.deadLettered` for per-batch DLQ count. Durable store: `new EventOutbox({ repository: new Repository(OutboxModel), transport })` (v2.9.1) — multi-worker claim, session-threaded writes, and dedupe semantics come from the repo's backing kit.
787
-
788
- ## Factory — createApp()
789
-
790
- ```typescript
791
- const app = await createApp({
792
- preset: 'production', // production | development | testing | edge
793
- runtime: 'memory', // memory (default) | distributed
794
- auth: { type: 'jwt', jwt: { secret } },
795
- cors: { origin: ['https://myapp.com'] },
796
- helmet: true, // false to disable
797
- rateLimit: { max: 100 }, // false to disable
798
- ajv: { keywords: ['x-internal'] }, // custom AJV keywords for schema validation
799
- arcPlugins: {
800
- events: true, // event plugin (default: true, false to disable)
801
- emitEvents: true, // CRUD event emission (default: true)
802
- queryCache: true, // server cache (default: false)
803
- sse: true, // SSE streaming (default: false)
804
- caching: true, // ETag + Cache-Control (default: false)
805
- },
806
- stores: { // required when runtime: 'distributed'
807
- events: new RedisEventTransport({ client: redis }),
808
- queryCache: new RedisCacheStore({ client: redis }),
809
- },
810
- });
811
- ```
554
+ `IRequestContext` = `{ params, query, body, user, headers, context, metadata, server }`.
555
+ `IControllerResponse` = `{ success, data?, error?, status?, meta?, headers? }`.
812
556
 
813
557
  ## Hooks
814
558
 
815
- **Inline on resource (recommended):**
559
+ Inline on resource — `ResourceHookContext` = `{ data, user?, meta? }`; `meta` has `id` and `existing` for update/delete:
816
560
 
817
561
  ```typescript
818
562
  defineResource({
819
563
  name: 'chat',
820
564
  hooks: {
821
565
  beforeCreate: async (ctx) => { ctx.data.slug = slugify(ctx.data.name); },
822
- afterCreate: async (ctx) => { analytics.track('created', { id: ctx.data._id, user: ctx.user?.id }); },
823
- beforeUpdate: async (ctx) => { console.log('Updating', ctx.meta?.id, 'existing:', ctx.meta?.existing); },
566
+ afterCreate: async (ctx) => { analytics.track('created', { id: ctx.data._id }); },
567
+ beforeUpdate: async (ctx) => { /* ctx.meta.existing has the pre-image */ },
824
568
  afterUpdate: async (ctx) => { await invalidateCache(ctx.data._id); },
825
569
  beforeDelete: async (ctx) => { if (ctx.data.isProtected) throw new Error('Cannot delete'); },
826
570
  afterDelete: async (ctx) => { await cleanupFiles(ctx.meta?.id); },
@@ -828,9 +572,7 @@ defineResource({
828
572
  });
829
573
  ```
830
574
 
831
- `ResourceHookContext`: `{ data, user?, meta? }` — `data` is the document, `meta` has `id` and `existing` (for update/delete).
832
-
833
- **App-level (cross-resource):**
575
+ App-level (cross-resource):
834
576
 
835
577
  ```typescript
836
578
  import { createHookSystem, beforeCreate, afterUpdate } from '@classytic/arc/hooks';
@@ -855,66 +597,125 @@ defineResource({
855
597
  });
856
598
  ```
857
599
 
858
- ## Query Parsing
600
+ ## Query parsing
859
601
 
860
- Arc's default parser handles filters, sort, select, populate, and pagination. Swap in MongoKit's `QueryParser` for $lookup joins.
602
+ Default parser handles filters, sort, select, populate, pagination.
861
603
 
862
604
  ```
863
605
  GET /products?page=2&limit=20&sort=-createdAt&select=name,price
864
606
  GET /products?price[gte]=100&status[in]=active,featured&search=keyword
865
- GET /products?after=<cursor_id>&limit=20 # keyset pagination
866
- GET /products?populate=category # ref-based populate
867
- GET /products?populate[category][select]=name,slug # populate with field select
868
- GET /products?populate[category][select]=-internal # exclude fields
869
- GET /products?populate[category][match][isActive]=true # populate with filter
607
+ GET /products?after=<cursor_id>&limit=20 # keyset pagination
608
+ GET /products?populate=category
609
+ GET /products?populate[category][select]=name,slug
610
+ GET /products?populate[category][match][isActive]=true
870
611
  ```
871
612
 
872
- **Lookup/join (no refs $lookup via MongoKit QueryParser):**
613
+ Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `like`, `regex`, `exists`.
614
+
615
+ **MongoKit `$lookup` joins:**
873
616
 
874
617
  ```
875
618
  GET /products?lookup[cat][from]=categories&lookup[cat][localField]=categorySlug&lookup[cat][foreignField]=slug&lookup[cat][single]=true
876
- GET /products?lookup[cat][from]=categories&...&lookup[cat][select]=name,slug
877
619
  ```
878
620
 
879
- Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `like`, `regex`, `exists`
880
-
881
- **Custom query parser (e.g., MongoKit >=3.4.5 for $lookup, whitelists, MCP auto-derive):**
621
+ **Custom parser (whitelists, MCP auto-derive):**
882
622
 
883
623
  ```typescript
884
624
  import { QueryParser } from '@classytic/mongokit';
885
625
 
886
626
  defineResource({
887
627
  name: 'product',
888
- adapter: createMongooseAdapter({ model: ProductModel, repository: productRepo }),
628
+ adapter,
889
629
  queryParser: new QueryParser({
890
- allowedFilterFields: ['status', 'category', 'orgId'], // whitelist filter fields
891
- allowedSortFields: ['createdAt', 'price'], // whitelist sort fields
892
- allowedOperators: ['eq', 'gte', 'lte', 'in'], // whitelist operators
630
+ allowedFilterFields: ['status', 'category', 'orgId'],
631
+ allowedSortFields: ['createdAt', 'price'],
632
+ allowedOperators: ['eq', 'gte', 'lte', 'in'],
893
633
  }),
894
- // MCP auto-derives filterableFields from queryParser — no duplication needed
895
634
  schemaOptions: {
896
- query: {
897
- allowedPopulate: ['category', 'brand'],
898
- allowedLookups: ['categories', 'brands'],
899
- },
635
+ query: { allowedPopulate: ['category', 'brand'], allowedLookups: ['categories', 'brands'] },
636
+ },
637
+ });
638
+ ```
639
+
640
+ MCP auto-derives `filterableFields` from `queryParser`.
641
+
642
+ ## QueryCache
643
+
644
+ TanStack Query-style server cache, stale-while-revalidate, auto-invalidation on mutations.
645
+
646
+ ```typescript
647
+ const app = await createApp({ arcPlugins: { queryCache: true } });
648
+
649
+ defineResource({
650
+ name: 'product',
651
+ cache: {
652
+ staleTime: 30,
653
+ gcTime: 300,
654
+ tags: ['catalog'],
655
+ invalidateOn: { 'category.*': ['catalog'] }, // event pattern → tag targets
656
+ list: { staleTime: 60 }, // per-operation override
657
+ byId: { staleTime: 10 },
900
658
  },
901
659
  });
902
660
  ```
903
661
 
904
- ## Error Classes
662
+ POST/PATCH/DELETE bumps resource version. Modes: `memory` (default) | `distributed` (requires `stores.queryCache: RedisCacheStore`). Response header: `x-cache: HIT | STALE | MISS`.
663
+
664
+ ## Events
665
+
666
+ `createApp` auto-registers `eventPlugin` (default: `MemoryEventTransport`).
667
+
668
+ ```typescript
669
+ const app = await createApp({
670
+ stores: { events: new RedisEventTransport(redis) }, // optional
671
+ arcPlugins: { events: { logEvents: true, retry: { maxRetries: 3, backoffMs: 1000 } } },
672
+ });
673
+
674
+ await app.events.publish('order.created', { orderId: '123' });
675
+ await app.events.subscribe('order.*', async (event) => { ... });
676
+ ```
677
+
678
+ CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`.
679
+
680
+ **Transports:** Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, at-least-once, consumer groups, DLQ).
681
+
682
+ **EventMeta:** `id`, `timestamp`, optional `schemaVersion`, `correlationId`, `causationId`, `partitionKey`, `source`, `idempotencyKey`, `resource`, `resourceId`, `userId`, `organizationId`, `aggregate: { type, id }`. Import event types from `@classytic/primitives/events` (`EventMeta`, `DomainEvent`, `EventHandler`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern`); arc re-exports the runtime `MemoryEventTransport` only. Use `createChildEvent(parent, ...)` to inherit correlation/causation/source/idempotencyKey.
683
+
684
+ Calling both `app.events.publish('order.placed', ...)` *and* a notification helper that internally publishes the same logical event triggers a one-shot dev-mode warning ("dual-publish"). Pick one path: manual publish OR `eventStrategy: 'auto'`.
685
+
686
+ **Event Outbox** — at-least-once via transactional outbox. Production: `new EventOutbox({ repository: outboxRepo, transport })` (multi-worker claim, session-threaded writes). Dev: `new EventOutbox({ store: new MemoryOutboxStore(), transport })`. `EventOutbox.store()` auto-maps `meta.idempotencyKey` → `dedupeKey`. `failurePolicy` centralises retry/DLQ.
687
+
688
+ Full event recipes → [references/events.md](references/events.md).
689
+
690
+ ## Errors
905
691
 
906
692
  ```typescript
907
693
  import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
908
- throw new NotFoundError('Product not found'); // 404
909
- throw createDomainError('MEMBER_NOT_FOUND', 'Not found', 404); // domain error with code
694
+
695
+ throw new NotFoundError('Product not found'); // 404
910
696
  throw createDomainError('SELF_REFERRAL', 'Cannot self-refer', 422, { field: 'referralCode' });
911
697
  ```
912
698
 
913
- Error handler catches: `ArcError` → `.statusCode` (Fastify) → `.status` (MongoKit, http-errors) → `errorMap` → Mongoose/MongoDB → fallback 500. DB-agnostic — any error with `.status` or `.statusCode` gets the correct HTTP response.
699
+ Resolution: `ArcError` → `.statusCode` (Fastify) → `.status` (MongoKit, http-errors) → user `errorMap` → Mongoose/MongoDB → 500. Any error with `.status` or `.statusCode` gets the correct HTTP response.
914
700
 
915
- ## Compensating Transaction
701
+ `ArcError` implements the `HttpError` throwable contract from `@classytic/repo-core/errors` (`status` getter mirrors `statusCode`, `meta` getter mirrors `details`). Wire envelope: `{ code, message, status, meta?, correlationId? }` — HTTP status code is the success/error discriminator, no redundant `success` field. For non-Arc errors, `toErrorContract(err)` (from `@classytic/repo-core/errors`) serialises any `HttpError` to the canonical `ErrorContract` wire shape; `statusToErrorCode(status)` maps numeric status to canonical `ErrorCode`.
916
702
 
917
- In-process rollback for multi-step operations. Not a distributed saga — use Temporal/Streamline for that.
703
+ **Class-based mappers:**
704
+
705
+ ```typescript
706
+ const app = await createApp({
707
+ errorHandler: {
708
+ errorMappers: [{
709
+ type: AccountingError,
710
+ toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
711
+ }],
712
+ },
713
+ });
714
+ ```
715
+
716
+ ## Compensating transaction
717
+
718
+ In-process rollback for multi-step operations (not a distributed saga — use Temporal / Streamline for that):
918
719
 
919
720
  ```typescript
920
721
  import { withCompensation } from '@classytic/arc/utils';
@@ -922,32 +723,22 @@ import { withCompensation } from '@classytic/arc/utils';
922
723
  const result = await withCompensation('checkout', [
923
724
  { name: 'reserve', execute: reserveStock, compensate: releaseStock },
924
725
  { name: 'charge', execute: chargeCard, compensate: refundCard },
925
- { name: 'notify', execute: sendEmail, fireAndForget: true }, // non-blocking
926
- ], { orderId }, {
927
- onStepComplete: (name, res) => fastify.events.publish(`checkout.${name}.done`, res),
928
- });
726
+ { name: 'notify', execute: sendEmail, fireAndForget: true },
727
+ ], { orderId });
929
728
  // result: { success, completedSteps, results, failedStep?, error?, compensationErrors? }
930
729
  ```
931
730
 
932
731
  ## Testing
933
732
 
934
- Three entry points — pick by what you're testing. Full details in [references/testing.md](references/testing.md).
935
-
936
733
  ```typescript
937
- import {
938
- createTestApp, // turnkey Fastify + in-memory Mongo + auth + fixtures
939
- createHttpTestHarness, // auto-generates ~16 CRUD/permission/validation tests
940
- expectArc, // fluent envelope matchers
941
- createTestFixtures, // DB-agnostic seeding with per-record destroyers
942
- } from '@classytic/arc/testing';
734
+ import { createTestApp, expectArc } from '@classytic/arc/testing';
943
735
 
944
736
  const ctx = await createTestApp({
945
737
  resources: [productResource],
946
- authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
947
- db: 'in-memory', // default
948
- connectMongoose: true, // one-liner for Mongoose-backed resources
738
+ authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
739
+ connectMongoose: true, // in-memory Mongo + Mongoose connect
949
740
  });
950
- ctx.auth.register('admin', { user: { id: '1', roles: ['admin'] }, orgId: 'org-1' });
741
+ ctx.auth.register('admin', { user: { id: '1', role: 'admin' }, orgId: 'org-1' });
951
742
 
952
743
  const res = await ctx.app.inject({
953
744
  method: 'POST', url: '/products',
@@ -955,34 +746,35 @@ const res = await ctx.app.inject({
955
746
  payload: { name: 'Widget' },
956
747
  });
957
748
  expectArc(res).ok().hidesField('password');
749
+
750
+ await ctx.close();
958
751
  ```
959
752
 
960
- `TestAppContext` = `{ app, auth, fixtures, dbUri, close }`. `authMode: 'better-auth'` requires the caller to also pass `auth: { type: 'better-auth', ... }`.
753
+ Three entry points: `createTestApp` (custom scenarios), `createHttpTestHarness` (~16 auto-generated CRUD/permission/validation tests per resource), `runStorageContract` (adapter conformance).
754
+
755
+ Full testing recipes → [references/testing.md](references/testing.md).
961
756
 
962
757
  ## CLI
963
758
 
964
759
  ```bash
965
- arc init my-api --mongokit --better-auth --ts
966
- arc generate resource product # standard resource
967
- arc generate resource product --mcp # resource + MCP tools file
968
- arc generate mcp analytics # standalone MCP tools file
969
- arc docs ./openapi.json --entry ./dist/index.js
760
+ arc init my-api --mongokit --jwt --ts # scaffold (also: --custom, --better-auth, --multi)
761
+ arc generate resource product # generate a resource
762
+ arc generate resource product --mcp # + MCP tools file
763
+ arc generate mcp analytics # standalone MCP tools file
764
+ arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
970
765
  arc introspect --entry ./dist/index.js
971
766
  arc doctor
972
767
  ```
973
768
 
974
- Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` files with resources.
975
-
976
- ## MCP (AI Agent Tools)
769
+ Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
977
770
 
978
- Expose Arc resources as MCP tools for AI agents. Stateless by default — fresh server per request, zero session overhead.
771
+ ## MCP (AI agent tools)
979
772
 
980
- See [mcp reference](references/mcp.md) for full details.
773
+ Resources auto-generate Model Context Protocol tools — same permissions, same field rules. Stateless by default (fresh server per request, scalable).
981
774
 
982
775
  ```typescript
983
776
  import { mcpPlugin } from '@classytic/arc/mcp';
984
777
 
985
- // Stateless (default) — production-ready, scalable
986
778
  await app.register(mcpPlugin, {
987
779
  resources: [productResource, orderResource],
988
780
  auth: false, // or: getAuth() | custom function
@@ -994,18 +786,18 @@ await app.register(mcpPlugin, {
994
786
  await app.register(mcpPlugin, { resources, stateful: true, sessionTtlMs: 600000 });
995
787
  ```
996
788
 
997
- Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:3000/mcp`
789
+ Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:3000/mcp`.
998
790
 
999
- **Auth** — three modes, user chooses: `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function:
791
+ **Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function:
1000
792
 
1001
793
  ```typescript
1002
- // Human user auth
794
+ // Human user
1003
795
  auth: async (headers) => {
1004
796
  if (headers['x-api-key'] !== process.env.MCP_KEY) return null;
1005
797
  return { userId: 'bot', organizationId: 'org-1', roles: ['admin'] };
1006
798
  },
1007
799
 
1008
- // Service account / machine-to-machine (produces kind: "service" scope)
800
+ // Service / machine produces kind: "service" scope
1009
801
  auth: async (headers) => ({
1010
802
  clientId: 'ingestion-pipeline',
1011
803
  organizationId: 'org-1',
@@ -1013,14 +805,14 @@ auth: async (headers) => ({
1013
805
  }),
1014
806
  ```
1015
807
 
1016
- `auth: false` → `ctx.user` is `null`, `scope.kind` is `"public"`. Permission guards like `!!ctx.user` correctly block anonymous callers.
808
+ `auth: false` → `ctx.user` null, `scope.kind: "public"`. `clientId` set `kind: "service"` works with `requireServiceScope()`. `PermissionResult.filters` flow into MCP tools — same as REST.
1017
809
 
1018
- **Guards** for custom tools: `guard(requireAuth, requireOrg, requireRole('admin'), handler)`
810
+ **Custom tools** co-locate with resources (`order.mcp.ts`), wire via `extraTools: [fulfillOrderTool]`. Generate: `arc generate resource order --mcp`.
1019
811
 
1020
- **AI SDK bridge** (v2.8.4+) — expose AI SDK `tool()` definitions over MCP without duplicating glue. Handles auth, guards, `{ error } → isError` translation, and thrown-error mapping:
812
+ **AI SDK bridge** — expose AI SDK `tool()` definitions over MCP without duplicating glue:
1021
813
 
1022
814
  ```typescript
1023
- import { bridgeToMcp, buildMcpToolsFromBridges, getUserId, hasOrg, type McpBridge } from '@classytic/arc/mcp';
815
+ import { buildMcpToolsFromBridges, getUserId, hasOrg, type McpBridge } from '@classytic/arc/mcp';
1024
816
 
1025
817
  export const triggerJobBridge: McpBridge = {
1026
818
  name: 'trigger_job',
@@ -1033,246 +825,190 @@ export const triggerJobBridge: McpBridge = {
1033
825
 
1034
826
  await app.register(mcpPlugin, {
1035
827
  resources,
1036
- extraTools: buildMcpToolsFromBridges([triggerJobBridge], {
1037
- exclude: process.env.DEPLOYMENT === 'readonly' ? ['trigger_job'] : [],
1038
- }),
828
+ extraTools: buildMcpToolsFromBridges([triggerJobBridge]),
1039
829
  });
1040
830
  ```
1041
831
 
1042
- **Service scope**: When `clientId` is set in auth result, MCP produces `kind: "service"` RequestScope — works with `requireServiceScope()`, `getClientId()`, `getServiceScopes()`. No synthetic userId needed for machine principals.
1043
-
1044
- **Multi-tenancy**: `organizationId` from auth flows into BaseController org-scoping automatically.
832
+ Full MCP recipes [references/mcp.md](references/mcp.md).
1045
833
 
1046
- **Permission filters**: `PermissionResult.filters` from resource permissions flow into MCP tools — same as REST. Define once, works everywhere:
834
+ ## Audit per-resource opt-in
1047
835
 
1048
836
  ```typescript
1049
- permissions: {
1050
- list: (ctx) => ({
1051
- granted: !!ctx.user,
1052
- filters: { orgId: ctx.user?.orgId, branchId: ctx.user?.branchId },
1053
- }),
1054
- }
1055
- // MCP tools automatically scope queries by orgId + branchId
1056
- ```
837
+ await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
1057
838
 
1058
- **Project structure** custom MCP tools co-located with resources:
839
+ defineResource({ name: 'order', audit: true });
840
+ defineResource({ name: 'payment', audit: { operations: ['delete'] } });
841
+ defineResource({ name: 'product' }); // not audited
1059
842
 
843
+ // Manual custom() for MCP tools / read auditing
844
+ app.post('/orders/:id/refund', async (req) => {
845
+ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
846
+ });
1060
847
  ```
1061
- src/resources/order/
1062
- order.resource.ts
1063
- order.mcp.ts ← defineTool('fulfill_order', { ... })
1064
- ```
1065
-
1066
- Generate: `arc generate resource order --mcp` | Wire: `extraTools: [fulfillOrderTool]`
1067
848
 
1068
- **Auto-load resources** — no barrel files, no manual `toPlugin()`:
849
+ ## DX helpers
1069
850
 
1070
851
  ```typescript
1071
- import { createApp, loadResources } from '@classytic/arc/factory';
852
+ import type { ArcRequest } from '@classytic/arc';
853
+ import { envelope, createDomainError } from '@classytic/arc';
854
+ import { getOrgContext } from '@classytic/arc/scope';
855
+ import { roles } from '@classytic/arc/permissions';
1072
856
 
1073
- const app = await createApp({
1074
- resourcePrefix: '/api/v1', // optional URL prefix
1075
- resources: await loadResources(import.meta.url), // discovers *.resource.ts
1076
- auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
1077
- });
1078
- ```
857
+ // Typed request for raw routes — no `(req as any).user`
858
+ handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
1079
859
 
1080
- `loadResources()` discovers files matching `*.resource.{ts,js,mts,mjs}`, recursively. Pass `import.meta.url` for dev/prod parity (resolves to `src/` in dev, `dist/` in prod automatically). Discovers `default` export, `export const resource`, OR any named export with `toPlugin()` (e.g., `export const userResource`).
860
+ // Response envelope
861
+ reply.send(envelope(data, { total: 100 }));
1081
862
 
1082
- Options: `exclude`, `include`, `suffix`, `recursive`, `context`, `logger`. Silent by default — pass `logger: { warn(msg) {...} }` to receive skip / factory-failure diagnostics.
863
+ // Canonical org extraction
864
+ const { userId, organizationId, roles: userRoles, orgRoles } = getOrgContext(request);
1083
865
 
1084
- **Per-resource opt-out of `resourcePrefix`**for webhooks, admin routes:
1085
- ```typescript
1086
- defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true })
1087
- // Registers at /hooks even with createApp({ resourcePrefix: '/api/v1' })
866
+ // Unified role checkplatform AND org roles
867
+ permissions: { create: roles('admin', 'editor'), delete: roles('admin') }
1088
868
  ```
1089
869
 
1090
- **Boot sequence:**
1091
- ```typescript
1092
- const app = await createApp({
1093
- resourcePrefix: '/api/v1',
1094
- plugins: async (f) => { await connectDB(); }, // 1. infra (DB, docs)
1095
- bootstrap: [inventoryInit, accountingInit], // 2. domain init (engines)
1096
- resources: await loadResources(import.meta.url), // 3. routes
1097
- afterResources: async (f) => { subscribeEvents(f); }, // 4. post-wiring
1098
- onReady: async (f) => { logger.info('ready'); }, // 5. lifecycle
1099
- });
1100
- ```
870
+ **Reply helpers** — opt-in via `createApp({ replyHelpers: true })`. Arc has no `{ success, data }` envelope: HTTP status discriminates, single-doc handlers `return doc` or `reply.send(doc)`, errors throw `ArcError` (the global handler serialises to `ErrorContract`). The two decorators cover the cases that DO need framework support:
1101
871
 
1102
- **Audit per-resource opt-in** — no growing exclude lists:
1103
872
  ```typescript
1104
- // Register audit plugin with perResource mode
1105
- await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
873
+ return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
874
+ return reply.sendList(canonicalListResult); // any kit-shaped paginated/array result
875
+ return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
876
+ ```
1106
877
 
1107
- // Opt-in at the resource level
1108
- defineResource({ name: 'order', audit: true });
1109
- defineResource({ name: 'payment', audit: { operations: ['delete'] } });
1110
- defineResource({ name: 'product' }); // not audited
878
+ `reply.sendList()` accepts a bare `T[]` or any kit pagination result (`OffsetPaginationResult` / `KeysetPaginationResult` / `AggregatePaginationResult`) and routes through `toCanonicalList` from `@classytic/repo-core/pagination` so the server and the typed `@classytic/arc-next` client share one declaration — `method` as the discriminant cannot drift between them.
1111
879
 
1112
- // Manual custom() for MCP tools / custom routes / read auditing
1113
- app.post('/orders/:id/refund', async (req) => {
1114
- await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
1115
- });
1116
- ```
880
+ **BigInt serialization** — `createApp({ serializeBigInt: true })` converts BigInt Number in JSON.
1117
881
 
1118
- **Import compatibility:** `loadResources()` uses runtime `import()`. Works with relative imports (`./foo.js`) and Node.js `#` subpath imports (`#shared/utils.js` via `package.json` `imports`). Does **NOT** work with tsconfig path aliases (`@/*`, `~/`) — those are compile-time only.
882
+ **Multipart body middleware** opt-in file upload (no-op for JSON requests, safe to always add):
1119
883
 
1120
- **Vitest workaround** (rare): if resources need engine bootstrap or transitive `node_modules` imports that don't compose with dynamic import:
1121
884
  ```typescript
1122
- import { preloadResources } from '@classytic/arc/testing';
885
+ import { multipartBody } from '@classytic/arc/middleware';
1123
886
 
1124
- export const preloadedResources = preloadResources(
1125
- import.meta.glob('../../src/resources/**/*.resource.ts', { eager: true, import: 'default' }),
1126
- );
887
+ defineResource({
888
+ name: 'product',
889
+ middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5 * 1024 * 1024 })] },
890
+ hooks: {
891
+ 'before:create': async (data) => {
892
+ if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
893
+ return data;
894
+ },
895
+ },
896
+ });
1127
897
  ```
1128
898
 
1129
- **Unified role check** — checks both platform AND org roles:
899
+ **SSE auth + streaming** — `preAuth` runs before auth (EventSource can't set headers); `raw: true` streams the response:
1130
900
 
1131
901
  ```typescript
1132
- import { roles } from '@classytic/arc/permissions';
1133
- permissions: {
1134
- create: roles('admin', 'editor'), // works with BA org roles + platform roles
1135
- delete: roles('admin'),
1136
- }
1137
- // Also: requireRoles(['admin'], { includeOrgRoles: true }) for backward compat
902
+ routes: [
903
+ { preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] },
904
+ { method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) },
905
+ ]
1138
906
  ```
1139
907
 
1140
- **DX helpers:**
908
+ **Per-resource opt-out of `resourcePrefix`** — for webhooks, admin routes:
1141
909
 
1142
910
  ```typescript
1143
- // Typed request for raw routes no more (req as any).user
1144
- import type { ArcRequest } from '@classytic/arc';
1145
- handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
911
+ defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true })
912
+ // Registers at /hooks even with createApp({ resourcePrefix: '/api/v1' })
913
+ ```
1146
914
 
1147
- // Response envelope — no manual { success, data } wrapping
1148
- import { envelope } from '@classytic/arc';
1149
- reply.send(envelope(data, { total: 100 }));
915
+ ## Enterprise auth (2.13)
1150
916
 
1151
- // Canonical org extraction replaces 19 duplicated patterns
1152
- import { getOrgContext } from '@classytic/arc/scope';
1153
- const { userId, organizationId, roles, orgRoles } = getOrgContext(request);
917
+ Three opt-in surfaces close the procurement-gate gaps without forcing parallel infrastructure. Sessions / refresh / OAuth flows stay in Better Auth's hands.
1154
918
 
1155
- // Domain errors with auto HTTP status mapping
1156
- import { createDomainError } from '@classytic/arc';
1157
- throw createDomainError('SELF_REFERRAL', 'Cannot refer yourself', 422);
919
+ ### SCIM 2.0 IdP provisioning (`@classytic/arc/scim`)
1158
920
 
1159
- // Custom routes always `routes: [...]` with optional `raw: true` for non-JSON
1160
- defineResource({
1161
- routes: [{ method: 'GET', path: '/stats', handler: 'getStats', permissions: allowPublic() }],
1162
- });
921
+ Auto-derived `/scim/v2/Users` + `/scim/v2/Groups` from existing arc resources. Okta / Azure AD / Google Workspace / JumpCloud / OneLogin out of the box. No shadow tables.
1163
922
 
1164
- // SSE auth — preAuth runs BEFORE auth middleware (EventSource can't set headers)
1165
- routes: [{ preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] }]
923
+ ```typescript
924
+ import { scimPlugin } from '@classytic/arc/scim';
1166
925
 
1167
- // SSE streaming — raw: true + stream the response
1168
- routes: [{ method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) }]
926
+ await app.register(scimPlugin, {
927
+ users: { resource: userResource },
928
+ groups: { resource: orgResource },
929
+ bearer: process.env.SCIM_TOKEN, // or: verify: async (req) => …
930
+ });
1169
931
  ```
1170
932
 
1171
- ## DX Helpers (v2.7.3+)
933
+ Mounts `GET/POST/PUT/PATCH/DELETE /scim/v2/Users[/:id]`, same for `Groups`, plus `ServiceProviderConfig` / `ResourceTypes` / `Schemas` discovery. SCIM filter language → arc query DSL. RFC 7644 PatchOp translates to canonical operators (`$set`/`$unset`/`$push`/`$pull`) and flows through `repo.findOneAndUpdate(...)`; PUT goes through `repo.bulkWrite([{ replaceOne }])`. SCIM does **not** run arc's HTTP controller pipeline — audit / multi-tenant / field-policy compose at the kit-plugin layer (`repo.use(...)`) and fire identically for arc REST + SCIM because both surfaces hit the same repository methods. → [references/scim.md](references/scim.md).
1172
934
 
1173
- **Reply helpers**consistent response envelopes (opt-in via `createApp({ replyHelpers: true })`):
1174
-
1175
- ```typescript
1176
- return reply.ok({ name: 'MacBook' }); // → 200 { success: true, data: {...} }
1177
- return reply.ok(product, 201); // → 201 { success: true, data: {...} }
1178
- return reply.fail('Not found', 404); // → 404 { success: false, error: '...' }
1179
- return reply.fail(['err1', 'err2'], 422); // → 422 { success: false, errors: [...] }
1180
- return reply.paginated({ docs, total, page, limit });
1181
- return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
1182
- ```
935
+ ### Agent-auth helpers — DPoP + capability mandates
1183
936
 
1184
- **Error mappers** class-based domain error HTTP response (in `errorHandler` options):
937
+ For AI-agent flows on protected resources (AP2 / Stripe x402 / MCP authorization). Three new helpers in `@classytic/arc/permissions`:
1185
938
 
1186
939
  ```typescript
1187
- const app = await createApp({
1188
- errorHandler: {
1189
- errorMappers: [{
1190
- type: AccountingError,
1191
- toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
1192
- }],
940
+ import { requireAgentScope, requireMandate, requireDPoP } from '@classytic/arc/permissions';
941
+
942
+ defineResource({
943
+ name: 'invoice',
944
+ actions: {
945
+ pay: {
946
+ handler: payInvoice,
947
+ permissions: requireAgentScope({
948
+ capability: 'payment.charge',
949
+ scopes: ['payment.write'],
950
+ requireDPoP: true, // RFC 9449 sender-constrained
951
+ audience: (ctx) => `invoice:${ctx.params?.id}`, // mandate must bind to this resource
952
+ validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
953
+ }),
954
+ },
1193
955
  },
1194
956
  });
1195
- // Handlers just throw — Arc catches and maps automatically
1196
957
  ```
1197
958
 
1198
- **BigInt serialization** opt-in via `createApp({ serializeBigInt: true })`. Converts BigInt Number in all JSON responses.
959
+ `RequestScope.service` gains optional `mandate` + `dpopJkt` fields. Your `authenticate` callback verifies the mandate JWT (one `jose.jwtVerify()` call) + DPoP proof (one `jose.dpop.verify()` call) and populates them. Arc validates *what's already proved* against the action — no peer-deps on `jose`. → [references/agent-auth.md](references/agent-auth.md).
1199
960
 
1200
- **Multipart body middleware** — opt-in file upload for CRUD routes:
961
+ ### Auth-event audit bridge (`@classytic/arc/auth/audit`)
962
+
963
+ BA's `databaseHooks` + endpoint hooks routed through the existing `auditPlugin` — one canonical row shape for resource AND auth events. Single query for "everything user X did".
1201
964
 
1202
965
  ```typescript
1203
- import { multipartBody } from '@classytic/arc/middleware';
966
+ import { wireBetterAuthAudit } from '@classytic/arc/auth/audit';
1204
967
 
1205
- defineResource({
1206
- name: 'product',
1207
- adapter,
1208
- middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png', 'image/jpeg'], maxFileSize: 5 * 1024 * 1024 })] },
1209
- hooks: {
1210
- 'before:create': async (data) => {
1211
- if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
1212
- return data;
1213
- },
1214
- },
968
+ const audit = wireBetterAuthAudit({
969
+ events: ['session.*', 'user.*', 'mfa.*', 'org.invite.*'],
970
+ });
971
+
972
+ const auth = betterAuth({
973
+ hooks: audit.hooks, // endpoint hooks (MFA, OAuth, password reset)
974
+ databaseHooks: audit.databaseHooks, // sign-in/up/out via session.create/delete
975
+ // ...
1215
976
  });
977
+
978
+ const app = await createApp({ ... });
979
+ audit.attach(app); // drains boot-time buffer + connects live logger
1216
980
  ```
1217
981
 
1218
- `multipartBody()` is a no-op for JSON requests safe to always add.
982
+ Buffered until `attach(app)` is called works for hosts that build BA before Fastify. Manual `audit.emit({ name, subjectId, ... })` for non-BA flows (webhook signature failures, custom MFA).
983
+
984
+ ### What's NOT in arc 2.13
985
+
986
+ SAML / SCIM-EnterpriseUser / device trust / SOC2 attestations / session storage. Reasons + workarounds → [references/enterprise-auth.md](references/enterprise-auth.md). Compliance control matrix → [`docs/compliance/{soc2,hipaa}.md`](../../docs/compliance/).
1219
987
 
1220
- ## Subpath Imports
988
+ ## Subpath imports
989
+
990
+ The most common imports — full enumeration in [references/api-reference.md](references/api-reference.md).
1221
991
 
1222
992
  ```typescript
1223
993
  import { defineResource, BaseController, allowPublic } from '@classytic/arc';
1224
- import { createApp } from '@classytic/arc/factory';
1225
- import { MemoryCacheStore, RedisCacheStore, QueryCache } from '@classytic/arc/cache';
1226
- import { createBetterAuthAdapter, extractBetterAuthOpenApi } from '@classytic/arc/auth';
1227
- // Optional Mongoose stub-models bridge for `populate()` against Better Auth
1228
- // collections subpath gate keeps Mongoose out of Prisma/Drizzle/Kysely bundles.
1229
- import { registerBetterAuthMongooseModels } from '@classytic/arc/auth/mongoose';
1230
- import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
1231
- import { eventPlugin } from '@classytic/arc/events';
1232
- import { RedisEventTransport } from '@classytic/arc/events/redis';
1233
- import { healthPlugin, gracefulShutdownPlugin } from '@classytic/arc/plugins';
1234
- import { tracingPlugin } from '@classytic/arc/plugins/tracing';
1235
- import { auditPlugin } from '@classytic/arc/audit';
1236
- import { idempotencyPlugin } from '@classytic/arc/idempotency';
1237
- import { ssePlugin } from '@classytic/arc/plugins';
1238
- import { jobsPlugin } from '@classytic/arc/integrations/jobs';
1239
- import { websocketPlugin } from '@classytic/arc/integrations/websocket';
1240
- import { eventGatewayPlugin } from '@classytic/arc/integrations/event-gateway';
1241
- import { createHookSystem } from '@classytic/arc/hooks';
1242
- import { createTestApp } from '@classytic/arc/testing';
1243
- import { Type, ArcListResponse } from '@classytic/arc/schemas';
1244
- import { createStateMachine, CircuitBreaker, withCompensation, defineCompensation } from '@classytic/arc/utils';
1245
- import { defineMigration } from '@classytic/arc/migrations';
1246
- // Scope accessors
1247
- import {
1248
- // Type guards
1249
- isMember, isService, isElevated, isAuthenticated, hasOrgAccess,
1250
- // Identity / org accessors
1251
- getUserId, getUserRoles, getOrgId, getOrgRoles, getTeamId, getClientId,
1252
- // Service scopes (OAuth-style strings on API keys)
1253
- getServiceScopes,
1254
- // App-defined scope dimensions (branch, project, region, …)
1255
- getScopeContext, getScopeContextMap,
1256
- // Parent-child org hierarchy
1257
- getAncestorOrgIds, isOrgInScope,
1258
- // Generic request-side helper
1259
- getRequestScope,
1260
- } from '@classytic/arc/scope';
1261
- import { createTenantKeyGenerator } from '@classytic/arc/scope';
1262
- import { createRoleHierarchy } from '@classytic/arc/permissions';
1263
- import { metricsPlugin, versioningPlugin } from '@classytic/arc/plugins';
1264
- import { webhookPlugin } from '@classytic/arc/integrations/webhooks';
1265
- import { mcpPlugin, createMcpServer, defineTool, definePrompt, fieldRulesToZod, resourceToTools } from '@classytic/arc/mcp';
1266
- import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
1267
- import { bulkPreset, multiTenantPreset, type TenantFieldSpec } from '@classytic/arc/presets';
994
+ import { createApp, loadResources } from '@classytic/arc/factory';
995
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter'; // or sqlitekit/adapter, prismakit/adapter
996
+ import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter';
997
+ import { getUserId, getOrgId, requireOrgId } from '@classytic/arc/scope';
998
+ import { mcpPlugin } from '@classytic/arc/mcp';
999
+ import { createTestApp, expectArc } from '@classytic/arc/testing';
1268
1000
  ```
1269
1001
 
1270
- ## References (Progressive Disclosure)
1002
+ ## References
1271
1003
 
1272
- - **[auth](references/auth.md)** — JWT, Better Auth, API key auth, custom auth, multi-tenant
1273
- - **[events](references/events.md)** — Domain events, transports, retry, outbox pattern, auto-emission
1004
+ - **[api-reference](references/api-reference.md)** — full subpath import map (every plugin, helper, type)
1005
+ - **[auth](references/auth.md)** — JWT, Better Auth, API key, custom auth
1006
+ - **[scim](references/scim.md)** — SCIM 2.0 plugin (Okta / Azure AD / Google Workspace provisioning)
1007
+ - **[agent-auth](references/agent-auth.md)** — DPoP + capability mandates (AP2 / x402 / MCP authorization)
1008
+ - **[enterprise-auth](references/enterprise-auth.md)** — what's in vs out of the box for enterprise auth
1009
+ - **[events](references/events.md)** — Domain events, transports, retry, outbox
1274
1010
  - **[integrations](references/integrations.md)** — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
1275
- - **[mcp](references/mcp.md)** — MCP tools for AI agents, auto-generation from resources, custom tools, Better Auth OAuth 2.1
1276
- - **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField` read sites, `PermissionResult.scope`, API key auth without a separate auth plugin
1277
- - **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops, saga, RPC schema versioning, tenant rate limiting
1011
+ - **[mcp](references/mcp.md)** — MCP tools, custom tools, Better Auth OAuth 2.1
1012
+ - **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField`, `PermissionResult.scope`, API key auth
1013
+ - **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops
1278
1014
  - **[testing](references/testing.md)** — Test app, mocks, data factories, in-memory MongoDB