@classytic/arc 2.15.4 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +1 -0
  2. package/bin/arc.js +12 -0
  3. package/dist/{BaseController-dx3m2J8V.mjs → BaseController-DlCCTIxJ.mjs} +61 -19
  4. package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
  5. package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-SvmT_9ti.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-f48hFk3m.mjs} +52 -9
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +4 -2
  9. package/dist/auth/index.d.mts +4 -4
  10. package/dist/auth/index.mjs +4 -4
  11. package/dist/auth/redis-session.d.mts +1 -1
  12. package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
  13. package/dist/buildHandler-BZX6zzDM.mjs +300 -0
  14. package/dist/cache/index.d.mts +3 -3
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/{caching-SM8gghN6.mjs → caching-TeHE8G-v.mjs} +1 -1
  17. package/dist/cli/commands/describe.d.mts +35 -1
  18. package/dist/cli/commands/describe.mjs +52 -12
  19. package/dist/cli/commands/docs.d.mts +1 -4
  20. package/dist/cli/commands/docs.mjs +4 -16
  21. package/dist/cli/commands/generate.d.mts +2 -20
  22. package/dist/cli/commands/generate.mjs +1 -546
  23. package/dist/cli/commands/init.d.mts +2 -40
  24. package/dist/cli/commands/init.mjs +1 -3045
  25. package/dist/cli/commands/introspect.mjs +53 -64
  26. package/dist/cli/index.d.mts +2 -2
  27. package/dist/cli/index.mjs +2 -2
  28. package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
  29. package/dist/core/index.d.mts +3 -3
  30. package/dist/core/index.mjs +5 -5
  31. package/dist/{core-CvmOqEms.mjs → core-DBJ_j6rX.mjs} +222 -44
  32. package/dist/createActionRouter-DUpN3Dd1.mjs +288 -0
  33. package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-Dq-TUCuY.mjs} +3 -2
  34. package/dist/{createApp-PFegs47-.mjs → createApp-DNccuhyI.mjs} +16 -14
  35. package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-DRwY0fYm.mjs} +1 -1
  36. package/dist/docs/index.d.mts +2 -2
  37. package/dist/docs/index.mjs +1 -1
  38. package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DpoXQHZ9.mjs} +17 -14
  39. package/dist/errors-C1lX_jlm.d.mts +91 -0
  40. package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-C2cGqtRO.mjs} +1 -1
  41. package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-CtHC_av1.d.mts} +1 -1
  42. package/dist/events/index.d.mts +3 -3
  43. package/dist/events/index.mjs +5 -5
  44. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +1 -1
  47. package/dist/factory/index.mjs +2 -2
  48. package/dist/{fields-COhcH3fk.d.mts → fields-Anj0xdih.d.mts} +1 -1
  49. package/dist/generate-BWFwgcCM.d.mts +38 -0
  50. package/dist/generate-CYac-OLv.mjs +654 -0
  51. package/dist/hooks/index.d.mts +1 -1
  52. package/dist/hooks/index.mjs +1 -1
  53. package/dist/idempotency/index.d.mts +2 -2
  54. package/dist/idempotency/index.mjs +1 -1
  55. package/dist/idempotency/redis.d.mts +1 -1
  56. package/dist/{index-BTqLEvhu.d.mts → index-3oIimXQn.d.mts} +12 -12
  57. package/dist/{index-BstGxcc3.d.mts → index-B-ulKx5P.d.mts} +55 -4
  58. package/dist/{index-BswOSJCE.d.mts → index-CkW0flkU.d.mts} +355 -16
  59. package/dist/index.d.mts +6 -6
  60. package/dist/index.mjs +7 -8
  61. package/dist/init-Dv71MsJr.d.mts +71 -0
  62. package/dist/init-HDvoO9L5.mjs +3098 -0
  63. package/dist/integrations/event-gateway.d.mts +2 -2
  64. package/dist/integrations/event-gateway.mjs +1 -1
  65. package/dist/integrations/index.d.mts +2 -2
  66. package/dist/integrations/jobs.mjs +3 -3
  67. package/dist/integrations/mcp/index.d.mts +239 -7
  68. package/dist/integrations/mcp/index.mjs +2 -528
  69. package/dist/integrations/mcp/testing.d.mts +2 -2
  70. package/dist/integrations/mcp/testing.mjs +6 -10
  71. package/dist/integrations/streamline.mjs +26 -1
  72. package/dist/integrations/websocket-redis.d.mts +1 -1
  73. package/dist/integrations/websocket.d.mts +1 -1
  74. package/dist/integrations/websocket.mjs +1 -0
  75. package/dist/loadResourcesFromEntry-BLMEI2Xa.mjs +51 -0
  76. package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-7vGV51ED.mjs} +1021 -318
  77. package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
  78. package/dist/middleware/index.d.mts +1 -1
  79. package/dist/middleware/index.mjs +1 -1
  80. package/dist/{openapi-BHXhoX8O.mjs → openapi-34T9yNwd.mjs} +47 -36
  81. package/dist/permissions/index.d.mts +2 -2
  82. package/dist/permissions/index.mjs +1 -1
  83. package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
  84. package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
  85. package/dist/pipeline/index.d.mts +1 -1
  86. package/dist/pipeline/index.mjs +1 -1
  87. package/dist/plugins/index.d.mts +5 -5
  88. package/dist/plugins/index.mjs +10 -10
  89. package/dist/plugins/response-cache.mjs +5 -5
  90. package/dist/plugins/tracing-entry.d.mts +1 -1
  91. package/dist/plugins/tracing-entry.mjs +1 -1
  92. package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
  93. package/dist/presets/filesUpload.d.mts +4 -4
  94. package/dist/presets/filesUpload.mjs +2 -2
  95. package/dist/presets/index.d.mts +1 -1
  96. package/dist/presets/index.mjs +1 -1
  97. package/dist/presets/multiTenant.d.mts +1 -1
  98. package/dist/presets/multiTenant.mjs +4 -3
  99. package/dist/presets/search.d.mts +2 -2
  100. package/dist/presets/search.mjs +1 -1
  101. package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
  102. package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
  103. package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Biqzfbi5.d.mts} +2 -2
  104. package/dist/{redis-DiMkdHEl.d.mts → redis-Cyzrz6SX.d.mts} +1 -1
  105. package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-DT-YjzrB.d.mts} +1 -1
  106. package/dist/registry/index.d.mts +319 -2
  107. package/dist/registry/index.mjs +3 -3
  108. package/dist/registry-BBE23CDj.mjs +576 -0
  109. package/dist/{routerShared-DrOa-26E.mjs → routerShared-CZV5aabX.mjs} +3 -3
  110. package/dist/scope/index.d.mts +3 -3
  111. package/dist/scope/index.mjs +3 -3
  112. package/dist/{sse-Bz-5ZeTt.mjs → sse-BY6sTy4P.mjs} +1 -1
  113. package/dist/testing/index.d.mts +2 -2
  114. package/dist/testing/index.mjs +16 -7
  115. package/dist/testing/storageContract.d.mts +1 -1
  116. package/dist/types/index.d.mts +5 -5
  117. package/dist/types/storage.d.mts +1 -1
  118. package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
  119. package/dist/{types-BQsjgQzS.d.mts → types-BsJMEQ4D.d.mts} +106 -12
  120. package/dist/{types-DrBaUwyV.d.mts → types-D-fYtKjb.d.mts} +33 -10
  121. package/dist/{types-CTYvcwHe.d.mts → types-DVfpSfx2.d.mts} +42 -1
  122. package/dist/utils/index.d.mts +1286 -2
  123. package/dist/utils/index.mjs +1 -1
  124. package/dist/{utils-_h9B3c57.mjs → utils-DC5ycPfr.mjs} +89 -40
  125. package/dist/{buildHandler-CcFOpJLh.mjs → validate-By96rH0r.mjs} +8 -299
  126. package/dist/{versioning-hmkPcDlX.d.mts → versioning-ZwX9tmbS.d.mts} +1 -1
  127. package/package.json +21 -28
  128. package/skills/arc/SKILL.md +300 -706
  129. package/skills/arc/references/auth.md +19 -7
  130. package/skills/arc-code-review/SKILL.md +1 -1
  131. package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
  132. package/dist/createActionRouter-S3MLVYot.mjs +0 -220
  133. package/dist/index-bRjYu21O.d.mts +0 -1320
  134. package/dist/org/index.d.mts +0 -66
  135. package/dist/org/index.mjs +0 -486
  136. package/dist/org/types.d.mts +0 -82
  137. package/dist/org/types.mjs +0 -1
  138. package/dist/registry-I-ogLgL9.mjs +0 -46
  139. /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-C-2oAHtw.d.mts} +0 -0
  140. /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-Hxvv5QQz.mjs} +0 -0
  141. /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
  142. /package/dist/{elevation-BXOWoGCF.d.mts → elevation-0YBpa663.d.mts} +0 -0
  143. /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
  144. /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-mHuyWzZE.d.mts} +0 -0
  145. /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-DFg-2KTp.d.mts} +0 -0
  146. /package/dist/{interface-beEtJyWM.d.mts → interface-CH0OQudo.d.mts} +0 -0
  147. /package/dist/{interface-DfLGcus7.d.mts → interface-NwJ_qPlY.d.mts} +0 -0
  148. /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
  149. /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-ChQEj8ih.mjs} +0 -0
  150. /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-TuOmguhi.mjs} +0 -0
  151. /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-C-gD32oF.mjs} +0 -0
  152. /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-Ctc89DSn.mjs} +0 -0
  153. /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-BqFegc0W.d.mts} +0 -0
  154. /package/dist/{storage-Dfzt4VTl.d.mts → storage-D2KZJAmn.d.mts} +0 -0
  155. /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-B0sunfZZ.mjs} +0 -0
  156. /package/dist/{tracing-QJVprktp.d.mts → tracing-Dm8n7Cnn.d.mts} +0 -0
  157. /package/dist/{versioning-BUrT5aP4.mjs → versioning-B6mimogM.mjs} +0 -0
  158. /package/dist/{websocket-ChC2rqe1.d.mts → websocket-BkjeGZRn.d.mts} +0 -0
@@ -7,7 +7,7 @@ description: |
7
7
  multi-tenant SaaS, OpenAPI, job queues, WebSocket, MCP tools, or production deployment.
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
- arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
10
+ arc permissions, arc hooks, arc factory, arc cache.
11
11
  license: MIT
12
12
  metadata:
13
13
  author: Classytic
@@ -23,49 +23,42 @@ tags:
23
23
  - cache
24
24
  - events
25
25
  - openapi
26
- progressive_disclosure:
27
- entry_point:
28
- summary: "Resource-oriented Fastify framework: defineResource(), presets, permissions, QueryCache, events, multi-tenant, OpenAPI, MCP"
29
- when_to_use: "Building REST APIs with Fastify, resource CRUD, authentication, presets, caching, events, or production deployment"
30
- quick_start: "1. npx @classytic/arc init my-api --mongokit --better-auth --single --ts 2. defineResource({ name, adapter, presets, permissions }) 3. createApp({ preset: 'production', resources, auth })"
31
26
  ---
32
27
 
33
28
  # @classytic/arc
34
29
 
35
- Resource-oriented backend framework for Fastify. **Fastify ≥5.8.5 · Node ≥22 · ESM only.**
30
+ Resource-oriented backend framework for Fastify. **Fastify ≥5.8 · Node ≥22 · ESM only.**
36
31
 
37
- One `defineResource()` call → REST + auth + permissions + events + cache + OpenAPI + MCP. Database-agnostic (Mongoose, Drizzle/sqlitekit, Prisma, custom).
32
+ One `defineResource()` call → REST + auth + permissions + events + cache + OpenAPI + MCP. Database-agnostic (Mongoose, Drizzle/sqlite, Prisma, custom).
38
33
 
39
- ## Scaffold a project
34
+ ## Install
40
35
 
41
36
  ```bash
37
+ # Scaffold
42
38
  npx @classytic/arc@latest init my-api --mongokit --better-auth --single --ts
43
- cd my-api && npm install && npm run dev
39
+
40
+ # Or add to an existing project
41
+ npm install @classytic/arc fastify
42
+ npm install @fastify/cors @fastify/helmet @fastify/rate-limit @fastify/sensible @fastify/under-pressure
43
+ npm install @classytic/mongokit mongoose # or sqlitekit / prismakit / custom
44
44
  ```
45
45
 
46
- Flags: `--mongokit | --custom`, `--better-auth | --jwt`, `--single | --multi`, `--ts | --js`, `--edge`, `--force`, `--skip-install`. Defaults: `--mongokit --better-auth --single --ts`. The scaffold seeds full `dependencies` + `devDependencies` so `npm install` works without the CLI's pre-pass.
46
+ `init` flags: `--mongokit | --custom`, `--better-auth | --jwt`, `--single | --multi`, `--ts | --js`, `--edge`, `--force`, `--skip-install`.
47
47
 
48
- ## createApp()
48
+ ## Boot
49
49
 
50
50
  ```typescript
51
- import { createApp } from '@classytic/arc/factory';
51
+ import { createApp, loadResources } from '@classytic/arc/factory';
52
52
 
53
53
  const app = await createApp({
54
- preset: 'production', // production | development | testing | edge
55
- runtime: 'memory', // memory (default) | distributed
54
+ preset: 'production', // production | development | testing | edge
55
+ runtime: 'memory', // memory | distributed
56
+ resourcePrefix: '/api/v1',
56
57
  auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
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'
58
+ cors: { origin: process.env.ALLOWED_ORIGINS!.split(','), credentials: true },
59
+ resources: await loadResources(import.meta.url), // auto-discovers *.resource.ts
60
+ arcPlugins: { events: true, queryCache: false, sse: false, caching: true },
61
+ stores: { // required when runtime: 'distributed'
69
62
  events: new RedisEventTransport({ client: redis }),
70
63
  queryCache: new RedisCacheStore({ client: redis }),
71
64
  },
@@ -74,45 +67,35 @@ const app = await createApp({
74
67
  await app.listen({ port: 8040, host: '0.0.0.0' });
75
68
  ```
76
69
 
77
- **Boot sequence:** `plugins` → `bootstrap[]` → `resources` (factory form runs here) → `afterResources` → `onReady`.
70
+ **Boot order (fixed):** `plugins` → `bootstrap[]` → `resources` → `afterResources` → `onReady`.
78
71
 
79
- **Async-booted engines** use the factory form for `resources` so it runs after `bootstrap[]`:
72
+ For async-booted engines, pass `resources` as a factory so it runs after `bootstrap[]`:
80
73
 
81
74
  ```typescript
82
75
  resources: async (fastify) => {
83
76
  const engine = await ensureCatalogEngine();
84
- return [buildProductResource(engine), buildCategoryResource(engine)];
77
+ return [buildProductResource(engine)];
85
78
  }
86
79
  ```
87
80
 
88
- **Auto-discover resources:**
89
-
90
- ```typescript
91
- import { loadResources } from '@classytic/arc/factory';
92
-
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
- ```
96
-
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).
81
+ `loadResources(import.meta.url)` resolves `src/` in dev and `dist/` in prod. Use `loadResources(import.meta.url, { context: { engine } })` to thread engine handles into `(ctx) => defineResource(...)` default exports.
98
82
 
99
83
  ## defineResource()
100
84
 
101
85
  ```typescript
102
- import { defineResource, allowPublic, requireRoles } from '@classytic/arc';
86
+ import { defineResource, allowPublic, requireRoles, requireAuth } from '@classytic/arc';
103
87
  import { createMongooseAdapter } from '@classytic/mongokit/adapter';
104
88
  import { buildCrudSchemasFromModel } from '@classytic/mongokit';
105
89
 
106
- const productResource = defineResource({
90
+ export default defineResource({
107
91
  name: 'product',
108
92
  adapter: createMongooseAdapter({
109
93
  model: ProductModel,
110
94
  repository: productRepo,
111
- schemaGenerator: buildCrudSchemasFromModel, // required (no built-in fallback)
95
+ schemaGenerator: buildCrudSchemasFromModel,
112
96
  }),
113
- controller: productController, // optional — auto-built if omitted
114
97
 
115
- presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'orgId' }],
98
+ presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'organizationId' }],
116
99
 
117
100
  permissions: {
118
101
  list: allowPublic(),
@@ -122,109 +105,70 @@ const productResource = defineResource({
122
105
  delete: requireRoles(['admin']),
123
106
  },
124
107
 
125
- cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
126
-
127
- routeGuards: [orgGuard.preHandler], // applied to ALL routes (CRUD + custom + preset)
128
-
129
108
  schemaOptions: {
130
109
  fieldRules: {
131
110
  name: { minLength: 2, maxLength: 200 },
132
111
  sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
133
112
  status: { enum: ['draft', 'active', 'archived'] },
134
- deletedAt: { systemManaged: true }, // arc stamps it; strip from body + required[]
135
- priceMode: { nullable: true }, // widen JSON-Schema type to accept null
113
+ deletedAt: { systemManaged: true }, // framework stamps; strip from body
114
+ priceMode: { nullable: true }, // accept null
115
+ organizationId: { systemManaged: true, preserveForElevated: true },
116
+ },
117
+ query: {
118
+ allowedPopulate: ['category', 'createdBy'],
119
+ filterableFields: { status: { type: 'string' } },
136
120
  },
137
121
  },
138
122
 
123
+ cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
124
+
139
125
  routes: [
140
- { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
141
- { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: requireAuth() },
126
+ { method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
127
+ { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: requireAuth() },
142
128
  ],
143
-
144
- actions: { // single POST /:id/action endpoint, discriminated on `action`
145
- approve: async (id, data, req) => service.approve(id, req.user._id),
146
- cancel: {
147
- handler: async (id, data, req) => service.cancel(id, data.reason, req.user._id),
148
- permissions: requireRoles('admin'),
149
- schema: { reason: { type: 'string' } },
150
- },
129
+ actions: {
130
+ approve: { handler: approveOrder, permissions: requireRoles(['admin']) },
151
131
  },
152
- actionPermissions: requireAuth(), // fallback gate for actions without per-action perm
153
132
  });
154
133
  ```
155
134
 
156
- **Generated routes:** `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id`. Presets add `/deleted` + `/:id/restore` (softDelete), `/slug/:slug` (slugLookup), etc.
157
-
158
- ## Active behavior to know about
159
-
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.
168
-
169
- ## Authentication
135
+ **Auto-generated:** `GET /`, `GET /:id`, `POST /`, `PATCH /:id`, `DELETE /:id`. Presets add their own routes (see table below).
170
136
 
171
- Discriminated union on `type`:
137
+ ## fieldRules
172
138
 
173
- ```typescript
174
- // Arc JWT (with optional revocation + custom token extractor)
175
- auth: {
176
- type: 'jwt',
177
- jwt: { secret, expiresIn: '15m', refreshSecret, refreshExpiresIn: '7d' },
178
- tokenExtractor: (req) => req.cookies?.['auth-token'] ?? null,
179
- isRevoked: async (decoded) => redis.sismember('revoked', decoded.jti),
180
- }
181
-
182
- // Better Auth (recommended for SaaS with orgs)
183
- import { createBetterAuthAdapter } from '@classytic/arc/auth';
184
- auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth, orgContext: true }) }
185
-
186
- // Custom Fastify plugin / function
187
- auth: { type: 'custom', plugin: myAuthPlugin }
188
- auth: { type: 'authenticator', authenticate: async (req, reply) => { ... } }
139
+ | Flag | Effect |
140
+ |---|---|
141
+ | `systemManaged` | Strip from body, drop from `required[]`. Framework stamps the value. |
142
+ | `preserveForElevated` | Elevated admins keep the field on ingest (cross-tenant writes). |
143
+ | `immutable` / `immutableAfterCreate` | Omit from update body. |
144
+ | `optional` | Strip from `required[]` without touching `properties`. |
145
+ | `nullable` | Widen JSON-Schema `type` to include null. |
146
+ | `hidden` | Block from response projection + OpenAPI. |
147
+ | `minLength` · `maxLength` · `min` · `max` · `pattern` · `enum` · `description` | Map to AJV + OpenAPI. |
189
148
 
190
- // Disabled
191
- auth: false
192
- ```
149
+ Mongoose model constraints take precedence; `fieldRules` supplements what the model doesn't declare.
193
150
 
194
- Decorates `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`.
151
+ ## Presets
195
152
 
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.
153
+ | Preset | Routes added | Config |
154
+ |---|---|---|
155
+ | `softDelete` | `GET /deleted`, `POST /:id/restore` | `{ deletedField }` |
156
+ | `slugLookup` | `GET /slug/:slug` | `{ slugField }` |
157
+ | `tree` | `GET /tree`, `GET /:parent/children` | `{ parentField }` |
158
+ | `ownedByUser` | (middleware only) | `{ ownerField }` |
159
+ | `multiTenant` | (middleware only) | `{ tenantField }` or `{ tenantFields: [...] }` |
160
+ | `audited` | (middleware only) | — |
161
+ | `bulk` | `POST/PATCH/DELETE /bulk` | `{ operations?, maxCreateItems? }` |
162
+ | `filesUpload` | `POST /upload`, `GET /:id`, `DELETE /:id` | `{ storage, sanitizeFilename?, allowedMimeTypes?, maxFileSize? }` |
163
+ | `search` | `POST /search`, `/search-similar`, `/embed` | `{ repository?, search?, similar?, embed? }` |
197
164
 
198
165
  ```typescript
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 })],
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.
166
+ presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
219
167
  ```
220
168
 
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.
224
-
225
- **Bearer plugin** (`bearer()` from `better-auth/plugins`): SPA / mobile clients use `Authorization: Bearer <session>` instead of cookies — same `auth.api.getSession()` path, no arc config change. Enable both for hybrid apps.
169
+ **`tenantField: false`** disables the silent org filter use it for lookup tables, platform settings, cross-org reports.
226
170
 
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/).
171
+ **`idField`** picks the lookup column for `:id` (default `_id`). URL segment is always `:id`. A 404 on `PATCH /agents/foo` when `GET` works is usually a permission `filters` clause excluding the row not an `idField` bug.
228
172
 
229
173
  ## Permissions
230
174
 
@@ -234,20 +178,16 @@ A `PermissionCheck` returns `boolean | { granted, reason?, filters?, scope? }`.
234
178
  import {
235
179
  allowPublic, requireAuth, requireRoles, requireOwnership,
236
180
  requireOrgMembership, requireOrgRole, requireTeamMembership,
237
- requireServiceScope, // OAuth-style API-key scopes
238
- requireScopeContext, // app-defined dimensions (branch, project, region)
239
- requireOrgInScope, // parent-child org hierarchy
181
+ requireServiceScope, requireScopeContext, requireOrgInScope,
240
182
  allOf, anyOf, when, denyAll,
241
- createDynamicPermissionMatrix, // DB-managed ACL
242
- } from '@classytic/arc';
183
+ createDynamicPermissionMatrix,
184
+ } from '@classytic/arc/permissions';
243
185
 
244
186
  permissions: {
245
- list: allowPublic(),
187
+ list: allowPublic(),
246
188
  create: requireRoles(['admin', 'editor']),
247
189
  update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
248
190
  delete: allOf(requireAuth(), requireRoles(['admin'])),
249
-
250
- // Mixed human + machine
251
191
  bulkImport: anyOf(requireOrgRole('admin'), requireServiceScope('jobs:bulk-write')),
252
192
  }
253
193
  ```
@@ -264,12 +204,12 @@ const requirePro = (): PermissionCheck => async (ctx) => {
264
204
  **Field-level:**
265
205
 
266
206
  ```typescript
267
- import { fields } from '@classytic/arc';
207
+ import { fields } from '@classytic/arc/permissions';
268
208
  fields: {
269
209
  password: fields.hidden(),
270
- salary: fields.visibleTo(['admin', 'hr']),
271
- role: fields.writableBy(['admin']),
272
- email: fields.redactFor(['viewer'], '***'),
210
+ salary: fields.visibleTo(['admin', 'hr']),
211
+ role: fields.writableBy(['admin']),
212
+ email: fields.redactFor(['viewer'], '***'),
273
213
  }
274
214
  ```
275
215
 
@@ -277,116 +217,79 @@ fields: {
277
217
 
278
218
  ```typescript
279
219
  const acl = createDynamicPermissionMatrix({
280
- resolveRolePermissions: async (ctx) => aclService.getRoleMatrix(ctx.user.orgId),
220
+ resolveRolePermissions: (ctx) => aclService.getRoleMatrix(ctx.user.orgId),
281
221
  cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
282
222
  });
283
223
  permissions: { list: acl.canAction('product', 'read') }
284
224
  ```
285
225
 
286
- `requireRoles()` checks BOTH platform roles (`user.role`) and org roles (`scope.orgRoles`). `requireOrgRole()` is human-only use `anyOf(requireOrgRole(...), requireServiceScope(...))` for mixed routes.
226
+ `requireRoles()` checks BOTH platform roles (`user.role`) and org roles (`scope.orgRoles`). For mixed human + machine routes, combine with `requireServiceScope(...)` via `anyOf(...)`.
287
227
 
288
- ### RequestScope — the auth context
228
+ ## RequestScope
289
229
 
290
- Five kinds, populated by your auth function. **Always read via accessors from `@classytic/arc/scope`, never direct property access.**
230
+ Five kinds, populated by your auth function. **Always read via accessors from `@classytic/arc/scope`** never direct property access.
291
231
 
292
232
  ```typescript
293
233
  type RequestScope =
294
234
  | { kind: 'public' }
295
235
  | { kind: 'authenticated'; userId?; userRoles? }
296
- | { kind: 'member'; userId?; userRoles; organizationId; orgRoles; teamId?; context?; ancestorOrgIds? }
297
- | { kind: 'service'; clientId; organizationId; scopes?; context?; ancestorOrgIds? }
298
- | { kind: 'elevated'; userId?; organizationId?; elevatedBy; context?; ancestorOrgIds? };
299
- ```
300
-
301
- | Helper | `member` | `service` | `elevated` |
302
- |---|---|---|---|
303
- | `requireOrgMembership()` | ✅ | ✅ | ✅ |
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 |
236
+ | { kind: 'member'; userId?; userRoles; organizationId; orgRoles; teamId?; context?; ancestorOrgIds? }
237
+ | { kind: 'service'; clientId; organizationId; scopes?; context?; ancestorOrgIds?; mandate?; dpopJkt? }
238
+ | { kind: 'elevated'; userId?; organizationId?; elevatedBy; context?; ancestorOrgIds? };
309
239
 
310
- ```typescript
311
240
  import {
312
241
  isMember, isService, isElevated, hasOrgAccess,
313
242
  getOrgId, getUserId, getUserRoles, getOrgRoles, getServiceScopes,
314
243
  getScopeContext, getAncestorOrgIds, isOrgInScope,
244
+ requireOrgId, requireUserId, // throwing accessors for handler boundaries
315
245
  } from '@classytic/arc/scope';
316
-
317
- if (hasOrgAccess(scope)) // member | service | elevated
318
- const branch = getScopeContext(scope, 'branchId');
319
- isOrgInScope(scope, 'acme-holding'); // pure predicate (no elevated bypass)
320
246
  ```
321
247
 
322
- ### Multi-level tenancy + parent-child org hierarchy
248
+ | Helper | `member` | `service` | `elevated` |
249
+ |---|---|---|---|
250
+ | `requireOrgMembership()` | ✅ | ✅ | ✅ |
251
+ | `requireOrgRole(roles)` | role match | ❌ | bypass |
252
+ | `requireServiceScope(scopes)` | ❌ | scope match | bypass |
253
+ | `requireTeamMembership()` | `teamId` set | n/a | bypass |
254
+ | `requireOrgInScope(target)` | target in chain | target in chain | bypass |
323
255
 
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):
256
+ Populate `scope.context` / `scope.ancestorOrgIds` in your auth function for branch/region scoping and parent-child org chains. Then `multiTenantPreset({ tenantFields: [...] })` auto-filters by every dimension. → [references/multi-tenancy.md](references/multi-tenancy.md)
325
257
 
326
- ```typescript
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
- };
339
- }
258
+ ## Authentication
340
259
 
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
- }
260
+ Discriminated union on `type`:
347
261
 
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
- })],
358
- });
262
+ ```typescript
263
+ auth: { type: 'jwt', jwt: { secret, expiresIn: '15m' } } // arc JWT
264
+ auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth }) } // Better Auth
265
+ auth: { type: 'custom', plugin: myAuthPlugin } // any Fastify plugin
266
+ auth: { type: 'authenticator', authenticate: async (req, reply) => { … } } // ad-hoc fn
267
+ auth: false // internal services
359
268
  ```
360
269
 
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.
270
+ Decorates `app.authenticate` / `app.optionalAuthenticate` / `app.authorize`.
362
271
 
363
- Full multi-tenancy guide → [references/multi-tenancy.md](references/multi-tenancy.md).
272
+ **Better Auth** is arc's recommended path for SaaS with orgs. Kit overlays read whatever BA plugins you enabled (`organization`, `twoFactor`, `admin`, `bearer`, `apiKey` from `@better-auth/api-key`). Bulk-stub populate models with `registerBetterAuthStubs(mongoose, { plugins, extraCollections })`; per-resource overlay with `createBetterAuthOverlay({ auth, mongoose, collection })`. Sqlitekit is symmetric. Full recipes (multi-plugin matrix, API-key flow, write path) → [references/auth.md](references/auth.md).
364
273
 
365
- ## fieldRules — OpenAPI + AJV in one place
274
+ ## Authoritative gotchas
366
275
 
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`. |
377
-
378
- Mongoose model-level constraints take precedence; `fieldRules` supplements what the model doesn't declare.
276
+ - **Field-write reject (default).** Requests carrying non-writable fields → 403 with denied list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
277
+ - **multiTenant injects org on UPDATE.** Body `organizationId` is overwritten with caller's scope (closes tenant-hop).
278
+ - **`request.user`** is `Record<string, unknown> | undefined`. Guard with `if (req.user)` on public routes.
279
+ - **Arc reads singular `user.role`** (string, comma-separated, or array). Don't put a plural `roles` field on the model.
280
+ - **`verifySignature(body, …)`** throws on parsed body pass `req.rawBody`.
281
+ - **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.
282
+ - **Better Auth roles.** BA stores `member.role = "admin,recruiter"` (comma-separated). Arc splits into `scope.orgRoles = ['admin', 'recruiter']`; `?role=admin` won't match use `role[like]=admin`.
379
283
 
380
284
  ## routeGuards + defineGuard
381
285
 
382
- Apply guards to **every** route on a resource:
286
+ Apply guards to **every** route on a resource (CRUD + custom + preset):
383
287
 
384
288
  ```typescript
385
289
  import { defineGuard } from '@classytic/arc/utils';
386
290
 
387
- // Typed guard — resolve once, extract anywhere
388
- const orgGuard = defineGuard({
389
- name: 'org',
291
+ const tenantGuard = defineGuard({
292
+ name: 'tenant',
390
293
  resolve: (req) => {
391
294
  const orgId = req.headers['x-org-id'] as string;
392
295
  if (!orgId) throw new Error('Missing x-org-id');
@@ -396,11 +299,11 @@ const orgGuard = defineGuard({
396
299
 
397
300
  defineResource({
398
301
  name: 'procurement',
399
- routeGuards: [orgGuard.preHandler],
302
+ routeGuards: [tenantGuard.preHandler],
400
303
  routes: [{
401
304
  method: 'GET', path: '/summary', raw: true, permissions: requireAuth(),
402
305
  handler: async (req, reply) => {
403
- const { orgId } = orgGuard.from(req); // typed, no re-computation
306
+ const { orgId } = tenantGuard.from(req);
404
307
  reply.send({ orgId });
405
308
  },
406
309
  }],
@@ -409,243 +312,36 @@ defineResource({
409
312
 
410
313
  **Order:** auth → permissions → cache/idempotency → `routeGuards` → per-route `preHandler`.
411
314
 
412
- ## Presets
413
-
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? }` |
425
-
426
- ```typescript
427
- presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
428
- ```
429
-
430
- ### tenantField — when to use, when to disable
431
-
432
- Default `'organizationId'` silently scopes queries to the caller's org. Correct for per-org resources, **wrong** for company-wide:
433
-
434
- ```typescript
435
- defineResource({ name: 'invoice' }); // → { organizationId: scope.orgId }
436
- defineResource({ name: 'account-type', tenantField: false }); // company-wide lookup
437
- defineResource({ name: 'workspace-item', tenantField: 'workspaceId' });
438
- ```
439
-
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.
441
-
442
- ### idField — custom primary key
443
-
444
- Default `'_id'`. Override for business identifiers (UUID, slug, `ORD-2026-0001`):
445
-
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.
454
-
455
- ### searchPreset (text + vector + embed)
456
-
457
- Backend-agnostic for Elasticsearch / OpenSearch / Algolia / Typesense / Atlas `$vectorSearch` / Pinecone / Qdrant.
458
-
459
- ```typescript
460
- import { searchPreset } from '@classytic/arc/presets/search';
461
-
462
- // A — auto-wire from a repo with search/searchSimilar/embed methods
463
- searchPreset({ repository: productRepo, search: true, similar: true })
464
-
465
- // B — external backends
466
- searchPreset({
467
- search: {
468
- path: '/full-text',
469
- schema: { body: z.object({ q: z.string().min(1) }) },
470
- handler: (req) => elastic.search({ index: 'products', q: req.body.q }),
471
- },
472
- similar: { handler: (req) => pinecone.query({ vector: req.body.vector, topK: 10 }), mcp: false },
473
- })
474
- ```
475
-
476
- Defaults: search/similar inherit `list` perms → `allowPublic()`. Embed → `requireAuth()`. Zod v4 schemas auto-convert. MCP tools namespaced as `{op}_{resource}`.
477
-
478
- ## Adapters
479
-
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/`.
481
-
482
- ```typescript
483
- // Mongoose — from mongokit
484
- import { createMongooseAdapter } from '@classytic/mongokit/adapter';
485
- import { buildCrudSchemasFromModel } from '@classytic/mongokit';
486
-
487
- const adapter = createMongooseAdapter({
488
- model: ProductModel,
489
- repository: productRepo,
490
- schemaGenerator: buildCrudSchemasFromModel, // no cast needed
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';
501
- ```
502
-
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.
504
-
505
- **Custom adapter** — implement `DataAdapter` / `MinimalRepo` from `@classytic/repo-core/adapter`:
506
-
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
- ```
516
-
517
- ## Controllers
518
-
519
- `BaseController` is mixin-composed; declaration-merged interfaces thread `TDoc` through every CRUD + preset method.
520
-
521
- ```typescript
522
- import { BaseController } from '@classytic/arc';
523
- import type { IRequestContext, IControllerResponse } from '@classytic/arc';
524
-
525
- class ProductController extends BaseController<Product> {
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
- }
532
-
533
- async getFeatured(req: IRequestContext): Promise<IControllerResponse<Product[]>> {
534
- const products = await this.repository.getAll({ filters: { isFeatured: true } });
535
- return { data: products };
536
- }
537
- }
538
-
539
- defineResource({ name: 'product', controller: new ProductController({ tenantField: '_id' }), tenantField: '_id' });
540
- ```
541
-
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):
545
-
546
- ```typescript
547
- import { BaseCrudController, SoftDeleteMixin, BulkMixin } from '@classytic/arc';
548
- class ReportController extends BaseCrudController<Report> {}
549
- class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController)) {}
550
- ```
551
-
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)`.
553
-
554
- `IRequestContext` = `{ params, query, body, user, headers, context, metadata, server }`.
555
- `IControllerResponse` = `{ success, data?, error?, status?, meta?, headers? }`.
556
-
557
- ## Hooks
558
-
559
- Inline on resource — `ResourceHookContext` = `{ data, user?, meta? }`; `meta` has `id` and `existing` for update/delete:
560
-
561
- ```typescript
562
- defineResource({
563
- name: 'chat',
564
- hooks: {
565
- beforeCreate: async (ctx) => { ctx.data.slug = slugify(ctx.data.name); },
566
- afterCreate: async (ctx) => { analytics.track('created', { id: ctx.data._id }); },
567
- beforeUpdate: async (ctx) => { /* ctx.meta.existing has the pre-image */ },
568
- afterUpdate: async (ctx) => { await invalidateCache(ctx.data._id); },
569
- beforeDelete: async (ctx) => { if (ctx.data.isProtected) throw new Error('Cannot delete'); },
570
- afterDelete: async (ctx) => { await cleanupFiles(ctx.meta?.id); },
571
- },
572
- });
573
- ```
574
-
575
- App-level (cross-resource):
576
-
577
- ```typescript
578
- import { createHookSystem, beforeCreate, afterUpdate } from '@classytic/arc/hooks';
579
-
580
- const hooks = createHookSystem();
581
- beforeCreate(hooks, 'product', async (ctx) => { ctx.data.slug = slugify(ctx.data.name); });
582
- afterUpdate(hooks, 'product', async (ctx) => { await invalidateCache(ctx.result._id); });
583
- ```
584
-
585
- ## Pipeline
586
-
587
- ```typescript
588
- import { guard, transform, intercept } from '@classytic/arc/pipeline';
589
-
590
- defineResource({
591
- pipe: {
592
- create: [
593
- guard('verified', async (ctx) => ctx.user?.verified === true),
594
- transform('inject', async (ctx) => { ctx.body.createdBy = ctx.user._id; }),
595
- ],
596
- },
597
- });
598
- ```
599
-
600
315
  ## Query parsing
601
316
 
602
- Default parser handles filters, sort, select, populate, pagination.
603
-
604
317
  ```
605
318
  GET /products?page=2&limit=20&sort=-createdAt&select=name,price
606
319
  GET /products?price[gte]=100&status[in]=active,featured&search=keyword
607
- GET /products?after=<cursor_id>&limit=20 # keyset pagination
608
- GET /products?populate=category
320
+ GET /products?after=<cursor_id>&limit=20 # keyset
609
321
  GET /products?populate[category][select]=name,slug
610
- GET /products?populate[category][match][isActive]=true
322
+ GET /products?filter[status]=active&filter[price][gte]=100 # bracket envelope (2.16)
611
323
  ```
612
324
 
613
325
  Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `like`, `regex`, `exists`.
614
326
 
615
- **MongoKit `$lookup` joins:**
616
-
617
- ```
618
- GET /products?lookup[cat][from]=categories&lookup[cat][localField]=categorySlug&lookup[cat][foreignField]=slug&lookup[cat][single]=true
619
- ```
620
-
621
- **Custom parser (whitelists, MCP auto-derive):**
327
+ **Whitelisted parser (MCP filter auto-derive):**
622
328
 
623
329
  ```typescript
624
330
  import { QueryParser } from '@classytic/mongokit';
625
331
 
626
332
  defineResource({
627
- name: 'product',
628
- adapter,
629
333
  queryParser: new QueryParser({
630
334
  allowedFilterFields: ['status', 'category', 'orgId'],
631
- allowedSortFields: ['createdAt', 'price'],
632
- allowedOperators: ['eq', 'gte', 'lte', 'in'],
335
+ allowedSortFields: ['createdAt', 'price'],
336
+ allowedOperators: ['eq', 'gte', 'lte', 'in'],
633
337
  }),
634
- schemaOptions: {
635
- query: { allowedPopulate: ['category', 'brand'], allowedLookups: ['categories', 'brands'] },
636
- },
338
+ schemaOptions: { query: { allowedPopulate: ['category'], allowedLookups: ['categories'] } },
637
339
  });
638
340
  ```
639
341
 
640
- MCP auto-derives `filterableFields` from `queryParser`.
342
+ ## Aggregations
641
343
 
642
- ## Aggregationsdashboards in declarative form
643
-
644
- Add `aggregations: { … }` to a resource and Arc registers `GET
645
- /{prefix}/aggregations/:name` per entry. Each runs a portable `$match
646
- → $group → $project → $sort → $limit` pipeline against the kit's
647
- `repo.aggregate(req, options)` — same shape across mongokit /
648
- sqlitekit / prismakit, so dashboards work unchanged across backends.
344
+ `GET /:prefix/aggregations/:name` per entry. Portable `$match → $group → $project → $sort → $limit` against `repo.aggregate(req, options)` same shape across kits.
649
345
 
650
346
  ```typescript
651
347
  import { defineResource, defineAggregation } from '@classytic/arc';
@@ -655,7 +351,6 @@ defineResource({
655
351
  adapter,
656
352
  presets: [multiTenantPreset({ tenantField: 'organizationId' })],
657
353
  permissions: { list: canViewRevenue() },
658
-
659
354
  aggregations: {
660
355
  byPaymentMethod: defineAggregation({
661
356
  groupBy: 'method',
@@ -664,158 +359,123 @@ defineResource({
664
359
  cache: { staleTime: 60, swr: true, tags: ['revenue'] },
665
360
  permissions: canViewRevenue(),
666
361
  }),
667
- byFlow: defineAggregation({
668
- groupBy: 'flow',
669
- measures: { total: 'sum:amount', count: 'count' },
670
- cache: { staleTime: 60, swr: true, tags: ['revenue'] },
671
- permissions: canViewRevenue(),
672
- }),
673
362
  byDay: defineAggregation({
674
363
  dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
675
364
  groupBy: 'flow',
676
365
  measures: { total: 'sum:amount', count: 'count' },
677
- sort: { day: 1 },
678
366
  requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
679
- cache: { staleTime: 60, swr: true, tags: ['revenue'] },
680
367
  permissions: canViewRevenue(),
681
368
  }),
682
369
  },
683
370
  });
684
371
  ```
685
372
 
686
- **Tenant scope flows through the second arg, NOT the filter.** Arc is
687
- DB-agnostic — type-coercion (string → ObjectId for mongokit
688
- `fieldType: 'objectId'`, UUID/text for sqlitekit, etc.) belongs to the
689
- kit. Arc threads `tenantOptions` to `repo.aggregate(req, options)`;
690
- the kit's multi-tenant plugin reads `context.organizationId`, casts
691
- correctly, and merges into the request. Authors never inject the
692
- tenant key into `aggReq.filter` themselves.
693
-
694
- **2.15.3 — `multiTenantPreset` now wires `/aggregations/:name`.** Pre-2.15.3
695
- the preset only scoped CRUD; aggregation routes leaked across orgs for any
696
- caller whose `scope.kind !== 'member'`. Adding `multiTenantPreset({ tenantField: 'organizationId' })`
697
- now emits an `aggregations` middleware slot alongside the five CRUD slots, so
698
- member callers see only their org and `kind: 'elevated'` callers WITHOUT a
699
- target org get `bypassTenant: true` (platform-admin cross-tenant dashboards).
700
- **Kit config note:** set `scope: true` (or `scope: { fieldType: 'objectId' }`)
701
- on revenue/order/etc. engines — the pre-2.15.2 advice to use `scope: false`
702
- "to avoid double-scoping with arc" is no longer correct; arc 2.15.2+
703
- deliberately leaves `aggReq.filter` clean and relies on the kit. Required
704
- peers: `@classytic/repo-core ≥ 0.4.1`, `@classytic/mongokit ≥ 3.13.2`,
705
- `@classytic/sqlitekit ≥ 0.3.1`.
706
-
707
- **Caller filters via query string compose with `groupBy` / measures:**
373
+ Safety guards on the declaration: `requireFilters`, `requireDateRange { field, maxRangeDays }`, `maxGroups`. Caller query-string filters compose with `groupBy` / measures. SWR + `tags` invalidation tie aggregations to CRUD writes. Every aggregation auto-exports as an MCP tool.
708
374
 
709
- ```
710
- GET /api/transactions/aggregations/byPaymentMethod?status=verified
711
- GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
712
- ```
375
+ Tenant scope flows through `options` (second arg), NOT into `aggReq.filter`. The kit's multi-tenant plugin handles type coercion (string → ObjectId, UUID/text, etc.). **Aggregation-only resources** (`disableDefaultRoutes: true` + no controller) work — arc falls back to the adapter's repo. If aggregations exist without a repo AND without `materialized`, `defineResource()` throws at boot.
713
376
 
714
- **Safety guards on the declaration:**
715
- - `requireDateRange: { field, maxRangeDays }` — bounded range mandatory; kills accidental all-time scans
716
- - `requireFilters: ['orgId']` — mandatory scope keys
717
- - `maxGroups: 1000` — post-execution row cap; 422 on overflow
377
+ ### `materialized` hook escape hatch + footgun
718
378
 
719
- **Cache invalidation:** writes through resource CRUD bump the
720
- matching tag. Aggregations cached with the same `tags` invalidate
721
- together. SWR mode serves stale immediately while revalidating in
722
- background.
379
+ When a kit can't express your aggregation in the portable IR (`$graphLookup`, window functions, custom SQL), declare a `materialized` hook to own dispatch yourself:
723
380
 
724
- **MCP auto-export:** every aggregation surfaces as an MCP tool
725
- named `{resource}_aggregations_{name}` with the same permission gate
726
- and filter validation as the HTTP route.
381
+ ```typescript
382
+ defineAggregation({
383
+ measures: { count: 'count' },
384
+ permissions: canViewRevenue(),
385
+ materialized: async (ctx) => {
386
+ // ctx = { filter, orgId, userId, requestId, query }
387
+ // ⚠️ ctx.filter contains ONLY the declaration filter + caller query string.
388
+ // Tenant scope is in ctx.orgId, NOT in ctx.filter. Soft-delete is NOT merged.
389
+ // Bypassing repo.aggregate() means bypassing the kit's hook pipeline.
727
390
 
728
- For backends without `repo.aggregate` (custom adapters), declare a
729
- `materialized` hook on the aggregation Arc routes through it
730
- instead of the kit and returns the same `{ rows }` envelope.
391
+ // Right (mongokit) — route through repo.aggregatePipeline so the kit's
392
+ // multi-tenant + soft-delete + audit hooks all run:
393
+ const rows = await orderRepo.aggregatePipeline(
394
+ [{ $group: { _id: '$flow', total: { $sum: '$amount' } } }],
395
+ { organizationId: ctx.orgId, userId: ctx.userId, requestId: ctx.requestId },
396
+ );
397
+ return { rows };
398
+
399
+ // ❌ Wrong — Model.aggregate(...) bypasses kit plugins; tenant + soft-delete
400
+ // leak across orgs. Never call the driver directly in a materialized hook.
401
+ },
402
+ });
403
+ ```
404
+
405
+ For sqlitekit / custom adapters without an `aggregatePipeline` equivalent, **you** must inject tenant + soft-delete clauses into the SQL before executing — `ctx.filter` is not sufficient. Prefer the portable `repo.aggregate(req, options)` path whenever the IR can express your shape.
731
406
 
732
407
  ## QueryCache
733
408
 
734
- TanStack Query-style server cache, stale-while-revalidate, auto-invalidation on mutations.
409
+ TanStack-Query-style server cache with SWR + auto-invalidation on mutations:
735
410
 
736
411
  ```typescript
737
412
  const app = await createApp({ arcPlugins: { queryCache: true } });
738
413
 
739
414
  defineResource({
740
- name: 'product',
741
415
  cache: {
742
- staleTime: 30,
743
- gcTime: 300,
744
- tags: ['catalog'],
416
+ staleTime: 30, gcTime: 300, tags: ['catalog'],
745
417
  invalidateOn: { 'category.*': ['catalog'] }, // event pattern → tag targets
746
- list: { staleTime: 60 }, // per-operation override
418
+ list: { staleTime: 60 },
747
419
  byId: { staleTime: 10 },
748
420
  },
749
421
  });
750
422
  ```
751
423
 
752
- POST/PATCH/DELETE bumps resource version. Modes: `memory` (default) | `distributed` (requires `stores.queryCache: RedisCacheStore`). Response header: `x-cache: HIT | STALE | MISS`.
424
+ POST/PATCH/DELETE bumps resource version. Response header: `x-cache: HIT | STALE | MISS`. `runtime: 'distributed'` requires `stores.queryCache: RedisCacheStore`.
753
425
 
754
426
  ## Events
755
427
 
756
- `createApp` auto-registers `eventPlugin` (default: `MemoryEventTransport`).
428
+ CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`. Manual publish:
757
429
 
758
430
  ```typescript
759
- const app = await createApp({
760
- stores: { events: new RedisEventTransport(redis) }, // optional
761
- arcPlugins: { events: { logEvents: true, retry: { maxRetries: 3, backoffMs: 1000 } } },
762
- });
763
-
764
431
  await app.events.publish('order.created', { orderId: '123' });
765
- await app.events.subscribe('order.*', async (event) => { ... });
432
+ await app.events.subscribe('order.*', async (event) => { });
766
433
  ```
767
434
 
768
- CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`.
769
-
770
- **Transports:** Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, at-least-once, consumer groups, DLQ).
435
+ Transports: Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, consumer groups, DLQ). Event types live in `@classytic/primitives/events` (`createEvent`, `createChildEvent`, `matchEventPattern`, …); arc re-exports the runtime `MemoryEventTransport` only.
771
436
 
772
- **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.
437
+ **Outbox** (at-least-once via transactional outbox):
773
438
 
774
- 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'`.
439
+ ```typescript
440
+ const outbox = new EventOutbox({ repository: outboxRepo, transport });
441
+ // Dev: { store: new MemoryOutboxStore(), transport }
442
+ ```
775
443
 
776
- **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.
444
+ Full event recipes (retry, DLQ, dual-publish warnings, idempotency keys) [references/events.md](references/events.md).
777
445
 
778
- Full event recipes → [references/events.md](references/events.md).
446
+ ## Hooks
779
447
 
780
- ## Errors
448
+ Inline on resource. `ResourceHookContext` = `{ data, user?, meta? }`; `meta` has `id` and `existing` for update/delete.
781
449
 
782
450
  ```typescript
783
- import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
784
-
785
- throw new NotFoundError('Product not found'); // 404
786
- throw createDomainError('SELF_REFERRAL', 'Cannot self-refer', 422, { field: 'referralCode' });
451
+ hooks: {
452
+ beforeCreate: async (ctx) => { ctx.data.slug = slugify(ctx.data.name); },
453
+ afterCreate: async (ctx) => { analytics.track('created', { id: ctx.data._id }); },
454
+ beforeUpdate: async (ctx) => { /* ctx.meta.existing has the pre-image */ },
455
+ beforeDelete: async (ctx) => { if (ctx.data.isProtected) throw new Error('locked'); },
456
+ }
787
457
  ```
788
458
 
789
- Resolution: `ArcError` → `.statusCode` (Fastify) `.status` (MongoKit, http-errors) → user `errorMap` Mongoose/MongoDB → 500. Any error with `.status` or `.statusCode` gets the correct HTTP response.
459
+ App-level (cross-resource): `createHookSystem()` + `beforeCreate(hooks, 'product', fn)` from `@classytic/arc/hooks`.
790
460
 
791
- `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`.
792
-
793
- **Class-based mappers:**
461
+ ## Errors
794
462
 
795
463
  ```typescript
796
- const app = await createApp({
797
- errorHandler: {
798
- errorMappers: [{
799
- type: AccountingError,
800
- toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
801
- }],
802
- },
803
- });
804
- ```
464
+ import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
805
465
 
806
- ## Compensating transaction
466
+ throw new NotFoundError('Product'); // 404
467
+ throw createDomainError('SELF_REFERRAL', 'Cannot self-refer', 422, { field });
468
+ ```
807
469
 
808
- In-process rollback for multi-step operations (not a distributed sagause Temporal / Streamline for that):
470
+ Wire envelope: `{ code, message, status, meta?, correlationId? }`. HTTP status is the discriminator no `success` field. Custom mappers:
809
471
 
810
472
  ```typescript
811
- import { withCompensation } from '@classytic/arc/utils';
812
-
813
- const result = await withCompensation('checkout', [
814
- { name: 'reserve', execute: reserveStock, compensate: releaseStock },
815
- { name: 'charge', execute: chargeCard, compensate: refundCard },
816
- { name: 'notify', execute: sendEmail, fireAndForget: true },
817
- ], { orderId });
818
- // result: { success, completedSteps, results, failedStep?, error?, compensationErrors? }
473
+ errorHandler: {
474
+ errorMappers: [{
475
+ type: AccountingError,
476
+ toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
477
+ }],
478
+ }
819
479
  ```
820
480
 
821
481
  ## Testing
@@ -826,7 +486,7 @@ import { createTestApp, expectArc } from '@classytic/arc/testing';
826
486
  const ctx = await createTestApp({
827
487
  resources: [productResource],
828
488
  authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
829
- connectMongoose: true, // in-memory Mongo + Mongoose connect
489
+ connectMongoose: true, // in-memory Mongo
830
490
  });
831
491
  ctx.auth.register('admin', { user: { id: '1', role: 'admin' }, orgId: 'org-1' });
832
492
 
@@ -836,34 +496,29 @@ const res = await ctx.app.inject({
836
496
  payload: { name: 'Widget' },
837
497
  });
838
498
  expectArc(res).ok().hidesField('password');
839
-
840
499
  await ctx.close();
841
500
  ```
842
501
 
843
- Three entry points: `createTestApp` (custom scenarios), `createHttpTestHarness` (~16 auto-generated CRUD/permission/validation tests per resource), `runStorageContract` (adapter conformance).
844
-
845
- Full testing recipes → [references/testing.md](references/testing.md).
502
+ Three entry points: `createTestApp` (custom), `createHttpTestHarness` (~16 auto-generated CRUD/perm/validation tests per resource), `runStorageContract` (adapter conformance). → [references/testing.md](references/testing.md)
846
503
 
847
504
  ## CLI
848
505
 
849
- The bin is `arc` (registered by `@classytic/arc`). Outside an arc project use `npx @classytic/arc <cmd>`; inside one (devDep installed) bare `arc` resolves through `node_modules/.bin`.
850
-
851
506
  ```bash
852
- npx @classytic/arc init my-api --mongokit --better-auth --single --ts # scaffold (also: --custom, --jwt, --multi, --js, --edge)
853
- npx @classytic/arc generate resource product # generate a resource (alias: arc g r product)
854
- npx @classytic/arc generate resource product --mcp # + MCP tools file
855
- npx @classytic/arc generate mcp analytics # standalone MCP tools file
856
- npx @classytic/arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
857
- npx @classytic/arc introspect --entry ./dist/index.js
858
- npx @classytic/arc describe ./dist/resources.js --json # JSON metadata for AI agents
859
- npx @classytic/arc doctor
507
+ arc init my-api --mongokit --better-auth --ts # scaffold
508
+ arc generate resource product # alias: arc g r product
509
+ arc generate resource product --mcp # + MCP tools file
510
+ arc generate mcp analytics # standalone MCP file
511
+ arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
512
+ arc introspect --entry ./dist/index.js
513
+ arc describe ./dist/resources.js --json # JSON metadata
514
+ arc doctor # diagnose env
860
515
  ```
861
516
 
862
517
  Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
863
518
 
864
519
  ## MCP (AI agent tools)
865
520
 
866
- Resources auto-generate Model Context Protocol tools — same permissions, same field rules. Stateless by default (fresh server per request, scalable).
521
+ Resources auto-generate Model Context Protocol tools — same permissions, same field rules. Stateless by default.
867
522
 
868
523
  ```typescript
869
524
  import { mcpPlugin } from '@classytic/arc/mcp';
@@ -874,109 +529,124 @@ await app.register(mcpPlugin, {
874
529
  exclude: ['credential'],
875
530
  overrides: { product: { operations: ['list', 'get'] } },
876
531
  });
877
-
878
- // Stateful — when you need server-initiated messages
879
- await app.register(mcpPlugin, { resources, stateful: true, sessionTtlMs: 600000 });
880
532
  ```
881
533
 
882
- Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:3000/mcp`.
534
+ **Per-resource opt-out:** `defineResource({ mcp: false })` exclude a resource from MCP tool generation entirely. Useful for internal-admin or write-heavy resources where you don't want LLMs poking.
883
535
 
884
- **Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function:
536
+ **Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function returning `{ userId, organizationId, roles }` (human) or `{ clientId, organizationId, scopes }` (service). `PermissionResult.filters` flow into MCP tools exactly like REST.
885
537
 
886
- ```typescript
887
- // Human user
888
- auth: async (headers) => {
889
- if (headers['x-api-key'] !== process.env.MCP_KEY) return null;
890
- return { userId: 'bot', organizationId: 'org-1', roles: ['admin'] };
891
- },
892
-
893
- // Service / machine — produces kind: "service" scope
894
- auth: async (headers) => ({
895
- clientId: 'ingestion-pipeline',
896
- organizationId: 'org-1',
897
- scopes: ['read:products', 'write:events'],
898
- }),
899
- ```
538
+ **Custom tools** — co-locate with resources (`order.mcp.ts`), wire via `extraTools`. **AI SDK bridge** — expose AI SDK `tool()` defs via `buildMcpToolsFromBridges([...])`.
900
539
 
901
- `auth: false` `ctx.user` null, `scope.kind: "public"`. `clientId` set → `kind: "service"` works with `requireServiceScope()`. `PermissionResult.filters` flow into MCP tools — same as REST.
540
+ Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:3000/mcp`. Full recipes [references/mcp.md](references/mcp.md).
902
541
 
903
- **Custom tools** co-locate with resources (`order.mcp.ts`), wire via `extraTools: [fulfillOrderTool]`. Generate: `arc generate resource order --mcp`.
542
+ ## Tenant cleanup (GDPR / SOX / HIPAA)
904
543
 
905
- **AI SDK bridge** expose AI SDK `tool()` definitions over MCP without duplicating glue:
544
+ Per-resource strategy + one auth-lifecycle wire-up:
906
545
 
907
546
  ```typescript
908
- import { buildMcpToolsFromBridges, getUserId, hasOrg, type McpBridge } from '@classytic/arc/mcp';
909
-
910
- export const triggerJobBridge: McpBridge = {
911
- name: 'trigger_job',
912
- description: 'Start a job.',
913
- inputSchema: { phase: z.enum(['investigate', 'fix']) },
914
- annotations: { destructiveHint: true },
915
- buildTool: (ctx) => buildTriggerJobTool(getUserId(ctx) ?? ''),
916
- guard: (ctx) => (hasOrg(ctx) ? null : 'Organization scope required'),
917
- };
918
-
919
- await app.register(mcpPlugin, {
920
- resources,
921
- extraTools: buildMcpToolsFromBridges([triggerJobBridge]),
547
+ defineResource({
548
+ name: 'invoice',
549
+ tenantField: 'organizationId',
550
+ onTenantDelete: {
551
+ strategy: { type: 'anonymize', fields: { customerName: '[REDACTED]', email: null } },
552
+ priority: 50, batchSize: 1000,
553
+ },
922
554
  });
555
+ defineResource({ name: 'event', onTenantDelete: { strategy: { type: 'hard' } } });
556
+ defineResource({ name: 'ledger', onTenantDelete: { strategy: { type: 'skip', reason: 'SOX retention' } } });
557
+
558
+ import { cascadeDeleteForOrganization, assertNoTenantData } from '@classytic/arc/registry';
559
+
560
+ betterAuth.org.afterDelete = async ({ organizationId }) => {
561
+ const report = await cascadeDeleteForOrganization(fastify.arc.registry, {
562
+ organizationId, concurrency: 4, logger: fastify.log,
563
+ });
564
+ if (report.failures.length > 0) await alerting.fire({ report });
565
+ };
923
566
  ```
924
567
 
925
- Full MCP recipes [references/mcp.md](references/mcp.md).
568
+ Strategies: `hard` · `soft` · `anonymize` (`fields` static or `(doc) => value`) · `skip` (`reason` mandatory). Resources without `onTenantDelete` are never touched. **Index `tenantField`** on every cascading resource or chunked SELECTs run O(n²). Compliance smoke: `assertNoTenantData(registry, { organizationId })`.
926
569
 
927
- ## Audit per-resource opt-in
570
+ ## Enterprise auth (opt-in)
571
+
572
+ | Capability | Surface | Reference |
573
+ |---|---|---|
574
+ | SCIM 2.0 provisioning (Okta / Azure AD / Google) | `@classytic/arc/scim` — `scimPlugin({ users, groups, bearer })` | [references/scim.md](references/scim.md) |
575
+ | Agent mandates + DPoP (AP2 / x402 / MCP authz) | `@classytic/arc/permissions` — `requireAgentScope`, `requireMandate`, `requireDPoP` | [references/agent-auth.md](references/agent-auth.md) |
576
+ | Auth-event audit bridge | `@classytic/arc/auth/audit` — `wireBetterAuthAudit({ events })` | [references/enterprise-auth.md](references/enterprise-auth.md) |
577
+
578
+ Out of scope: SAML (use BA SAML plugin), session storage (BA `secondaryStorage`), DPoP crypto (one `jose.dpop.verify()` in your `authenticate`), device trust (Castle / Stytch).
579
+
580
+ ## Audit per-resource
928
581
 
929
582
  ```typescript
930
583
  await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
931
584
 
932
- defineResource({ name: 'order', audit: true });
585
+ defineResource({ name: 'order', audit: true });
933
586
  defineResource({ name: 'payment', audit: { operations: ['delete'] } });
934
587
  defineResource({ name: 'product' }); // not audited
935
588
 
936
- // Manual custom() for MCP tools / read auditing
937
- app.post('/orders/:id/refund', async (req) => {
938
- await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
939
- });
589
+ // Manual (MCP tools / read auditing)
590
+ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
940
591
  ```
941
592
 
942
- ## DX helpers
593
+ ## Adapters
943
594
 
944
- ```typescript
945
- import type { ArcRequest } from '@classytic/arc';
946
- import { envelope, createDomainError } from '@classytic/arc';
947
- import { getOrgContext } from '@classytic/arc/scope';
948
- import { roles } from '@classytic/arc/permissions';
595
+ Cross-framework adapter contract lives in `@classytic/repo-core/adapter`. Every kit-specific adapter ships from its kit's `/adapter` subpath; arc itself has zero kit-bound adapters.
949
596
 
950
- // Typed request for raw routes — no `(req as any).user`
951
- handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
597
+ ```typescript
598
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
599
+ import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter';
600
+ import { createPrismaAdapter } from '@classytic/prismakit/adapter';
952
601
 
953
- // Response envelope
954
- reply.send(envelope(data, { total: 100 }));
602
+ import type { DataAdapter, RepositoryLike, AdapterRepositoryInput }
603
+ from '@classytic/repo-core/adapter';
604
+ // MinimalRepo<TDoc> — 5-method floor (getAll, getById, create, update, delete)
605
+ // StandardRepo<TDoc> — MinimalRepo + optional batch ops, CAS, soft-delete, aggregate, …
606
+ // Arc feature-detects optional methods at call sites; missing methods → 501.
607
+ ```
955
608
 
956
- // Canonical org extraction
957
- const { userId, organizationId, roles: userRoles, orgRoles } = getOrgContext(request);
609
+ Custom kits implementing `DataAdapter<TDoc>` plug in identically. Kit-native repos plug in **without** `as RepositoryLike` casts.
958
610
 
959
- // Unified role check — platform AND org roles
960
- permissions: { create: roles('admin', 'editor'), delete: roles('admin') }
961
- ```
611
+ ## Controllers
962
612
 
963
- **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:
613
+ `BaseController` is mixin-composed. The auto-built controller covers every preset; you only need a custom one to add domain methods.
964
614
 
965
615
  ```typescript
966
- return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
967
- return reply.sendList(canonicalListResult); // any kit-shaped paginated/array result
968
- return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
616
+ import { BaseController, type IRequestContext, type IControllerResponse } from '@classytic/arc';
617
+
618
+ class ProductController extends BaseController<Product> {
619
+ constructor(opts: { tenantField?: string | false; idField?: string } = {}) {
620
+ super(productRepo, { resourceName: 'product', ...opts });
621
+ }
622
+ async getFeatured(req: IRequestContext): Promise<IControllerResponse<Product[]>> {
623
+ return { data: await this.repository.getAll({ filters: { isFeatured: true } }) };
624
+ }
625
+ }
626
+
627
+ defineResource({ name: 'product', controller: new ProductController({ tenantField: '_id' }), tenantField: '_id' });
969
628
  ```
970
629
 
971
- `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.
630
+ When you pass your own controller, arc **cannot** thread `tenantField` / `schemaOptions` / `idField` / `cache` / `onFieldWriteDenied` into it. Forward via `super()` AND pass to `defineResource()`. Presets that inject controller fields (slugLookup, softDelete, tree) only reach arc's auto-built `BaseController` extend `BaseController` or drop the preset.
972
631
 
973
- **BigInt serialization** — `createApp({ serializeBigInt: true })` converts BigInt → Number in JSON.
632
+ **Slim mixins** (no soft-delete/tree/slug/bulk on by default):
974
633
 
975
- **Multipart body middleware** — opt-in file upload (no-op for JSON requests, safe to always add):
634
+ ```typescript
635
+ import { BaseCrudController, SoftDeleteMixin, BulkMixin, SlugMixin, TreeMixin } from '@classytic/arc';
636
+ class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController)) {}
637
+ ```
638
+
639
+ ## DX helpers
976
640
 
977
641
  ```typescript
642
+ import type { ArcRequest } from '@classytic/arc';
643
+ import { envelope } from '@classytic/arc';
978
644
  import { multipartBody } from '@classytic/arc/middleware';
979
645
 
646
+ // Typed request — no `(req as any).user`
647
+ handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
648
+
649
+ // File upload — no-op for JSON requests, safe to always add
980
650
  defineResource({
981
651
  name: 'product',
982
652
  middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5 * 1024 * 1024 })] },
@@ -987,121 +657,45 @@ defineResource({
987
657
  },
988
658
  },
989
659
  });
990
- ```
991
-
992
- **SSE auth + streaming** — `preAuth` runs before auth (EventSource can't set headers); `raw: true` streams the response:
993
660
 
994
- ```typescript
661
+ // SSE — preAuth runs before auth (EventSource can't set headers); raw: true streams the response
995
662
  routes: [
996
663
  { preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] },
997
664
  { method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) },
998
665
  ]
999
- ```
1000
-
1001
- **Per-resource opt-out of `resourcePrefix`** — for webhooks, admin routes:
1002
666
 
1003
- ```typescript
1004
- defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true })
1005
- // Registers at /hooks even with createApp({ resourcePrefix: '/api/v1' })
1006
- ```
667
+ // Per-resource opt-out of resourcePrefix (webhooks / admin routes)
668
+ defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true });
1007
669
 
1008
- ## Enterprise auth (2.13)
1009
-
1010
- Three opt-in surfaces close the procurement-gate gaps without forcing parallel infrastructure. Sessions / refresh / OAuth flows stay in Better Auth's hands.
1011
-
1012
- ### SCIM 2.0 — IdP provisioning (`@classytic/arc/scim`)
1013
-
1014
- 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.
1015
-
1016
- ```typescript
1017
- import { scimPlugin } from '@classytic/arc/scim';
1018
-
1019
- await app.register(scimPlugin, {
1020
- users: { resource: userResource },
1021
- groups: { resource: orgResource },
1022
- bearer: process.env.SCIM_TOKEN, // or: verify: async (req) => …
1023
- });
1024
- ```
1025
-
1026
- 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).
1027
-
1028
- ### Agent-auth helpers — DPoP + capability mandates
1029
-
1030
- For AI-agent flows on protected resources (AP2 / Stripe x402 / MCP authorization). Three new helpers in `@classytic/arc/permissions`:
1031
-
1032
- ```typescript
1033
- import { requireAgentScope, requireMandate, requireDPoP } from '@classytic/arc/permissions';
1034
-
1035
- defineResource({
1036
- name: 'invoice',
1037
- actions: {
1038
- pay: {
1039
- handler: payInvoice,
1040
- permissions: requireAgentScope({
1041
- capability: 'payment.charge',
1042
- scopes: ['payment.write'],
1043
- requireDPoP: true, // RFC 9449 sender-constrained
1044
- audience: (ctx) => `invoice:${ctx.params?.id}`, // mandate must bind to this resource
1045
- validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
1046
- }),
1047
- },
1048
- },
1049
- });
1050
- ```
1051
-
1052
- `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).
1053
-
1054
- ### Auth-event audit bridge (`@classytic/arc/auth/audit`)
1055
-
1056
- 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".
1057
-
1058
- ```typescript
1059
- import { wireBetterAuthAudit } from '@classytic/arc/auth/audit';
1060
-
1061
- const audit = wireBetterAuthAudit({
1062
- events: ['session.*', 'user.*', 'mfa.*', 'org.invite.*'],
1063
- });
1064
-
1065
- const auth = betterAuth({
1066
- hooks: audit.hooks, // endpoint hooks (MFA, OAuth, password reset)
1067
- databaseHooks: audit.databaseHooks, // sign-in/up/out via session.create/delete
1068
- // ...
1069
- });
1070
-
1071
- const app = await createApp({ ... });
1072
- audit.attach(app); // drains boot-time buffer + connects live logger
670
+ // Reply helpers — opt-in via createApp({ replyHelpers: true })
671
+ return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
672
+ return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
1073
673
  ```
1074
674
 
1075
- 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).
1076
-
1077
- ### What's NOT in arc 2.13
1078
-
1079
- 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/).
1080
-
1081
675
  ## Subpath imports
1082
676
 
1083
- The most common imports — full enumeration in [references/api-reference.md](references/api-reference.md).
1084
-
1085
677
  ```typescript
1086
- import { defineResource, BaseController, allowPublic } from '@classytic/arc';
1087
- import { createApp, loadResources } from '@classytic/arc/factory';
1088
- import { createMongooseAdapter } from '@classytic/mongokit/adapter'; // or sqlitekit/adapter, prismakit/adapter
1089
- import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter';
1090
- import { getUserId, getOrgId, requireOrgId } from '@classytic/arc/scope';
1091
- import { mcpPlugin } from '@classytic/arc/mcp';
1092
- import { createTestApp, expectArc } from '@classytic/arc/testing';
678
+ import { defineResource, BaseController, allowPublic } from '@classytic/arc';
679
+ import { createApp, loadResources } from '@classytic/arc/factory';
680
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
681
+ import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter';
682
+ import { getUserId, getOrgId, requireOrgId } from '@classytic/arc/scope';
683
+ import { mcpPlugin } from '@classytic/arc/mcp';
684
+ import { createTestApp, expectArc } from '@classytic/arc/testing';
1093
685
  ```
1094
686
 
687
+ Full subpath map → [references/api-reference.md](references/api-reference.md).
688
+
1095
689
  ## References
1096
690
 
1097
691
  - **[api-reference](references/api-reference.md)** — full subpath import map (every plugin, helper, type)
1098
- - **[auth](references/auth.md)** — JWT, Better Auth, API key, custom auth
1099
- - **[scim](references/scim.md)** — SCIM 2.0 plugin (Okta / Azure AD / Google Workspace provisioning)
1100
- - **[agent-auth](references/agent-auth.md)** — DPoP + capability mandates (AP2 / x402 / MCP authorization)
1101
- - **[enterprise-auth](references/enterprise-auth.md)** — what's in vs out of the box for enterprise auth
1102
- - **[events](references/events.md)** — Domain events, transports, retry, outbox
692
+ - **[auth](references/auth.md)** — JWT, Better Auth, API key, custom auth, kit overlays
693
+ - **[scim](references/scim.md)** — SCIM 2.0 plugin (Okta / Azure AD / Google Workspace)
694
+ - **[agent-auth](references/agent-auth.md)** — DPoP + capability mandates (AP2 / x402 / MCP authz)
695
+ - **[enterprise-auth](references/enterprise-auth.md)** — what's in vs out of the box
696
+ - **[events](references/events.md)** — Transports, retry, outbox, idempotency
1103
697
  - **[integrations](references/integrations.md)** — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
1104
- - **[mcp](references/mcp.md)** — MCP tools, custom tools, Better Auth OAuth 2.1
1105
- - **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField`, `PermissionResult.scope`, API key auth
1106
- - **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops
1107
- - **[testing](references/testing.md)** — Test app, mocks, data factories, in-memory MongoDB
698
+ - **[mcp](references/mcp.md)** — Tool generation, custom tools, AI SDK bridges, Better Auth OAuth 2.1
699
+ - **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField`, parent-child orgs, API-key auth
700
+ - **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, bulk ops
701
+ - **[testing](references/testing.md)** — Test app, mocks, fixtures, in-memory Mongo