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