@classytic/arc 2.11.3 → 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.
- package/README.md +16 -11
- package/dist/EventTransport-BFQjw9pB.mjs +133 -0
- package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.mjs +2 -2
- package/dist/{adapters-D0tT2Tyo.mjs → adapters-DUUiiimH.mjs} +17 -2
- package/dist/audit/index.d.mts +2 -2
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/cache/index.d.mts +3 -3
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +125 -43
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/{core-DnUsRpuX.mjs → core-CbcQRIch.mjs} +15 -10
- package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CIKOcNA7.mjs} +1 -1
- package/dist/{createApp-BFxtdKy6.mjs → createApp-C9bRrqlX.mjs} +4 -6
- package/dist/defineEvent-D1Ky9M1D.mjs +188 -0
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-Cts2-Tfj.mjs} +8 -134
- package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-DDJoNEPL.d.mts} +34 -7
- package/dist/events/index.d.mts +164 -5
- package/dist/events/index.mjs +128 -180
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +204 -31
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- package/dist/{fields-C8Y0XLAu.d.mts → fields-BRjxOAFp.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-6u4_Gg6G.d.mts → index-CXXRbnf8.d.mts} +51 -5
- package/dist/{index-DdQ3O9Pg.d.mts → index-D9t1KNaB.d.mts} +2 -2
- package/dist/{index-BbMrcvGp.d.mts → index-Rg8axYPz.d.mts} +12 -4
- package/dist/{index-BdXnTPRj.d.mts → index-m8mOOlFW.d.mts} +3 -3
- package/dist/{index-BYCqHCVu.d.mts → index-rHjXmJar.d.mts} +3 -3
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +3 -3
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{openapi-BGUn7Ki1.mjs → openapi-D7G1V7ex.mjs} +1 -1
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +2 -2
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +5 -5
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
- package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
- package/dist/redis-stream-xTGxB2bm.d.mts +232 -0
- package/dist/registry/index.d.mts +1 -1
- package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-CxNmI6xF.mjs} +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +1 -1
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-9beEMe25.d.mts → types-BQ9TJQNy.d.mts} +1 -1
- package/dist/{types-BH7dEGvU.d.mts → types-D7KpfiL1.d.mts} +10 -10
- package/dist/utils/index.d.mts +1 -1
- package/dist/{versioning-M9lNLhO8.d.mts → versioning-DsglKfM_.d.mts} +1 -1
- package/package.json +1 -1
- package/skills/arc/SKILL.md +409 -769
- package/dist/redis-stream-CM8TXTix.d.mts +0 -110
- /package/dist/{EventTransport-CfVEGaEl.d.mts → EventTransport-CYNUXdCJ.d.mts} +0 -0
- /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BQQXZ_VR.d.mts} +0 -0
- /package/dist/{errorHandler-Co3lnVmJ.d.mts → errorHandler-DEWmGWPz.d.mts} +0 -0
- /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
- /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
- /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
- /package/dist/{pluralize-BneOJkpi.mjs → pluralize-CWP6MB39.mjs} +0 -0
- /package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-Dy2p4MxS.mjs} +0 -0
- /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
- /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
- /package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-Cp4uKC1U.mjs} +0 -0
- /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
- /package/dist/{types-tgR4Pt8F.d.mts → types-DDyTPc6y.d.mts} +0 -0
- /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
package/skills/arc/SKILL.md
CHANGED
|
@@ -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.
|
|
11
|
+
version: 2.11.4
|
|
12
12
|
license: MIT
|
|
13
13
|
metadata:
|
|
14
14
|
author: Classytic
|
|
15
|
-
version: "2.11.
|
|
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.
|
|
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.
|
|
38
|
+
Resource-oriented backend framework for Fastify. **Fastify ≥5.8.5 · Node ≥22 · ESM only.**
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
One `defineResource()` call → REST + auth + permissions + events + cache + OpenAPI + MCP. Database-agnostic (Mongoose, Drizzle/sqlitekit, custom).
|
|
41
41
|
|
|
42
|
-
##
|
|
42
|
+
## Scaffold a project
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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:
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
**Auto-discover resources:**
|
|
81
92
|
|
|
82
|
-
|
|
93
|
+
```typescript
|
|
94
|
+
import { loadResources } from '@classytic/arc/factory';
|
|
83
95
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 },
|
|
126
|
-
priceMode: { nullable: true },
|
|
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: '/
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
155
|
+
## Active behavior to know about
|
|
227
156
|
|
|
228
|
-
|
|
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
|
-
|
|
168
|
+
Discriminated union on `type`:
|
|
233
169
|
|
|
234
170
|
```typescript
|
|
235
|
-
// Arc JWT (with optional
|
|
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,
|
|
240
|
-
isRevoked: async (decoded) => redis.sismember('revoked', decoded.jti),
|
|
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
|
|
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
|
-
|
|
191
|
+
Decorates `app.authenticate`, `app.optionalAuthenticate`, `app.authorize`.
|
|
258
192
|
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
+
Full auth recipes → [references/auth.md](references/auth.md).
|
|
290
207
|
|
|
291
208
|
## Permissions
|
|
292
209
|
|
|
293
|
-
|
|
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
|
-
//
|
|
302
|
-
|
|
303
|
-
//
|
|
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
|
-
//
|
|
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
|
-
**
|
|
234
|
+
**Custom check:**
|
|
323
235
|
|
|
324
236
|
```typescript
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
**
|
|
343
|
-
(branch, project, region, workspace, department, …):
|
|
243
|
+
**Field-level:**
|
|
344
244
|
|
|
345
245
|
```typescript
|
|
346
|
-
import {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
|
267
|
+
### RequestScope — the auth context
|
|
445
268
|
|
|
446
|
-
Five kinds,
|
|
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)` |
|
|
470
|
-
| `requireServiceScope(scopes)` | ❌ |
|
|
471
|
-
| `requireScopeContext(...)` |
|
|
472
|
-
| `requireTeamMembership()` |
|
|
473
|
-
| `requireOrgInScope(target)` |
|
|
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))
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
301
|
+
### Multi-level tenancy + parent-child org hierarchy
|
|
490
302
|
|
|
491
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
// Single-field (default, backwards compatible)
|
|
537
|
-
presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
|
|
344
|
+
## fieldRules — OpenAPI + AJV in one place
|
|
538
345
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
## routeGuards + defineGuard
|
|
560
360
|
|
|
561
|
-
|
|
361
|
+
Apply guards to **every** route on a resource:
|
|
562
362
|
|
|
563
363
|
```typescript
|
|
564
|
-
|
|
565
|
-
defineResource({ name: 'invoice', ... });
|
|
566
|
-
// → queries auto-scoped: { organizationId: 'org-123' }
|
|
364
|
+
import { defineGuard } from '@classytic/arc/utils';
|
|
567
365
|
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
366
|
+
// Typed guard — resolve 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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
+
## Presets
|
|
584
392
|
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
419
|
+
Use `tenantField: false` for lookup tables, platform settings, cross-org reports, single-tenant apps.
|
|
616
420
|
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
+
### searchPreset (text + vector + embed)
|
|
624
435
|
|
|
625
|
-
Backend-agnostic
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
-
|
|
460
|
+
import { createMongooseAdapter } from '@classytic/arc';
|
|
461
|
+
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
|
|
671
462
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
470
|
+
`createMongooseAdapter` accepts `AdapterRepositoryInput<TDoc>` — kit-native repos (mongokit, sqlitekit) plug in **without** `as RepositoryLike` casts.
|
|
687
471
|
|
|
688
|
-
**
|
|
472
|
+
**Custom adapter** — implement `MinimalRepo` from `@classytic/repo-core/repository`:
|
|
689
473
|
|
|
690
|
-
|
|
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
|
|
481
|
+
## Controllers
|
|
693
482
|
|
|
694
|
-
`BaseController`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
823
|
-
beforeUpdate: async (ctx) => {
|
|
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
|
-
|
|
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
|
|
564
|
+
## Query parsing
|
|
859
565
|
|
|
860
|
-
|
|
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
|
|
866
|
-
GET /products?populate=category
|
|
867
|
-
GET /products?populate[category][select]=name,slug
|
|
868
|
-
GET /products?populate[category][
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
+
adapter,
|
|
889
593
|
queryParser: new QueryParser({
|
|
890
|
-
allowedFilterFields: ['status', 'category', 'orgId'],
|
|
891
|
-
allowedSortFields: ['createdAt', 'price'],
|
|
892
|
-
allowedOperators: ['eq', 'gte', 'lte', 'in'],
|
|
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
|
-
|
|
898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
909
|
-
throw
|
|
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
|
-
|
|
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
|
-
|
|
663
|
+
**Class-based mappers:**
|
|
916
664
|
|
|
917
|
-
|
|
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 },
|
|
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',
|
|
947
|
-
|
|
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',
|
|
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
|
-
|
|
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 --
|
|
966
|
-
arc generate resource product
|
|
967
|
-
arc generate resource product --mcp
|
|
968
|
-
arc generate mcp analytics
|
|
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`
|
|
975
|
-
|
|
976
|
-
## MCP (AI Agent Tools)
|
|
729
|
+
Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
|
|
977
730
|
|
|
978
|
-
|
|
731
|
+
## MCP (AI agent tools)
|
|
979
732
|
|
|
980
|
-
|
|
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** —
|
|
751
|
+
**Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function:
|
|
1000
752
|
|
|
1001
753
|
```typescript
|
|
1002
|
-
// Human user
|
|
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
|
|
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`
|
|
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
|
-
**
|
|
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**
|
|
772
|
+
**AI SDK bridge** — expose AI SDK `tool()` definitions over MCP without duplicating glue:
|
|
1021
773
|
|
|
1022
774
|
```typescript
|
|
1023
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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' });
|
|
801
|
+
defineResource({ name: 'product' }); // not audited
|
|
1111
802
|
|
|
1112
|
-
// Manual custom() for MCP tools /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1148
|
-
import { envelope } from '@classytic/arc';
|
|
820
|
+
// Response envelope
|
|
1149
821
|
reply.send(envelope(data, { total: 100 }));
|
|
1150
822
|
|
|
1151
|
-
// Canonical org extraction
|
|
1152
|
-
|
|
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
|
-
//
|
|
1160
|
-
|
|
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
|
-
|
|
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' });
|
|
1177
|
-
return reply.
|
|
1178
|
-
return reply.fail('
|
|
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
|
-
**
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
|
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,
|
|
896
|
+
import { createStateMachine, CircuitBreaker, withCompensation, defineGuard } from '@classytic/arc/utils';
|
|
1245
897
|
import { defineMigration } from '@classytic/arc/migrations';
|
|
1246
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
|
910
|
+
## References
|
|
1271
911
|
|
|
1272
|
-
- **[auth](references/auth.md)** — JWT, Better Auth, API key
|
|
1273
|
-
- **[events](references/events.md)** — Domain events, transports, retry, outbox
|
|
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
|
|
1276
|
-
- **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField
|
|
1277
|
-
- **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE, QueryCache, bulk ops
|
|
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
|