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