@classytic/arc 2.15.3 → 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 (159) 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 -3036
  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.d.mts +71 -2
  72. package/dist/integrations/streamline.mjs +81 -8
  73. package/dist/integrations/websocket-redis.d.mts +1 -1
  74. package/dist/integrations/websocket.d.mts +1 -1
  75. package/dist/integrations/websocket.mjs +1 -0
  76. package/dist/loadResourcesFromEntry-BLMEI2Xa.mjs +51 -0
  77. package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-7vGV51ED.mjs} +1021 -318
  78. package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
  79. package/dist/middleware/index.d.mts +1 -1
  80. package/dist/middleware/index.mjs +1 -1
  81. package/dist/{openapi-BHXhoX8O.mjs → openapi-34T9yNwd.mjs} +47 -36
  82. package/dist/permissions/index.d.mts +2 -2
  83. package/dist/permissions/index.mjs +1 -1
  84. package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
  85. package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
  86. package/dist/pipeline/index.d.mts +1 -1
  87. package/dist/pipeline/index.mjs +1 -1
  88. package/dist/plugins/index.d.mts +5 -5
  89. package/dist/plugins/index.mjs +10 -10
  90. package/dist/plugins/response-cache.mjs +5 -5
  91. package/dist/plugins/tracing-entry.d.mts +1 -1
  92. package/dist/plugins/tracing-entry.mjs +1 -1
  93. package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
  94. package/dist/presets/filesUpload.d.mts +4 -4
  95. package/dist/presets/filesUpload.mjs +2 -2
  96. package/dist/presets/index.d.mts +1 -1
  97. package/dist/presets/index.mjs +1 -1
  98. package/dist/presets/multiTenant.d.mts +1 -1
  99. package/dist/presets/multiTenant.mjs +4 -3
  100. package/dist/presets/search.d.mts +2 -2
  101. package/dist/presets/search.mjs +1 -1
  102. package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
  103. package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
  104. package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Biqzfbi5.d.mts} +2 -2
  105. package/dist/{redis-DiMkdHEl.d.mts → redis-Cyzrz6SX.d.mts} +1 -1
  106. package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-DT-YjzrB.d.mts} +1 -1
  107. package/dist/registry/index.d.mts +319 -2
  108. package/dist/registry/index.mjs +3 -3
  109. package/dist/registry-BBE23CDj.mjs +576 -0
  110. package/dist/{routerShared-DrOa-26E.mjs → routerShared-CZV5aabX.mjs} +3 -3
  111. package/dist/scope/index.d.mts +3 -3
  112. package/dist/scope/index.mjs +3 -3
  113. package/dist/{sse-Bz-5ZeTt.mjs → sse-BY6sTy4P.mjs} +1 -1
  114. package/dist/testing/index.d.mts +2 -2
  115. package/dist/testing/index.mjs +16 -7
  116. package/dist/testing/storageContract.d.mts +1 -1
  117. package/dist/types/index.d.mts +5 -5
  118. package/dist/types/storage.d.mts +1 -1
  119. package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
  120. package/dist/{types-BQsjgQzS.d.mts → types-BsJMEQ4D.d.mts} +106 -12
  121. package/dist/{types-DrBaUwyV.d.mts → types-D-fYtKjb.d.mts} +33 -10
  122. package/dist/{types-CTYvcwHe.d.mts → types-DVfpSfx2.d.mts} +42 -1
  123. package/dist/utils/index.d.mts +1286 -2
  124. package/dist/utils/index.mjs +1 -1
  125. package/dist/{utils-_h9B3c57.mjs → utils-DC5ycPfr.mjs} +89 -40
  126. package/dist/{buildHandler-CcFOpJLh.mjs → validate-By96rH0r.mjs} +8 -299
  127. package/dist/{versioning-hmkPcDlX.d.mts → versioning-ZwX9tmbS.d.mts} +1 -1
  128. package/package.json +22 -29
  129. package/skills/arc/SKILL.md +299 -689
  130. package/skills/arc/references/auth.md +19 -7
  131. package/skills/arc-code-review/SKILL.md +1 -1
  132. package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
  133. package/dist/createActionRouter-S3MLVYot.mjs +0 -220
  134. package/dist/index-bRjYu21O.d.mts +0 -1320
  135. package/dist/org/index.d.mts +0 -66
  136. package/dist/org/index.mjs +0 -486
  137. package/dist/org/types.d.mts +0 -82
  138. package/dist/org/types.mjs +0 -1
  139. package/dist/registry-I-ogLgL9.mjs +0 -46
  140. /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-C-2oAHtw.d.mts} +0 -0
  141. /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-Hxvv5QQz.mjs} +0 -0
  142. /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
  143. /package/dist/{elevation-BXOWoGCF.d.mts → elevation-0YBpa663.d.mts} +0 -0
  144. /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
  145. /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-mHuyWzZE.d.mts} +0 -0
  146. /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-DFg-2KTp.d.mts} +0 -0
  147. /package/dist/{interface-beEtJyWM.d.mts → interface-CH0OQudo.d.mts} +0 -0
  148. /package/dist/{interface-DfLGcus7.d.mts → interface-NwJ_qPlY.d.mts} +0 -0
  149. /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
  150. /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-ChQEj8ih.mjs} +0 -0
  151. /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-TuOmguhi.mjs} +0 -0
  152. /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-C-gD32oF.mjs} +0 -0
  153. /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-Ctc89DSn.mjs} +0 -0
  154. /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-BqFegc0W.d.mts} +0 -0
  155. /package/dist/{storage-Dfzt4VTl.d.mts → storage-D2KZJAmn.d.mts} +0 -0
  156. /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-B0sunfZZ.mjs} +0 -0
  157. /package/dist/{tracing-QJVprktp.d.mts → tracing-Dm8n7Cnn.d.mts} +0 -0
  158. /package/dist/{versioning-BUrT5aP4.mjs → versioning-B6mimogM.mjs} +0 -0
  159. /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. arc init my-api --mongokit --jwt --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
42
- npx @classytic/arc@latest init my-api --mongokit --jwt --ts
43
- cd my-api && npm install && npm run dev
37
+ # Scaffold
38
+ npx @classytic/arc@latest init my-api --mongokit --better-auth --single --ts
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`, `--jwt | --better-auth`, `--single | --multi`, `--ts | --js`. 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`.
641
-
642
- ## Aggregations — dashboards in declarative form
342
+ ## Aggregations
643
343
 
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,145 +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.
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.
693
374
 
694
- **Caller filters via query string compose with `groupBy` / measures:**
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.
695
376
 
696
- ```
697
- GET /api/transactions/aggregations/byPaymentMethod?status=verified
698
- GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
699
- ```
377
+ ### `materialized` hook — escape hatch + footgun
700
378
 
701
- **Safety guards on the declaration:**
702
- - `requireDateRange: { field, maxRangeDays }` — bounded range mandatory; kills accidental all-time scans
703
- - `requireFilters: ['orgId']` — mandatory scope keys
704
- - `maxGroups: 1000` — post-execution row cap; 422 on overflow
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:
705
380
 
706
- **Cache invalidation:** writes through resource CRUD bump the
707
- matching tag. Aggregations cached with the same `tags` invalidate
708
- together. SWR mode serves stale immediately while revalidating in
709
- background.
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.
710
390
 
711
- **MCP auto-export:** every aggregation surfaces as an MCP tool
712
- named `{resource}_aggregations_{name}` with the same permission gate
713
- and filter validation as the HTTP route.
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 };
714
398
 
715
- For backends without `repo.aggregate` (custom adapters), declare a
716
- `materialized` hook on the aggregation Arc routes through it
717
- instead of the kit and returns the same `{ rows }` envelope.
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.
718
406
 
719
407
  ## QueryCache
720
408
 
721
- TanStack Query-style server cache, stale-while-revalidate, auto-invalidation on mutations.
409
+ TanStack-Query-style server cache with SWR + auto-invalidation on mutations:
722
410
 
723
411
  ```typescript
724
412
  const app = await createApp({ arcPlugins: { queryCache: true } });
725
413
 
726
414
  defineResource({
727
- name: 'product',
728
415
  cache: {
729
- staleTime: 30,
730
- gcTime: 300,
731
- tags: ['catalog'],
416
+ staleTime: 30, gcTime: 300, tags: ['catalog'],
732
417
  invalidateOn: { 'category.*': ['catalog'] }, // event pattern → tag targets
733
- list: { staleTime: 60 }, // per-operation override
418
+ list: { staleTime: 60 },
734
419
  byId: { staleTime: 10 },
735
420
  },
736
421
  });
737
422
  ```
738
423
 
739
- 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`.
740
425
 
741
426
  ## Events
742
427
 
743
- `createApp` auto-registers `eventPlugin` (default: `MemoryEventTransport`).
428
+ CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`. Manual publish:
744
429
 
745
430
  ```typescript
746
- const app = await createApp({
747
- stores: { events: new RedisEventTransport(redis) }, // optional
748
- arcPlugins: { events: { logEvents: true, retry: { maxRetries: 3, backoffMs: 1000 } } },
749
- });
750
-
751
431
  await app.events.publish('order.created', { orderId: '123' });
752
- await app.events.subscribe('order.*', async (event) => { ... });
432
+ await app.events.subscribe('order.*', async (event) => { });
753
433
  ```
754
434
 
755
- CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`.
756
-
757
- **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.
758
436
 
759
- **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):
760
438
 
761
- 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
+ ```
762
443
 
763
- **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).
764
445
 
765
- Full event recipes → [references/events.md](references/events.md).
446
+ ## Hooks
766
447
 
767
- ## Errors
448
+ Inline on resource. `ResourceHookContext` = `{ data, user?, meta? }`; `meta` has `id` and `existing` for update/delete.
768
449
 
769
450
  ```typescript
770
- import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
771
-
772
- throw new NotFoundError('Product not found'); // 404
773
- 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
+ }
774
457
  ```
775
458
 
776
- 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`.
777
460
 
778
- `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`.
779
-
780
- **Class-based mappers:**
461
+ ## Errors
781
462
 
782
463
  ```typescript
783
- const app = await createApp({
784
- errorHandler: {
785
- errorMappers: [{
786
- type: AccountingError,
787
- toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
788
- }],
789
- },
790
- });
791
- ```
464
+ import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
792
465
 
793
- ## Compensating transaction
466
+ throw new NotFoundError('Product'); // 404
467
+ throw createDomainError('SELF_REFERRAL', 'Cannot self-refer', 422, { field });
468
+ ```
794
469
 
795
- 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:
796
471
 
797
472
  ```typescript
798
- import { withCompensation } from '@classytic/arc/utils';
799
-
800
- const result = await withCompensation('checkout', [
801
- { name: 'reserve', execute: reserveStock, compensate: releaseStock },
802
- { name: 'charge', execute: chargeCard, compensate: refundCard },
803
- { name: 'notify', execute: sendEmail, fireAndForget: true },
804
- ], { orderId });
805
- // 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
+ }
806
479
  ```
807
480
 
808
481
  ## Testing
@@ -813,7 +486,7 @@ import { createTestApp, expectArc } from '@classytic/arc/testing';
813
486
  const ctx = await createTestApp({
814
487
  resources: [productResource],
815
488
  authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
816
- connectMongoose: true, // in-memory Mongo + Mongoose connect
489
+ connectMongoose: true, // in-memory Mongo
817
490
  });
818
491
  ctx.auth.register('admin', { user: { id: '1', role: 'admin' }, orgId: 'org-1' });
819
492
 
@@ -823,31 +496,29 @@ const res = await ctx.app.inject({
823
496
  payload: { name: 'Widget' },
824
497
  });
825
498
  expectArc(res).ok().hidesField('password');
826
-
827
499
  await ctx.close();
828
500
  ```
829
501
 
830
- Three entry points: `createTestApp` (custom scenarios), `createHttpTestHarness` (~16 auto-generated CRUD/permission/validation tests per resource), `runStorageContract` (adapter conformance).
831
-
832
- 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)
833
503
 
834
504
  ## CLI
835
505
 
836
506
  ```bash
837
- arc init my-api --mongokit --jwt --ts # scaffold (also: --custom, --better-auth, --multi)
838
- arc generate resource product # generate a resource
839
- arc generate resource product --mcp # + MCP tools file
840
- arc generate mcp analytics # standalone MCP tools file
841
- arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
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
842
512
  arc introspect --entry ./dist/index.js
843
- arc doctor
513
+ arc describe ./dist/resources.js --json # JSON metadata
514
+ arc doctor # diagnose env
844
515
  ```
845
516
 
846
517
  Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
847
518
 
848
519
  ## MCP (AI agent tools)
849
520
 
850
- 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.
851
522
 
852
523
  ```typescript
853
524
  import { mcpPlugin } from '@classytic/arc/mcp';
@@ -858,109 +529,124 @@ await app.register(mcpPlugin, {
858
529
  exclude: ['credential'],
859
530
  overrides: { product: { operations: ['list', 'get'] } },
860
531
  });
861
-
862
- // Stateful — when you need server-initiated messages
863
- await app.register(mcpPlugin, { resources, stateful: true, sessionTtlMs: 600000 });
864
532
  ```
865
533
 
866
- 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.
867
535
 
868
- **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.
869
537
 
870
- ```typescript
871
- // Human user
872
- auth: async (headers) => {
873
- if (headers['x-api-key'] !== process.env.MCP_KEY) return null;
874
- return { userId: 'bot', organizationId: 'org-1', roles: ['admin'] };
875
- },
876
-
877
- // Service / machine — produces kind: "service" scope
878
- auth: async (headers) => ({
879
- clientId: 'ingestion-pipeline',
880
- organizationId: 'org-1',
881
- scopes: ['read:products', 'write:events'],
882
- }),
883
- ```
538
+ **Custom tools** — co-locate with resources (`order.mcp.ts`), wire via `extraTools`. **AI SDK bridge** — expose AI SDK `tool()` defs via `buildMcpToolsFromBridges([...])`.
884
539
 
885
- `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).
886
541
 
887
- **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)
888
543
 
889
- **AI SDK bridge** expose AI SDK `tool()` definitions over MCP without duplicating glue:
544
+ Per-resource strategy + one auth-lifecycle wire-up:
890
545
 
891
546
  ```typescript
892
- import { buildMcpToolsFromBridges, getUserId, hasOrg, type McpBridge } from '@classytic/arc/mcp';
893
-
894
- export const triggerJobBridge: McpBridge = {
895
- name: 'trigger_job',
896
- description: 'Start a job.',
897
- inputSchema: { phase: z.enum(['investigate', 'fix']) },
898
- annotations: { destructiveHint: true },
899
- buildTool: (ctx) => buildTriggerJobTool(getUserId(ctx) ?? ''),
900
- guard: (ctx) => (hasOrg(ctx) ? null : 'Organization scope required'),
901
- };
902
-
903
- await app.register(mcpPlugin, {
904
- resources,
905
- 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
+ },
906
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
+ };
907
566
  ```
908
567
 
909
- 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 })`.
910
569
 
911
- ## 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
912
581
 
913
582
  ```typescript
914
583
  await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
915
584
 
916
- defineResource({ name: 'order', audit: true });
585
+ defineResource({ name: 'order', audit: true });
917
586
  defineResource({ name: 'payment', audit: { operations: ['delete'] } });
918
587
  defineResource({ name: 'product' }); // not audited
919
588
 
920
- // Manual custom() for MCP tools / read auditing
921
- app.post('/orders/:id/refund', async (req) => {
922
- await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
923
- });
589
+ // Manual (MCP tools / read auditing)
590
+ await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
924
591
  ```
925
592
 
926
- ## DX helpers
593
+ ## Adapters
927
594
 
928
- ```typescript
929
- import type { ArcRequest } from '@classytic/arc';
930
- import { envelope, createDomainError } from '@classytic/arc';
931
- import { getOrgContext } from '@classytic/arc/scope';
932
- 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.
933
596
 
934
- // Typed request for raw routes — no `(req as any).user`
935
- 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';
936
601
 
937
- // Response envelope
938
- 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
+ ```
939
608
 
940
- // Canonical org extraction
941
- 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.
942
610
 
943
- // Unified role check — platform AND org roles
944
- permissions: { create: roles('admin', 'editor'), delete: roles('admin') }
945
- ```
611
+ ## Controllers
946
612
 
947
- **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.
948
614
 
949
615
  ```typescript
950
- return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
951
- return reply.sendList(canonicalListResult); // any kit-shaped paginated/array result
952
- 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' });
953
628
  ```
954
629
 
955
- `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.
956
631
 
957
- **BigInt serialization** — `createApp({ serializeBigInt: true })` converts BigInt → Number in JSON.
632
+ **Slim mixins** (no soft-delete/tree/slug/bulk on by default):
958
633
 
959
- **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
960
640
 
961
641
  ```typescript
642
+ import type { ArcRequest } from '@classytic/arc';
643
+ import { envelope } from '@classytic/arc';
962
644
  import { multipartBody } from '@classytic/arc/middleware';
963
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
964
650
  defineResource({
965
651
  name: 'product',
966
652
  middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5 * 1024 * 1024 })] },
@@ -971,121 +657,45 @@ defineResource({
971
657
  },
972
658
  },
973
659
  });
974
- ```
975
-
976
- **SSE auth + streaming** — `preAuth` runs before auth (EventSource can't set headers); `raw: true` streams the response:
977
660
 
978
- ```typescript
661
+ // SSE — preAuth runs before auth (EventSource can't set headers); raw: true streams the response
979
662
  routes: [
980
663
  { preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] },
981
664
  { method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) },
982
665
  ]
983
- ```
984
-
985
- **Per-resource opt-out of `resourcePrefix`** — for webhooks, admin routes:
986
-
987
- ```typescript
988
- defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true })
989
- // Registers at /hooks even with createApp({ resourcePrefix: '/api/v1' })
990
- ```
991
-
992
- ## Enterprise auth (2.13)
993
-
994
- Three opt-in surfaces close the procurement-gate gaps without forcing parallel infrastructure. Sessions / refresh / OAuth flows stay in Better Auth's hands.
995
-
996
- ### SCIM 2.0 — IdP provisioning (`@classytic/arc/scim`)
997
-
998
- 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.
999
-
1000
- ```typescript
1001
- import { scimPlugin } from '@classytic/arc/scim';
1002
-
1003
- await app.register(scimPlugin, {
1004
- users: { resource: userResource },
1005
- groups: { resource: orgResource },
1006
- bearer: process.env.SCIM_TOKEN, // or: verify: async (req) => …
1007
- });
1008
- ```
1009
-
1010
- 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).
1011
666
 
1012
- ### Agent-auth helpers DPoP + capability mandates
667
+ // Per-resource opt-out of resourcePrefix (webhooks / admin routes)
668
+ defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true });
1013
669
 
1014
- For AI-agent flows on protected resources (AP2 / Stripe x402 / MCP authorization). Three new helpers in `@classytic/arc/permissions`:
1015
-
1016
- ```typescript
1017
- import { requireAgentScope, requireMandate, requireDPoP } from '@classytic/arc/permissions';
1018
-
1019
- defineResource({
1020
- name: 'invoice',
1021
- actions: {
1022
- pay: {
1023
- handler: payInvoice,
1024
- permissions: requireAgentScope({
1025
- capability: 'payment.charge',
1026
- scopes: ['payment.write'],
1027
- requireDPoP: true, // RFC 9449 sender-constrained
1028
- audience: (ctx) => `invoice:${ctx.params?.id}`, // mandate must bind to this resource
1029
- validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
1030
- }),
1031
- },
1032
- },
1033
- });
1034
- ```
1035
-
1036
- `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).
1037
-
1038
- ### Auth-event audit bridge (`@classytic/arc/auth/audit`)
1039
-
1040
- 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".
1041
-
1042
- ```typescript
1043
- import { wireBetterAuthAudit } from '@classytic/arc/auth/audit';
1044
-
1045
- const audit = wireBetterAuthAudit({
1046
- events: ['session.*', 'user.*', 'mfa.*', 'org.invite.*'],
1047
- });
1048
-
1049
- const auth = betterAuth({
1050
- hooks: audit.hooks, // endpoint hooks (MFA, OAuth, password reset)
1051
- databaseHooks: audit.databaseHooks, // sign-in/up/out via session.create/delete
1052
- // ...
1053
- });
1054
-
1055
- const app = await createApp({ ... });
1056
- 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' });
1057
673
  ```
1058
674
 
1059
- 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).
1060
-
1061
- ### What's NOT in arc 2.13
1062
-
1063
- 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/).
1064
-
1065
675
  ## Subpath imports
1066
676
 
1067
- The most common imports — full enumeration in [references/api-reference.md](references/api-reference.md).
1068
-
1069
677
  ```typescript
1070
- import { defineResource, BaseController, allowPublic } from '@classytic/arc';
1071
- import { createApp, loadResources } from '@classytic/arc/factory';
1072
- import { createMongooseAdapter } from '@classytic/mongokit/adapter'; // or sqlitekit/adapter, prismakit/adapter
1073
- import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter';
1074
- import { getUserId, getOrgId, requireOrgId } from '@classytic/arc/scope';
1075
- import { mcpPlugin } from '@classytic/arc/mcp';
1076
- 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';
1077
685
  ```
1078
686
 
687
+ Full subpath map → [references/api-reference.md](references/api-reference.md).
688
+
1079
689
  ## References
1080
690
 
1081
691
  - **[api-reference](references/api-reference.md)** — full subpath import map (every plugin, helper, type)
1082
- - **[auth](references/auth.md)** — JWT, Better Auth, API key, custom auth
1083
- - **[scim](references/scim.md)** — SCIM 2.0 plugin (Okta / Azure AD / Google Workspace provisioning)
1084
- - **[agent-auth](references/agent-auth.md)** — DPoP + capability mandates (AP2 / x402 / MCP authorization)
1085
- - **[enterprise-auth](references/enterprise-auth.md)** — what's in vs out of the box for enterprise auth
1086
- - **[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
1087
697
  - **[integrations](references/integrations.md)** — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
1088
- - **[mcp](references/mcp.md)** — MCP tools, custom tools, Better Auth OAuth 2.1
1089
- - **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField`, `PermissionResult.scope`, API key auth
1090
- - **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops
1091
- - **[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