@classytic/arc 2.11.2 → 2.11.4

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