@classytic/arc 2.15.3 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -3036
- 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.d.mts +71 -2
- package/dist/integrations/streamline.mjs +81 -8
- 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 +22 -29
- package/skills/arc/SKILL.md +299 -689
- 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. arc init my-api --mongokit --jwt --ts 2. defineResource({ name, adapter, presets, permissions }) 3. createApp({ preset: 'production', resources, auth })"
|
|
31
26
|
---
|
|
32
27
|
|
|
33
28
|
# @classytic/arc
|
|
34
29
|
|
|
35
|
-
Resource-oriented backend framework for Fastify. **Fastify ≥5.8
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
# Scaffold
|
|
38
|
+
npx @classytic/arc@latest init my-api --mongokit --better-auth --single --ts
|
|
39
|
+
|
|
40
|
+
# Or add to an existing project
|
|
41
|
+
npm install @classytic/arc fastify
|
|
42
|
+
npm install @fastify/cors @fastify/helmet @fastify/rate-limit @fastify/sensible @fastify/under-pressure
|
|
43
|
+
npm install @classytic/mongokit mongoose # or sqlitekit / prismakit / custom
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
## Aggregations — dashboards in declarative form
|
|
342
|
+
## Aggregations
|
|
643
343
|
|
|
644
|
-
|
|
645
|
-
/{prefix}/aggregations/:name` per entry. Each runs a portable `$match
|
|
646
|
-
→ $group → $project → $sort → $limit` pipeline against the kit's
|
|
647
|
-
`repo.aggregate(req, options)` — same shape across mongokit /
|
|
648
|
-
sqlitekit / prismakit, so dashboards work unchanged across backends.
|
|
344
|
+
`GET /:prefix/aggregations/:name` per entry. Portable `$match → $group → $project → $sort → $limit` against `repo.aggregate(req, options)` — same shape across kits.
|
|
649
345
|
|
|
650
346
|
```typescript
|
|
651
347
|
import { defineResource, defineAggregation } from '@classytic/arc';
|
|
@@ -655,7 +351,6 @@ defineResource({
|
|
|
655
351
|
adapter,
|
|
656
352
|
presets: [multiTenantPreset({ tenantField: 'organizationId' })],
|
|
657
353
|
permissions: { list: canViewRevenue() },
|
|
658
|
-
|
|
659
354
|
aggregations: {
|
|
660
355
|
byPaymentMethod: defineAggregation({
|
|
661
356
|
groupBy: 'method',
|
|
@@ -664,145 +359,123 @@ defineResource({
|
|
|
664
359
|
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
665
360
|
permissions: canViewRevenue(),
|
|
666
361
|
}),
|
|
667
|
-
byFlow: defineAggregation({
|
|
668
|
-
groupBy: 'flow',
|
|
669
|
-
measures: { total: 'sum:amount', count: 'count' },
|
|
670
|
-
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
671
|
-
permissions: canViewRevenue(),
|
|
672
|
-
}),
|
|
673
362
|
byDay: defineAggregation({
|
|
674
363
|
dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
|
|
675
364
|
groupBy: 'flow',
|
|
676
365
|
measures: { total: 'sum:amount', count: 'count' },
|
|
677
|
-
sort: { day: 1 },
|
|
678
366
|
requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
|
|
679
|
-
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
|
|
680
367
|
permissions: canViewRevenue(),
|
|
681
368
|
}),
|
|
682
369
|
},
|
|
683
370
|
});
|
|
684
371
|
```
|
|
685
372
|
|
|
686
|
-
|
|
687
|
-
DB-agnostic — type-coercion (string → ObjectId for mongokit
|
|
688
|
-
`fieldType: 'objectId'`, UUID/text for sqlitekit, etc.) belongs to the
|
|
689
|
-
kit. Arc threads `tenantOptions` to `repo.aggregate(req, options)`;
|
|
690
|
-
the kit's multi-tenant plugin reads `context.organizationId`, casts
|
|
691
|
-
correctly, and merges into the request. Authors never inject the
|
|
692
|
-
tenant key into `aggReq.filter` themselves.
|
|
373
|
+
Safety guards on the declaration: `requireFilters`, `requireDateRange { field, maxRangeDays }`, `maxGroups`. Caller query-string filters compose with `groupBy` / measures. SWR + `tags` invalidation tie aggregations to CRUD writes. Every aggregation auto-exports as an MCP tool.
|
|
693
374
|
|
|
694
|
-
|
|
375
|
+
Tenant scope flows through `options` (second arg), NOT into `aggReq.filter`. The kit's multi-tenant plugin handles type coercion (string → ObjectId, UUID/text, etc.). **Aggregation-only resources** (`disableDefaultRoutes: true` + no controller) work — arc falls back to the adapter's repo. If aggregations exist without a repo AND without `materialized`, `defineResource()` throws at boot.
|
|
695
376
|
|
|
696
|
-
|
|
697
|
-
GET /api/transactions/aggregations/byPaymentMethod?status=verified
|
|
698
|
-
GET /api/transactions/aggregations/byDay?createdAt[gte]=2026-01-01&createdAt[lt]=2026-02-01
|
|
699
|
-
```
|
|
377
|
+
### `materialized` hook — escape hatch + footgun
|
|
700
378
|
|
|
701
|
-
|
|
702
|
-
- `requireDateRange: { field, maxRangeDays }` — bounded range mandatory; kills accidental all-time scans
|
|
703
|
-
- `requireFilters: ['orgId']` — mandatory scope keys
|
|
704
|
-
- `maxGroups: 1000` — post-execution row cap; 422 on overflow
|
|
379
|
+
When a kit can't express your aggregation in the portable IR (`$graphLookup`, window functions, custom SQL), declare a `materialized` hook to own dispatch yourself:
|
|
705
380
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
381
|
+
```typescript
|
|
382
|
+
defineAggregation({
|
|
383
|
+
measures: { count: 'count' },
|
|
384
|
+
permissions: canViewRevenue(),
|
|
385
|
+
materialized: async (ctx) => {
|
|
386
|
+
// ctx = { filter, orgId, userId, requestId, query }
|
|
387
|
+
// ⚠️ ctx.filter contains ONLY the declaration filter + caller query string.
|
|
388
|
+
// Tenant scope is in ctx.orgId, NOT in ctx.filter. Soft-delete is NOT merged.
|
|
389
|
+
// Bypassing repo.aggregate() means bypassing the kit's hook pipeline.
|
|
710
390
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
391
|
+
// ✅ Right (mongokit) — route through repo.aggregatePipeline so the kit's
|
|
392
|
+
// multi-tenant + soft-delete + audit hooks all run:
|
|
393
|
+
const rows = await orderRepo.aggregatePipeline(
|
|
394
|
+
[{ $group: { _id: '$flow', total: { $sum: '$amount' } } }],
|
|
395
|
+
{ organizationId: ctx.orgId, userId: ctx.userId, requestId: ctx.requestId },
|
|
396
|
+
);
|
|
397
|
+
return { rows };
|
|
714
398
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
399
|
+
// ❌ Wrong — Model.aggregate(...) bypasses kit plugins; tenant + soft-delete
|
|
400
|
+
// leak across orgs. Never call the driver directly in a materialized hook.
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
For sqlitekit / custom adapters without an `aggregatePipeline` equivalent, **you** must inject tenant + soft-delete clauses into the SQL before executing — `ctx.filter` is not sufficient. Prefer the portable `repo.aggregate(req, options)` path whenever the IR can express your shape.
|
|
718
406
|
|
|
719
407
|
## QueryCache
|
|
720
408
|
|
|
721
|
-
TanStack
|
|
409
|
+
TanStack-Query-style server cache with SWR + auto-invalidation on mutations:
|
|
722
410
|
|
|
723
411
|
```typescript
|
|
724
412
|
const app = await createApp({ arcPlugins: { queryCache: true } });
|
|
725
413
|
|
|
726
414
|
defineResource({
|
|
727
|
-
name: 'product',
|
|
728
415
|
cache: {
|
|
729
|
-
staleTime: 30,
|
|
730
|
-
gcTime: 300,
|
|
731
|
-
tags: ['catalog'],
|
|
416
|
+
staleTime: 30, gcTime: 300, tags: ['catalog'],
|
|
732
417
|
invalidateOn: { 'category.*': ['catalog'] }, // event pattern → tag targets
|
|
733
|
-
list: { staleTime: 60 },
|
|
418
|
+
list: { staleTime: 60 },
|
|
734
419
|
byId: { staleTime: 10 },
|
|
735
420
|
},
|
|
736
421
|
});
|
|
737
422
|
```
|
|
738
423
|
|
|
739
|
-
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`.
|
|
740
425
|
|
|
741
426
|
## Events
|
|
742
427
|
|
|
743
|
-
|
|
428
|
+
CRUD events auto-emit: `{resource}.created` / `{resource}.updated` / `{resource}.deleted`. Manual publish:
|
|
744
429
|
|
|
745
430
|
```typescript
|
|
746
|
-
const app = await createApp({
|
|
747
|
-
stores: { events: new RedisEventTransport(redis) }, // optional
|
|
748
|
-
arcPlugins: { events: { logEvents: true, retry: { maxRetries: 3, backoffMs: 1000 } } },
|
|
749
|
-
});
|
|
750
|
-
|
|
751
431
|
await app.events.publish('order.created', { orderId: '123' });
|
|
752
|
-
await app.events.subscribe('order.*', async (event) => {
|
|
432
|
+
await app.events.subscribe('order.*', async (event) => { … });
|
|
753
433
|
```
|
|
754
434
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
**Transports:** Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, at-least-once, consumer groups, DLQ).
|
|
435
|
+
Transports: Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, consumer groups, DLQ). Event types live in `@classytic/primitives/events` (`createEvent`, `createChildEvent`, `matchEventPattern`, …); arc re-exports the runtime `MemoryEventTransport` only.
|
|
758
436
|
|
|
759
|
-
**
|
|
437
|
+
**Outbox** (at-least-once via transactional outbox):
|
|
760
438
|
|
|
761
|
-
|
|
439
|
+
```typescript
|
|
440
|
+
const outbox = new EventOutbox({ repository: outboxRepo, transport });
|
|
441
|
+
// Dev: { store: new MemoryOutboxStore(), transport }
|
|
442
|
+
```
|
|
762
443
|
|
|
763
|
-
|
|
444
|
+
Full event recipes (retry, DLQ, dual-publish warnings, idempotency keys) → [references/events.md](references/events.md).
|
|
764
445
|
|
|
765
|
-
|
|
446
|
+
## Hooks
|
|
766
447
|
|
|
767
|
-
|
|
448
|
+
Inline on resource. `ResourceHookContext` = `{ data, user?, meta? }`; `meta` has `id` and `existing` for update/delete.
|
|
768
449
|
|
|
769
450
|
```typescript
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
451
|
+
hooks: {
|
|
452
|
+
beforeCreate: async (ctx) => { ctx.data.slug = slugify(ctx.data.name); },
|
|
453
|
+
afterCreate: async (ctx) => { analytics.track('created', { id: ctx.data._id }); },
|
|
454
|
+
beforeUpdate: async (ctx) => { /* ctx.meta.existing has the pre-image */ },
|
|
455
|
+
beforeDelete: async (ctx) => { if (ctx.data.isProtected) throw new Error('locked'); },
|
|
456
|
+
}
|
|
774
457
|
```
|
|
775
458
|
|
|
776
|
-
|
|
459
|
+
App-level (cross-resource): `createHookSystem()` + `beforeCreate(hooks, 'product', fn)` from `@classytic/arc/hooks`.
|
|
777
460
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
**Class-based mappers:**
|
|
461
|
+
## Errors
|
|
781
462
|
|
|
782
463
|
```typescript
|
|
783
|
-
|
|
784
|
-
errorHandler: {
|
|
785
|
-
errorMappers: [{
|
|
786
|
-
type: AccountingError,
|
|
787
|
-
toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
|
|
788
|
-
}],
|
|
789
|
-
},
|
|
790
|
-
});
|
|
791
|
-
```
|
|
464
|
+
import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
|
|
792
465
|
|
|
793
|
-
|
|
466
|
+
throw new NotFoundError('Product'); // 404
|
|
467
|
+
throw createDomainError('SELF_REFERRAL', 'Cannot self-refer', 422, { field });
|
|
468
|
+
```
|
|
794
469
|
|
|
795
|
-
|
|
470
|
+
Wire envelope: `{ code, message, status, meta?, correlationId? }`. HTTP status is the discriminator — no `success` field. Custom mappers:
|
|
796
471
|
|
|
797
472
|
```typescript
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
], { orderId });
|
|
805
|
-
// result: { success, completedSteps, results, failedStep?, error?, compensationErrors? }
|
|
473
|
+
errorHandler: {
|
|
474
|
+
errorMappers: [{
|
|
475
|
+
type: AccountingError,
|
|
476
|
+
toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
|
|
477
|
+
}],
|
|
478
|
+
}
|
|
806
479
|
```
|
|
807
480
|
|
|
808
481
|
## Testing
|
|
@@ -813,7 +486,7 @@ import { createTestApp, expectArc } from '@classytic/arc/testing';
|
|
|
813
486
|
const ctx = await createTestApp({
|
|
814
487
|
resources: [productResource],
|
|
815
488
|
authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
|
|
816
|
-
connectMongoose: true, // in-memory Mongo
|
|
489
|
+
connectMongoose: true, // in-memory Mongo
|
|
817
490
|
});
|
|
818
491
|
ctx.auth.register('admin', { user: { id: '1', role: 'admin' }, orgId: 'org-1' });
|
|
819
492
|
|
|
@@ -823,31 +496,29 @@ const res = await ctx.app.inject({
|
|
|
823
496
|
payload: { name: 'Widget' },
|
|
824
497
|
});
|
|
825
498
|
expectArc(res).ok().hidesField('password');
|
|
826
|
-
|
|
827
499
|
await ctx.close();
|
|
828
500
|
```
|
|
829
501
|
|
|
830
|
-
Three entry points: `createTestApp` (custom
|
|
831
|
-
|
|
832
|
-
Full testing recipes → [references/testing.md](references/testing.md).
|
|
502
|
+
Three entry points: `createTestApp` (custom), `createHttpTestHarness` (~16 auto-generated CRUD/perm/validation tests per resource), `runStorageContract` (adapter conformance). → [references/testing.md](references/testing.md)
|
|
833
503
|
|
|
834
504
|
## CLI
|
|
835
505
|
|
|
836
506
|
```bash
|
|
837
|
-
arc init my-api --mongokit --
|
|
838
|
-
arc generate resource product
|
|
839
|
-
arc generate resource product --mcp
|
|
840
|
-
arc generate mcp analytics
|
|
841
|
-
arc docs ./openapi.json --entry ./dist/index.js
|
|
507
|
+
arc init my-api --mongokit --better-auth --ts # scaffold
|
|
508
|
+
arc generate resource product # alias: arc g r product
|
|
509
|
+
arc generate resource product --mcp # + MCP tools file
|
|
510
|
+
arc generate mcp analytics # standalone MCP file
|
|
511
|
+
arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
|
|
842
512
|
arc introspect --entry ./dist/index.js
|
|
843
|
-
arc
|
|
513
|
+
arc describe ./dist/resources.js --json # JSON metadata
|
|
514
|
+
arc doctor # diagnose env
|
|
844
515
|
```
|
|
845
516
|
|
|
846
517
|
Set `"mcp": true` in `.arcrc` to always generate `.mcp.ts` alongside resources.
|
|
847
518
|
|
|
848
519
|
## MCP (AI agent tools)
|
|
849
520
|
|
|
850
|
-
Resources auto-generate Model Context Protocol tools — same permissions, same field rules. Stateless by default
|
|
521
|
+
Resources auto-generate Model Context Protocol tools — same permissions, same field rules. Stateless by default.
|
|
851
522
|
|
|
852
523
|
```typescript
|
|
853
524
|
import { mcpPlugin } from '@classytic/arc/mcp';
|
|
@@ -858,109 +529,124 @@ await app.register(mcpPlugin, {
|
|
|
858
529
|
exclude: ['credential'],
|
|
859
530
|
overrides: { product: { operations: ['list', 'get'] } },
|
|
860
531
|
});
|
|
861
|
-
|
|
862
|
-
// Stateful — when you need server-initiated messages
|
|
863
|
-
await app.register(mcpPlugin, { resources, stateful: true, sessionTtlMs: 600000 });
|
|
864
532
|
```
|
|
865
533
|
|
|
866
|
-
|
|
534
|
+
**Per-resource opt-out:** `defineResource({ mcp: false })` — exclude a resource from MCP tool generation entirely. Useful for internal-admin or write-heavy resources where you don't want LLMs poking.
|
|
867
535
|
|
|
868
|
-
**Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function
|
|
536
|
+
**Auth modes** — `false` | `getAuth()` (Better Auth OAuth 2.1) | custom function returning `{ userId, organizationId, roles }` (human) or `{ clientId, organizationId, scopes }` (service). `PermissionResult.filters` flow into MCP tools exactly like REST.
|
|
869
537
|
|
|
870
|
-
|
|
871
|
-
// Human user
|
|
872
|
-
auth: async (headers) => {
|
|
873
|
-
if (headers['x-api-key'] !== process.env.MCP_KEY) return null;
|
|
874
|
-
return { userId: 'bot', organizationId: 'org-1', roles: ['admin'] };
|
|
875
|
-
},
|
|
876
|
-
|
|
877
|
-
// Service / machine — produces kind: "service" scope
|
|
878
|
-
auth: async (headers) => ({
|
|
879
|
-
clientId: 'ingestion-pipeline',
|
|
880
|
-
organizationId: 'org-1',
|
|
881
|
-
scopes: ['read:products', 'write:events'],
|
|
882
|
-
}),
|
|
883
|
-
```
|
|
538
|
+
**Custom tools** — co-locate with resources (`order.mcp.ts`), wire via `extraTools`. **AI SDK bridge** — expose AI SDK `tool()` defs via `buildMcpToolsFromBridges([...])`.
|
|
884
539
|
|
|
885
|
-
|
|
540
|
+
Connect Claude CLI: `claude mcp add --transport http my-api http://localhost:3000/mcp`. Full recipes → [references/mcp.md](references/mcp.md).
|
|
886
541
|
|
|
887
|
-
|
|
542
|
+
## Tenant cleanup (GDPR / SOX / HIPAA)
|
|
888
543
|
|
|
889
|
-
|
|
544
|
+
Per-resource strategy + one auth-lifecycle wire-up:
|
|
890
545
|
|
|
891
546
|
```typescript
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
buildTool: (ctx) => buildTriggerJobTool(getUserId(ctx) ?? ''),
|
|
900
|
-
guard: (ctx) => (hasOrg(ctx) ? null : 'Organization scope required'),
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
await app.register(mcpPlugin, {
|
|
904
|
-
resources,
|
|
905
|
-
extraTools: buildMcpToolsFromBridges([triggerJobBridge]),
|
|
547
|
+
defineResource({
|
|
548
|
+
name: 'invoice',
|
|
549
|
+
tenantField: 'organizationId',
|
|
550
|
+
onTenantDelete: {
|
|
551
|
+
strategy: { type: 'anonymize', fields: { customerName: '[REDACTED]', email: null } },
|
|
552
|
+
priority: 50, batchSize: 1000,
|
|
553
|
+
},
|
|
906
554
|
});
|
|
555
|
+
defineResource({ name: 'event', onTenantDelete: { strategy: { type: 'hard' } } });
|
|
556
|
+
defineResource({ name: 'ledger', onTenantDelete: { strategy: { type: 'skip', reason: 'SOX retention' } } });
|
|
557
|
+
|
|
558
|
+
import { cascadeDeleteForOrganization, assertNoTenantData } from '@classytic/arc/registry';
|
|
559
|
+
|
|
560
|
+
betterAuth.org.afterDelete = async ({ organizationId }) => {
|
|
561
|
+
const report = await cascadeDeleteForOrganization(fastify.arc.registry, {
|
|
562
|
+
organizationId, concurrency: 4, logger: fastify.log,
|
|
563
|
+
});
|
|
564
|
+
if (report.failures.length > 0) await alerting.fire({ report });
|
|
565
|
+
};
|
|
907
566
|
```
|
|
908
567
|
|
|
909
|
-
|
|
568
|
+
Strategies: `hard` · `soft` · `anonymize` (`fields` static or `(doc) => value`) · `skip` (`reason` mandatory). Resources without `onTenantDelete` are never touched. **Index `tenantField`** on every cascading resource or chunked SELECTs run O(n²). Compliance smoke: `assertNoTenantData(registry, { organizationId })`.
|
|
910
569
|
|
|
911
|
-
##
|
|
570
|
+
## Enterprise auth (opt-in)
|
|
571
|
+
|
|
572
|
+
| Capability | Surface | Reference |
|
|
573
|
+
|---|---|---|
|
|
574
|
+
| SCIM 2.0 provisioning (Okta / Azure AD / Google) | `@classytic/arc/scim` — `scimPlugin({ users, groups, bearer })` | [references/scim.md](references/scim.md) |
|
|
575
|
+
| Agent mandates + DPoP (AP2 / x402 / MCP authz) | `@classytic/arc/permissions` — `requireAgentScope`, `requireMandate`, `requireDPoP` | [references/agent-auth.md](references/agent-auth.md) |
|
|
576
|
+
| Auth-event audit bridge | `@classytic/arc/auth/audit` — `wireBetterAuthAudit({ events })` | [references/enterprise-auth.md](references/enterprise-auth.md) |
|
|
577
|
+
|
|
578
|
+
Out of scope: SAML (use BA SAML plugin), session storage (BA `secondaryStorage`), DPoP crypto (one `jose.dpop.verify()` in your `authenticate`), device trust (Castle / Stytch).
|
|
579
|
+
|
|
580
|
+
## Audit per-resource
|
|
912
581
|
|
|
913
582
|
```typescript
|
|
914
583
|
await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
|
|
915
584
|
|
|
916
|
-
defineResource({ name: 'order',
|
|
585
|
+
defineResource({ name: 'order', audit: true });
|
|
917
586
|
defineResource({ name: 'payment', audit: { operations: ['delete'] } });
|
|
918
587
|
defineResource({ name: 'product' }); // not audited
|
|
919
588
|
|
|
920
|
-
// Manual
|
|
921
|
-
app.
|
|
922
|
-
await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
|
|
923
|
-
});
|
|
589
|
+
// Manual (MCP tools / read auditing)
|
|
590
|
+
await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
|
|
924
591
|
```
|
|
925
592
|
|
|
926
|
-
##
|
|
593
|
+
## Adapters
|
|
927
594
|
|
|
928
|
-
|
|
929
|
-
import type { ArcRequest } from '@classytic/arc';
|
|
930
|
-
import { envelope, createDomainError } from '@classytic/arc';
|
|
931
|
-
import { getOrgContext } from '@classytic/arc/scope';
|
|
932
|
-
import { roles } from '@classytic/arc/permissions';
|
|
595
|
+
Cross-framework adapter contract lives in `@classytic/repo-core/adapter`. Every kit-specific adapter ships from its kit's `/adapter` subpath; arc itself has zero kit-bound adapters.
|
|
933
596
|
|
|
934
|
-
|
|
935
|
-
|
|
597
|
+
```typescript
|
|
598
|
+
import { createMongooseAdapter } from '@classytic/mongokit/adapter';
|
|
599
|
+
import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter';
|
|
600
|
+
import { createPrismaAdapter } from '@classytic/prismakit/adapter';
|
|
936
601
|
|
|
937
|
-
|
|
938
|
-
|
|
602
|
+
import type { DataAdapter, RepositoryLike, AdapterRepositoryInput }
|
|
603
|
+
from '@classytic/repo-core/adapter';
|
|
604
|
+
// MinimalRepo<TDoc> — 5-method floor (getAll, getById, create, update, delete)
|
|
605
|
+
// StandardRepo<TDoc> — MinimalRepo + optional batch ops, CAS, soft-delete, aggregate, …
|
|
606
|
+
// Arc feature-detects optional methods at call sites; missing methods → 501.
|
|
607
|
+
```
|
|
939
608
|
|
|
940
|
-
|
|
941
|
-
const { userId, organizationId, roles: userRoles, orgRoles } = getOrgContext(request);
|
|
609
|
+
Custom kits implementing `DataAdapter<TDoc>` plug in identically. Kit-native repos plug in **without** `as RepositoryLike` casts.
|
|
942
610
|
|
|
943
|
-
|
|
944
|
-
permissions: { create: roles('admin', 'editor'), delete: roles('admin') }
|
|
945
|
-
```
|
|
611
|
+
## Controllers
|
|
946
612
|
|
|
947
|
-
|
|
613
|
+
`BaseController` is mixin-composed. The auto-built controller covers every preset; you only need a custom one to add domain methods.
|
|
948
614
|
|
|
949
615
|
```typescript
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
616
|
+
import { BaseController, type IRequestContext, type IControllerResponse } from '@classytic/arc';
|
|
617
|
+
|
|
618
|
+
class ProductController extends BaseController<Product> {
|
|
619
|
+
constructor(opts: { tenantField?: string | false; idField?: string } = {}) {
|
|
620
|
+
super(productRepo, { resourceName: 'product', ...opts });
|
|
621
|
+
}
|
|
622
|
+
async getFeatured(req: IRequestContext): Promise<IControllerResponse<Product[]>> {
|
|
623
|
+
return { data: await this.repository.getAll({ filters: { isFeatured: true } }) };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
defineResource({ name: 'product', controller: new ProductController({ tenantField: '_id' }), tenantField: '_id' });
|
|
953
628
|
```
|
|
954
629
|
|
|
955
|
-
|
|
630
|
+
When you pass your own controller, arc **cannot** thread `tenantField` / `schemaOptions` / `idField` / `cache` / `onFieldWriteDenied` into it. Forward via `super()` AND pass to `defineResource()`. Presets that inject controller fields (slugLookup, softDelete, tree) only reach arc's auto-built `BaseController` — extend `BaseController` or drop the preset.
|
|
956
631
|
|
|
957
|
-
**
|
|
632
|
+
**Slim mixins** (no soft-delete/tree/slug/bulk on by default):
|
|
958
633
|
|
|
959
|
-
|
|
634
|
+
```typescript
|
|
635
|
+
import { BaseCrudController, SoftDeleteMixin, BulkMixin, SlugMixin, TreeMixin } from '@classytic/arc';
|
|
636
|
+
class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController)) {}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
## DX helpers
|
|
960
640
|
|
|
961
641
|
```typescript
|
|
642
|
+
import type { ArcRequest } from '@classytic/arc';
|
|
643
|
+
import { envelope } from '@classytic/arc';
|
|
962
644
|
import { multipartBody } from '@classytic/arc/middleware';
|
|
963
645
|
|
|
646
|
+
// Typed request — no `(req as any).user`
|
|
647
|
+
handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
|
|
648
|
+
|
|
649
|
+
// File upload — no-op for JSON requests, safe to always add
|
|
964
650
|
defineResource({
|
|
965
651
|
name: 'product',
|
|
966
652
|
middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5 * 1024 * 1024 })] },
|
|
@@ -971,121 +657,45 @@ defineResource({
|
|
|
971
657
|
},
|
|
972
658
|
},
|
|
973
659
|
});
|
|
974
|
-
```
|
|
975
|
-
|
|
976
|
-
**SSE auth + streaming** — `preAuth` runs before auth (EventSource can't set headers); `raw: true` streams the response:
|
|
977
660
|
|
|
978
|
-
|
|
661
|
+
// SSE — preAuth runs before auth (EventSource can't set headers); raw: true streams the response
|
|
979
662
|
routes: [
|
|
980
663
|
{ preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] },
|
|
981
664
|
{ method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) },
|
|
982
665
|
]
|
|
983
|
-
```
|
|
984
|
-
|
|
985
|
-
**Per-resource opt-out of `resourcePrefix`** — for webhooks, admin routes:
|
|
986
|
-
|
|
987
|
-
```typescript
|
|
988
|
-
defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true })
|
|
989
|
-
// Registers at /hooks even with createApp({ resourcePrefix: '/api/v1' })
|
|
990
|
-
```
|
|
991
|
-
|
|
992
|
-
## Enterprise auth (2.13)
|
|
993
|
-
|
|
994
|
-
Three opt-in surfaces close the procurement-gate gaps without forcing parallel infrastructure. Sessions / refresh / OAuth flows stay in Better Auth's hands.
|
|
995
|
-
|
|
996
|
-
### SCIM 2.0 — IdP provisioning (`@classytic/arc/scim`)
|
|
997
|
-
|
|
998
|
-
Auto-derived `/scim/v2/Users` + `/scim/v2/Groups` from existing arc resources. Okta / Azure AD / Google Workspace / JumpCloud / OneLogin out of the box. No shadow tables.
|
|
999
|
-
|
|
1000
|
-
```typescript
|
|
1001
|
-
import { scimPlugin } from '@classytic/arc/scim';
|
|
1002
|
-
|
|
1003
|
-
await app.register(scimPlugin, {
|
|
1004
|
-
users: { resource: userResource },
|
|
1005
|
-
groups: { resource: orgResource },
|
|
1006
|
-
bearer: process.env.SCIM_TOKEN, // or: verify: async (req) => …
|
|
1007
|
-
});
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
|
-
Mounts `GET/POST/PUT/PATCH/DELETE /scim/v2/Users[/:id]`, same for `Groups`, plus `ServiceProviderConfig` / `ResourceTypes` / `Schemas` discovery. SCIM filter language → arc query DSL. RFC 7644 PatchOp translates to canonical operators (`$set`/`$unset`/`$push`/`$pull`) and flows through `repo.findOneAndUpdate(...)`; PUT goes through `repo.bulkWrite([{ replaceOne }])`. SCIM does **not** run arc's HTTP controller pipeline — audit / multi-tenant / field-policy compose at the kit-plugin layer (`repo.use(...)`) and fire identically for arc REST + SCIM because both surfaces hit the same repository methods. → [references/scim.md](references/scim.md).
|
|
1011
666
|
|
|
1012
|
-
|
|
667
|
+
// Per-resource opt-out of resourcePrefix (webhooks / admin routes)
|
|
668
|
+
defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true });
|
|
1013
669
|
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
import { requireAgentScope, requireMandate, requireDPoP } from '@classytic/arc/permissions';
|
|
1018
|
-
|
|
1019
|
-
defineResource({
|
|
1020
|
-
name: 'invoice',
|
|
1021
|
-
actions: {
|
|
1022
|
-
pay: {
|
|
1023
|
-
handler: payInvoice,
|
|
1024
|
-
permissions: requireAgentScope({
|
|
1025
|
-
capability: 'payment.charge',
|
|
1026
|
-
scopes: ['payment.write'],
|
|
1027
|
-
requireDPoP: true, // RFC 9449 sender-constrained
|
|
1028
|
-
audience: (ctx) => `invoice:${ctx.params?.id}`, // mandate must bind to this resource
|
|
1029
|
-
validateAmount: (ctx, m) => (ctx.data as { amount: number }).amount <= (m.cap ?? 0),
|
|
1030
|
-
}),
|
|
1031
|
-
},
|
|
1032
|
-
},
|
|
1033
|
-
});
|
|
1034
|
-
```
|
|
1035
|
-
|
|
1036
|
-
`RequestScope.service` gains optional `mandate` + `dpopJkt` fields. Your `authenticate` callback verifies the mandate JWT (one `jose.jwtVerify()` call) + DPoP proof (one `jose.dpop.verify()` call) and populates them. Arc validates *what's already proved* against the action — no peer-deps on `jose`. → [references/agent-auth.md](references/agent-auth.md).
|
|
1037
|
-
|
|
1038
|
-
### Auth-event audit bridge (`@classytic/arc/auth/audit`)
|
|
1039
|
-
|
|
1040
|
-
BA's `databaseHooks` + endpoint hooks routed through the existing `auditPlugin` — one canonical row shape for resource AND auth events. Single query for "everything user X did".
|
|
1041
|
-
|
|
1042
|
-
```typescript
|
|
1043
|
-
import { wireBetterAuthAudit } from '@classytic/arc/auth/audit';
|
|
1044
|
-
|
|
1045
|
-
const audit = wireBetterAuthAudit({
|
|
1046
|
-
events: ['session.*', 'user.*', 'mfa.*', 'org.invite.*'],
|
|
1047
|
-
});
|
|
1048
|
-
|
|
1049
|
-
const auth = betterAuth({
|
|
1050
|
-
hooks: audit.hooks, // endpoint hooks (MFA, OAuth, password reset)
|
|
1051
|
-
databaseHooks: audit.databaseHooks, // sign-in/up/out via session.create/delete
|
|
1052
|
-
// ...
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
const app = await createApp({ ... });
|
|
1056
|
-
audit.attach(app); // drains boot-time buffer + connects live logger
|
|
670
|
+
// Reply helpers — opt-in via createApp({ replyHelpers: true })
|
|
671
|
+
return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
|
|
672
|
+
return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
|
|
1057
673
|
```
|
|
1058
674
|
|
|
1059
|
-
Buffered until `attach(app)` is called — works for hosts that build BA before Fastify. Manual `audit.emit({ name, subjectId, ... })` for non-BA flows (webhook signature failures, custom MFA).
|
|
1060
|
-
|
|
1061
|
-
### What's NOT in arc 2.13
|
|
1062
|
-
|
|
1063
|
-
SAML / SCIM-EnterpriseUser / device trust / SOC2 attestations / session storage. Reasons + workarounds → [references/enterprise-auth.md](references/enterprise-auth.md). Compliance control matrix → [`docs/compliance/{soc2,hipaa}.md`](../../docs/compliance/).
|
|
1064
|
-
|
|
1065
675
|
## Subpath imports
|
|
1066
676
|
|
|
1067
|
-
The most common imports — full enumeration in [references/api-reference.md](references/api-reference.md).
|
|
1068
|
-
|
|
1069
677
|
```typescript
|
|
1070
|
-
import { defineResource, BaseController, allowPublic }
|
|
1071
|
-
import { createApp, loadResources }
|
|
1072
|
-
import { createMongooseAdapter }
|
|
1073
|
-
import type { DataAdapter, RepositoryLike }
|
|
1074
|
-
import { getUserId, getOrgId, requireOrgId }
|
|
1075
|
-
import { mcpPlugin }
|
|
1076
|
-
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';
|
|
1077
685
|
```
|
|
1078
686
|
|
|
687
|
+
Full subpath map → [references/api-reference.md](references/api-reference.md).
|
|
688
|
+
|
|
1079
689
|
## References
|
|
1080
690
|
|
|
1081
691
|
- **[api-reference](references/api-reference.md)** — full subpath import map (every plugin, helper, type)
|
|
1082
|
-
- **[auth](references/auth.md)** — JWT, Better Auth, API key, custom auth
|
|
1083
|
-
- **[scim](references/scim.md)** — SCIM 2.0 plugin (Okta / Azure AD / Google Workspace
|
|
1084
|
-
- **[agent-auth](references/agent-auth.md)** — DPoP + capability mandates (AP2 / x402 / MCP
|
|
1085
|
-
- **[enterprise-auth](references/enterprise-auth.md)** — what's in vs out of the box
|
|
1086
|
-
- **[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
|
|
1087
697
|
- **[integrations](references/integrations.md)** — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
|
|
1088
|
-
- **[mcp](references/mcp.md)** —
|
|
1089
|
-
- **[multi-tenancy](references/multi-tenancy.md)** — Scope ladder, `tenantField`,
|
|
1090
|
-
- **[production](references/production.md)** — Health, audit, idempotency, tracing, metrics, versioning, SSE,
|
|
1091
|
-
- **[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
|