@classytic/arc
Resource-oriented backend framework for Fastify. Fastify ≥5.8 · Node ≥22 · ESM only.
One defineResource() call → REST + auth + permissions + events + cache + OpenAPI + MCP. Database-agnostic (Mongoose, Drizzle/sqlite, Prisma, custom).
Install
# Scaffold
npx @classytic/arc@latest init my-api --mongokit --better-auth --single --ts
# Or add to an existing project
npm install @classytic/arc fastify
npm install @fastify/cors @fastify/helmet @fastify/rate-limit @fastify/sensible @fastify/under-pressure
npm install @classytic/mongokit mongoose # or sqlitekit / prismakit / custom
init flags: --mongokit | --custom, --better-auth | --jwt, --single | --multi, --ts | --js, --edge, --force, --skip-install.
Boot
import { createApp, loadResources } from '@classytic/arc/factory';
const app = await createApp({
preset: 'production', // production | development | testing | edge
runtime: 'memory', // memory | distributed
resourcePrefix: '/api/v1',
auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
cors: { origin: process.env.ALLOWED_ORIGINS!.split(','), credentials: true },
resources: await loadResources(import.meta.url), // auto-discovers *.resource.ts
arcPlugins: { events: true, queryCache: false, sse: false, caching: true },
stores: { // required when runtime: 'distributed'
events: new RedisEventTransport({ client: redis }),
queryCache: new RedisCacheStore({ client: redis }),
},
});
await app.listen({ port: 8040, host: '0.0.0.0' });
Boot order (fixed): plugins → bootstrap[] → resources → afterResources → onReady.
For async-booted engines, pass resources as a factory so it runs after bootstrap[]:
resources: async (fastify) => {
const engine = await ensureCatalogEngine();
return [buildProductResource(engine)];
}
loadResources(import.meta.url) resolves src/ in dev and dist/ in prod. Use loadResources(import.meta.url, { context: { engine } }) to thread engine handles into (ctx) => defineResource(...) default exports.
defineResource()
import { defineResource, allowPublic, requireRoles, requireAuth } from '@classytic/arc';
import { createMongooseAdapter } from '@classytic/mongokit/adapter';
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
export default defineResource({
name: 'product',
adapter: createMongooseAdapter({
model: ProductModel,
repository: productRepo,
schemaGenerator: buildCrudSchemasFromModel,
}),
presets: ['softDelete', 'slugLookup', { name: 'multiTenant', tenantField: 'organizationId' }],
permissions: {
list: allowPublic(),
get: allowPublic(),
create: requireRoles(['admin']),
update: requireRoles(['admin']),
delete: requireRoles(['admin']),
},
schemaOptions: {
fieldRules: {
name: { minLength: 2, maxLength: 200 },
sku: { pattern: '^[A-Z]{3}-\\d{3}$' },
status: { enum: ['draft', 'active', 'archived'] },
deletedAt: { systemManaged: true }, // framework stamps; strip from body
priceMode: { nullable: true }, // accept null
organizationId: { systemManaged: true, preserveForElevated: true },
},
query: {
allowedPopulate: ['category', 'createdBy'],
filterableFields: { status: { type: 'string' } },
},
},
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] },
routes: [
{ method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
{ method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: requireAuth() },
],
actions: {
approve: { handler: approveOrder, permissions: requireRoles(['admin']) },
},
});
Auto-generated: GET /, GET /:id, POST /, PATCH /:id, DELETE /:id. Presets add their own routes (see table below).
Resource shorthands:
// "fetch-all" reference data — read-only crud (list+get), defaultLimit/maxLimit=1000,
// cache { staleTime: 300, gcTime: 600 }. Explicit narrow flags still win.
defineResource({ name: 'currency', adapter, referenceData: true });
// Service resource — no adapter, no auto-CRUD, no validation, no registry entry.
// Equivalent to disableDefaultRoutes + skipValidation + skipRegistry.
defineResource({ name: 'health', customRoutesOnly: true, routes: [...] });
// Resource-level pagination knobs (no custom queryParser needed):
defineResource({ name: 'pipeline', adapter, defaultLimit: 200, maxLimit: 500 });
400s on ?limit= violations include the cap: message: "Query parameter 'limit' must be <= 500 (got 800) (cap is 500)" + meta: { field: 'limit', cap: 500 }.
fieldRules
| Flag | Effect |
|---|---|
| systemManaged | Strip from body, drop from required[]. Framework stamps the value. |
| preserveForElevated | Elevated admins keep the field on ingest (cross-tenant writes). |
| immutable / immutableAfterCreate | Omit from update body. |
| optional | Strip from required[] without touching properties. |
| nullable | Widen JSON-Schema type to include null. |
| hidden | Block from response projection + OpenAPI. |
| minLength · maxLength · min · max · pattern · enum · description | Map to AJV + OpenAPI. |
Mongoose model constraints take precedence; fieldRules supplements what the model doesn't declare.
Presets
| Preset | Routes added | Config |
|---|---|---|
| softDelete | GET /deleted, POST /:id/restore | { deletedField } |
| slugLookup | GET /slug/:slug | { slugField } |
| tree | GET /tree, GET /:parent/children | { parentField } |
| ownedByUser | (middleware only) | { ownerField } |
| multiTenant | (middleware only) | { tenantField } or { tenantFields: [...] } |
| audited | (middleware only) | — |
| bulk | POST/PATCH/DELETE /bulk | { operations?, maxCreateItems? } |
| filesUpload | POST /upload, GET /:id, DELETE /:id | { storage, sanitizeFilename?, allowedMimeTypes?, maxFileSize? } |
| search | POST /search, /search-similar, /embed | { repository?, search?, similar?, embed? } |
presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
tenantField: false disables the silent org filter — use it for lookup tables, platform settings, cross-org reports.
idField picks the lookup column for :id (default _id). URL segment is always :id. A 404 on PATCH /agents/foo when GET works is usually a permission filters clause excluding the row — not an idField bug.
Permissions
A PermissionCheck returns boolean | { granted, reason?, filters?, scope? }. filters propagate into the repo query (row-level ABAC). scope stamps attributes downstream.
import {
allowPublic, requireAuth, requireRoles, requireOwnership,
requireOrgMembership, requireOrgRole, requireTeamMembership,
requireServiceScope, requireScopeContext, requireOrgInScope,
allOf, anyOf, when, denyAll,
createDynamicPermissionMatrix,
} from '@classytic/arc/permissions';
permissions: {
list: allowPublic(),
create: requireRoles(['admin', 'editor']),
update: anyOf(requireOwnership('userId'), requireRoles(['admin'])),
delete: allOf(requireAuth(), requireRoles(['admin'])),
bulkImport: anyOf(requireOrgRole('admin'), requireServiceScope('jobs:bulk-write')),
}
Custom check:
const requirePro = (): PermissionCheck => async (ctx) => {
if (!ctx.user) return { granted: false, reason: 'Auth required' };
return { granted: ctx.user.plan === 'pro' };
};
Field-level:
import { fields } from '@classytic/arc/permissions';
fields: {
password: fields.hidden(),
salary: fields.visibleTo(['admin', 'hr']),
role: fields.writableBy(['admin']),
email: fields.redactFor(['viewer'], '***'),
}
Dynamic ACL (DB-backed):
const acl = createDynamicPermissionMatrix({
resolveRolePermissions: (ctx) => aclService.getRoleMatrix(ctx.user.orgId),
cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
});
permissions: { list: acl.canAction('product', 'read') }
requireRoles() checks BOTH platform roles (user.role) and org roles (scope.orgRoles). For mixed human + machine routes, combine with requireServiceScope(...) via anyOf(...).
RequestScope
Five kinds, populated by your auth function. Always read via accessors from @classytic/arc/scope — never direct property access.
type RequestScope =
| { kind: 'public' }
| { kind: 'authenticated'; userId?; userRoles? }
| { kind: 'member'; userId?; userRoles; organizationId; orgRoles; teamId?; context?; ancestorOrgIds? }
| { kind: 'service'; clientId; organizationId; scopes?; context?; ancestorOrgIds?; mandate?; dpopJkt? }
| { kind: 'elevated'; userId?; organizationId?; elevatedBy; context?; ancestorOrgIds? };
import {
isMember, isService, isElevated, hasOrgAccess,
getOrgId, getUserId, getUserRoles, getOrgRoles, getServiceScopes,
getScopeContext, getAncestorOrgIds, isOrgInScope,
requireOrgId, requireUserId, // throwing accessors for handler boundaries
} from '@classytic/arc/scope';
| Helper | member | service | elevated |
|---|---|---|---|
| requireOrgMembership() | ✅ | ✅ | ✅ |
| requireOrgRole(roles) | role match | ❌ | bypass |
| requireServiceScope(scopes) | ❌ | scope match | bypass |
| requireTeamMembership() | teamId set | n/a | bypass |
| requireOrgInScope(target) | target in chain | target in chain | bypass |
Populate scope.context / scope.ancestorOrgIds in your auth function for branch/region scoping and parent-child org chains. Then multiTenantPreset({ tenantFields: [...] }) auto-filters by every dimension. → references/multi-tenancy.md
Authentication
Discriminated union on type:
auth: { type: 'jwt', jwt: { secret, expiresIn: '15m' } } // arc JWT
auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth }) } // Better Auth
auth: { type: 'custom', plugin: myAuthPlugin } // any Fastify plugin
auth: { type: 'authenticator', authenticate: async (req, reply) => { … } } // ad-hoc fn
auth: false // internal services
Decorates app.authenticate / app.optionalAuthenticate / app.authorize.
Better Auth is arc's recommended path for SaaS with orgs. Kit overlays read whatever BA plugins you enabled (organization, twoFactor, admin, bearer, apiKey from @better-auth/api-key). Bulk-stub populate models with registerBetterAuthStubs(mongoose, { plugins, extraCollections }); per-resource overlay with createBetterAuthOverlay({ auth, mongoose, collection }). Sqlitekit is symmetric. Full recipes (multi-plugin matrix, API-key flow, write path) → references/auth.md.
Authoritative gotchas
- Field-write reject (default). Requests carrying non-writable fields → 403 with denied list. Opt into silent strip:
defineResource({ onFieldWriteDenied: 'strip' }). - multiTenant injects org on UPDATE. Body
organizationIdis overwritten with caller's scope (closes tenant-hop). request.userisRecord<string, unknown> | undefined. Guard withif (req.user)on public routes.- Arc reads singular
user.role(string, comma-separated, or array). Don't put a pluralrolesfield on the model. verifySignature(body, …)throws on parsed body — passreq.rawBody.- MCP tools fail-closed. A resource action without per-action perm + no
actionPermissions+ nopermissions.updatefallback → throws at tool generation. DeclareallowPublic()to opt into unauthenticated. - Better Auth roles. BA stores
member.role = "admin,recruiter"(comma-separated). Arc splits intoscope.orgRoles = ['admin', 'recruiter'];?role=adminwon't match — userole[like]=admin.
routeGuards + defineGuard
Apply guards to every route on a resource (CRUD + custom + preset):
import { defineGuard } from '@classytic/arc/utils';
const tenantGuard = defineGuard({
name: 'tenant',
resolve: (req) => {
const orgId = req.headers['x-org-id'] as string;
if (!orgId) throw new Error('Missing x-org-id');
return { orgId, actorId: req.user?.id ?? 'system' };
},
});
defineResource({
name: 'procurement',
routeGuards: [tenantGuard.preHandler],
routes: [{
method: 'GET', path: '/summary', raw: true, permissions: requireAuth(),
handler: async (req, reply) => {
const { orgId } = tenantGuard.from(req);
reply.send({ orgId });
},
}],
});
Order: auth → permissions → cache/idempotency → routeGuards → per-route preHandler.
Query parsing
GET /products?page=2&limit=20&sort=-createdAt&select=name,price
GET /products?price[gte]=100&status[in]=active,featured&search=keyword
GET /products?after=<cursor_id>&limit=20 # keyset
GET /products?populate[category][select]=name,slug
GET /products?filter[status]=active&filter[price][gte]=100 # bracket envelope (2.16)
Operators: eq, ne, gt, gte, lt, lte, in, nin, like, regex, exists.
Whitelisted parser (MCP filter auto-derive):
import { QueryParser } from '@classytic/mongokit';
defineResource({
queryParser: new QueryParser({
allowedFilterFields: ['status', 'category', 'orgId'],
allowedSortFields: ['createdAt', 'price'],
allowedOperators: ['eq', 'gte', 'lte', 'in'],
}),
schemaOptions: { query: { allowedPopulate: ['category'], allowedLookups: ['categories'] } },
});
Aggregations
GET /:prefix/aggregations/:name per entry. Portable $match → $group → $project → $sort → $limit against repo.aggregate(req, options) — same shape across kits.
import { defineResource, defineAggregation } from '@classytic/arc';
defineResource({
name: 'transaction',
adapter,
presets: [multiTenantPreset({ tenantField: 'organizationId' })],
permissions: { list: canViewRevenue() },
aggregations: {
byPaymentMethod: defineAggregation({
groupBy: 'method',
measures: { total: 'sum:amount', count: 'count' },
sort: { total: -1 },
cache: { staleTime: 60, swr: true, tags: ['revenue'] },
permissions: canViewRevenue(),
}),
byDay: defineAggregation({
dateBuckets: { day: { field: 'createdAt', interval: 'day' } },
groupBy: 'flow',
measures: { total: 'sum:amount', count: 'count' },
requireDateRange: { field: 'createdAt', maxRangeDays: 365 },
permissions: canViewRevenue(),
}),
},
});
Safety guards on the declaration: requireFilters, requireDateRange { field, maxRangeDays }, maxGroups. Caller query-string filters compose with groupBy / measures. SWR + tags invalidation tie aggregations to CRUD writes. Every aggregation auto-exports as an MCP tool.
Tenant scope flows through options (second arg), NOT into aggReq.filter. The kit's multi-tenant plugin handles type coercion (string → ObjectId, UUID/text, etc.). Aggregation-only resources (disableDefaultRoutes: true + no controller) work — arc falls back to the adapter's repo. If aggregations exist without a repo AND without materialized, defineResource() throws at boot.
materialized hook — escape hatch + footgun
When a kit can't express your aggregation in the portable IR ($graphLookup, window functions, custom SQL), declare a materialized hook to own dispatch yourself:
defineAggregation({
measures: { count: 'count' },
permissions: canViewRevenue(),
materialized: async (ctx) => {
// ctx = { filter, orgId, userId, requestId, query }
// ⚠️ ctx.filter contains ONLY the declaration filter + caller query string.
// Tenant scope is in ctx.orgId, NOT in ctx.filter. Soft-delete is NOT merged.
// Bypassing repo.aggregate() means bypassing the kit's hook pipeline.
// ✅ Right (mongokit) — route through repo.aggregatePipeline so the kit's
// multi-tenant + soft-delete + audit hooks all run:
const rows = await orderRepo.aggregatePipeline(
[{ $group: { _id: '$flow', total: { $sum: '$amount' } } }],
{ organizationId: ctx.orgId, userId: ctx.userId, requestId: ctx.requestId },
);
return { rows };
// ❌ Wrong — Model.aggregate(...) bypasses kit plugins; tenant + soft-delete
// leak across orgs. Never call the driver directly in a materialized hook.
},
});
For sqlitekit / custom adapters without an aggregatePipeline equivalent, you must inject tenant + soft-delete clauses into the SQL before executing — ctx.filter is not sufficient. Prefer the portable repo.aggregate(req, options) path whenever the IR can express your shape.
QueryCache
TanStack-Query-style server cache with SWR + auto-invalidation on mutations:
const app = await createApp({ arcPlugins: { queryCache: true } });
defineResource({
cache: {
staleTime: 30, gcTime: 300, tags: ['catalog'],
invalidateOn: { 'category.*': ['catalog'] }, // event pattern → tag targets
list: { staleTime: 60 },
byId: { staleTime: 10 },
},
});
POST/PATCH/DELETE bumps resource version. Response header: x-cache: HIT | STALE | MISS. runtime: 'distributed' requires stores.queryCache: RedisCacheStore.
Events
CRUD events auto-emit: {resource}.created / {resource}.updated / {resource}.deleted. Manual publish:
await app.events.publish('order.created', { orderId: '123' });
await app.events.subscribe('order.*', async (event) => { … });
Transports: Memory · Redis Pub/Sub (fire-and-forget) · Redis Streams (durable, consumer groups, DLQ). Event types live in @classytic/primitives/events (createEvent, createChildEvent, matchEventPattern, …); arc re-exports the runtime MemoryEventTransport only.
Outbox (at-least-once via transactional outbox):
const outbox = new EventOutbox({ repository: outboxRepo, transport });
// Dev: { store: new MemoryOutboxStore(), transport }
Full event recipes (retry, DLQ, dual-publish warnings, idempotency keys) → references/events.md.
Hooks
Inline on resource. ResourceHookContext = { data, user?, meta? }; meta has id and existing for update/delete.
hooks: {
beforeCreate: async (ctx) => { ctx.data.slug = slugify(ctx.data.name); },
afterCreate: async (ctx) => { analytics.track('created', { id: ctx.data._id }); },
beforeUpdate: async (ctx) => { /* ctx.meta.existing has the pre-image */ },
beforeDelete: async (ctx) => { if (ctx.data.isProtected) throw new Error('locked'); },
}
App-level (cross-resource): createHookSystem() + beforeCreate(hooks, 'product', fn) from @classytic/arc/hooks.
Errors
import { ArcError, NotFoundError, ValidationError, createDomainError } from '@classytic/arc';
throw new NotFoundError('Product'); // 404
throw createDomainError('SELF_REFERRAL', 'Cannot self-refer', 422, { field });
Wire envelope: { code, message, status, meta?, correlationId? }. HTTP status is the discriminator — no success field. Custom mappers:
errorHandler: {
errorMappers: [{
type: AccountingError,
toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
}],
}
Testing
import { createTestApp, expectArc } from '@classytic/arc/testing';
const ctx = await createTestApp({
resources: [productResource],
authMode: 'jwt', // 'jwt' | 'better-auth' | 'none'
connectMongoose: true, // in-memory Mongo
});
ctx.auth.register('admin', { user: { id: '1', role: 'admin' }, orgId: 'org-1' });
const res = await ctx.app.inject({
method: 'POST', url: '/products',
headers: ctx.auth.as('admin').headers,
payload: { name: 'Widget' },
});
expectArc(res).ok().hidesField('password');
await ctx.close();
Three entry points: createTestApp (custom), createHttpTestHarness (~16 auto-generated CRUD/perm/validation tests per resource), runStorageContract (adapter conformance). → references/testing.md
CLI
arc init my-api --mongokit --better-auth --ts # scaffold
arc generate resource product # alias: arc g r product
arc generate resource product --mcp # + MCP tools file
arc generate mcp analytics # standalone MCP file
arc docs ./openapi.json --entry ./dist/index.js # emit OpenAPI
arc introspect --entry ./dist/index.js
arc describe ./dist/resources.js --json # JSON metadata
arc doctor # diagnose env
Set "mcp": true in .arcrc to always generate .mcp.ts alongside resources.
MCP (AI agent tools)
Resources auto-generate Model Context Protocol tools — same permissions, same field rules. Stateless by default.
import { mcpPlugin } from '@classytic/arc/mcp';
await app.register(mcpPlugin, {
resources: [productResource, orderResource],
auth: false, // or: getAuth() | custom function
exclude: ['credential'],
overrides: { product: { operations: ['list', 'get'] } },
});
Per-resource opt-out: defineResource({ mcp: false }) — exclude a resource from MCP tool generation entirely. Useful for internal-admin or write-heavy resources where you don't want LLMs poking.
Auth modes — false | getAuth() (Better Auth OAuth 2.1) | createMcpAuthFromBetterAuthApiKey(getAuth(), { orgFromMetadata: true }) (BA API-key plugin — handles referenceId/userId fallback + metadata.organizationId extraction) | custom function returning { userId, organizationId, roles } (human) or { clientId, organizationId, scopes } (service). PermissionResult.filters flow into MCP tools exactly like REST.
Tool-name collisions — preset vs user (e.g. softDelete route vs actions.restore) auto-namespace to softdelete_restore_<resource>; every other collision shape throws ArcError('arc.mcp.tool_name_collision') naming both sources.
Custom tools — co-locate with resources (order.mcp.ts), wire via extraTools. AI SDK bridge — expose AI SDK tool() defs via buildMcpToolsFromBridges([...]).
Connect Claude CLI: claude mcp add --transport http my-api http://localhost:3000/mcp. Full recipes → references/mcp.md.
AI SDK streaming → Fastify
streamResponse: true routes that return a Web ReadableStream (Vercel AI SDK's result.toUIMessageStream(), fetch().body) auto-pipe through pipeUIMessageStreamToReply() — no JsonToSseTransformStream boilerplate.
import { streamText } from 'ai';
defineResource({
name: 'chat',
customRoutesOnly: true,
routes: [{
method: 'POST', path: '/', streamResponse: true, raw: true,
permissions: requireAuth(),
handler: async (req) => {
const result = streamText({ model, messages: req.body.messages });
return result.toUIMessageStream(); // arc pipes it for you
},
}],
});
For manual control: import { pipeUIMessageStreamToReply, UI_MESSAGE_STREAM_HEADERS } from '@classytic/arc/utils'. Client disconnect cancels the source stream (no zombie LLM spend).
Tenant cleanup (GDPR / SOX / HIPAA)
Per-resource strategy + one auth-lifecycle wire-up:
defineResource({
name: 'invoice',
tenantField: 'organizationId',
onTenantDelete: {
strategy: { type: 'anonymize', fields: { customerName: '[REDACTED]', email: null } },
priority: 50, batchSize: 1000,
},
});
defineResource({ name: 'event', onTenantDelete: { strategy: { type: 'hard' } } });
defineResource({ name: 'ledger', onTenantDelete: { strategy: { type: 'skip', reason: 'SOX retention' } } });
import { cascadeDeleteForOrganization, assertNoTenantData } from '@classytic/arc/registry';
betterAuth.org.afterDelete = async ({ organizationId }) => {
const report = await cascadeDeleteForOrganization(fastify.arc.registry, {
organizationId, concurrency: 4, logger: fastify.log,
});
if (report.failures.length > 0) await alerting.fire({ report });
};
Strategies: hard · soft · anonymize (fields static or (doc) => value) · skip (reason mandatory). Resources without onTenantDelete are never touched. Index tenantField on every cascading resource or chunked SELECTs run O(n²). Compliance smoke: assertNoTenantData(registry, { organizationId }).
Enterprise auth (opt-in)
| Capability | Surface | Reference |
|---|---|---|
| SCIM 2.0 provisioning (Okta / Azure AD / Google) | @classytic/arc/scim — scimPlugin({ users, groups, bearer }) | references/scim.md |
| Agent mandates + DPoP (AP2 / x402 / MCP authz) | @classytic/arc/permissions — requireAgentScope, requireMandate, requireDPoP | references/agent-auth.md |
| Auth-event audit bridge | @classytic/arc/auth/audit — wireBetterAuthAudit({ events }) | references/enterprise-auth.md |
Out of scope: SAML (use BA SAML plugin), session storage (BA secondaryStorage), DPoP crypto (one jose.dpop.verify() in your authenticate), device trust (Castle / Stytch).
Audit per-resource
await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
defineResource({ name: 'order', audit: true });
defineResource({ name: 'payment', audit: { operations: ['delete'] } });
defineResource({ name: 'product' }); // not audited
// Manual (MCP tools / read auditing)
await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
Adapters
Cross-framework adapter contract lives in @classytic/repo-core/adapter. Every kit-specific adapter ships from its kit's /adapter subpath; arc itself has zero kit-bound adapters.
import { createMongooseAdapter } from '@classytic/mongokit/adapter';
import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter';
import { createPrismaAdapter } from '@classytic/prismakit/adapter';
import type { DataAdapter, RepositoryLike, AdapterRepositoryInput }
from '@classytic/repo-core/adapter';
// MinimalRepo<TDoc> — 5-method floor (getAll, getById, create, update, delete)
// StandardRepo<TDoc> — MinimalRepo + optional batch ops, CAS, soft-delete, aggregate, …
// Arc feature-detects optional methods at call sites; missing methods → 501.
Custom kits implementing DataAdapter<TDoc> plug in identically. Kit-native repos plug in without as RepositoryLike casts.
Controllers
BaseController is mixin-composed. The auto-built controller covers every preset; you only need a custom one to add domain methods.
import { BaseController, type IRequestContext, type IControllerResponse } from '@classytic/arc';
class ProductController extends BaseController<Product> {
constructor(opts: { tenantField?: string | false; idField?: string } = {}) {
super(productRepo, { resourceName: 'product', ...opts });
}
async getFeatured(req: IRequestContext): Promise<IControllerResponse<Product[]>> {
return { data: await this.repository.getAll({ filters: { isFeatured: true } }) };
}
}
defineResource({ name: 'product', controller: new ProductController({ tenantField: '_id' }), tenantField: '_id' });
When you pass your own controller, arc cannot thread tenantField / schemaOptions / idField / cache / onFieldWriteDenied into it. Forward via super() AND pass to defineResource(). Presets that inject controller fields (slugLookup, softDelete, tree) only reach arc's auto-built BaseController — extend BaseController or drop the preset.
Slim mixins (no soft-delete/tree/slug/bulk on by default):
import { BaseCrudController, SoftDeleteMixin, BulkMixin, SlugMixin, TreeMixin } from '@classytic/arc';
class OrderController extends SoftDeleteMixin(BulkMixin(BaseCrudController)) {}
DX helpers
import type { ArcRequest } from '@classytic/arc';
import { envelope } from '@classytic/arc';
import { multipartBody } from '@classytic/arc/middleware';
// Typed request — no `(req as any).user`
handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
// File upload — no-op for JSON requests, safe to always add
defineResource({
name: 'product',
middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5 * 1024 * 1024 })] },
hooks: {
'before:create': async (data) => {
if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
return data;
},
},
});
// SSE — preAuth runs before auth (EventSource can't set headers); raw: true streams the response
routes: [
{ preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] },
{ method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) },
]
// Per-resource opt-out of resourcePrefix (webhooks / admin routes)
defineResource({ name: 'webhook', prefix: '/hooks', skipGlobalPrefix: true });
// Reply helpers — opt-in via createApp({ replyHelpers: true })
return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
return reply.stream(csvReadable, { contentType: 'text/csv', filename: 'export.csv' });
Subpath imports
import { defineResource, BaseController, allowPublic } from '@classytic/arc';
import { createApp, loadResources } from '@classytic/arc/factory';
import { createMongooseAdapter } from '@classytic/mongokit/adapter';
import type { DataAdapter, RepositoryLike } from '@classytic/repo-core/adapter';
import { getUserId, getOrgId, requireOrgId } from '@classytic/arc/scope';
import { mcpPlugin } from '@classytic/arc/mcp';
import { createTestApp, expectArc } from '@classytic/arc/testing';
Full subpath map → references/api-reference.md.
References
- api-reference — full subpath import map (every plugin, helper, type)
- auth — JWT, Better Auth, API key, custom auth, kit overlays
- scim — SCIM 2.0 plugin (Okta / Azure AD / Google Workspace)
- agent-auth — DPoP + capability mandates (AP2 / x402 / MCP authz)
- enterprise-auth — what's in vs out of the box
- events — Transports, retry, outbox, idempotency
- integrations — BullMQ jobs, WebSocket, EventGateway, Streamline, Webhooks
- mcp — Tool generation, custom tools, AI SDK bridges, Better Auth OAuth 2.1
- multi-tenancy — Scope ladder,
tenantField, parent-child orgs, API-key auth - production — Health, audit, idempotency, tracing, metrics, versioning, SSE, bulk ops
- testing — Test app, mocks, fixtures, in-memory Mongo
微信扫一扫