@classytic/arc 2.22.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/{BaseController-C6FlRWxF.mjs → BaseController-BQ8KNvWC.mjs} +3 -2
  2. package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
  3. package/dist/{actionPermissions-ze70xXJi.mjs → actionPermissions-lszoJle1.mjs} +1 -1
  4. package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
  5. package/dist/audit/index.d.mts +1 -1
  6. package/dist/auth/index.d.mts +1 -1
  7. package/dist/auth/index.mjs +2 -2
  8. package/dist/cli/commands/describe.d.mts +1 -1
  9. package/dist/cli/commands/describe.mjs +1 -1
  10. package/dist/cli/commands/docs.mjs +2 -2
  11. package/dist/cli/commands/init.mjs +1 -1
  12. package/dist/cli/commands/introspect.mjs +1 -1
  13. package/dist/cli/index.mjs +1 -1
  14. package/dist/core/index.d.mts +2 -2
  15. package/dist/core/index.mjs +3 -3
  16. package/dist/{core-b6fgFgnd.mjs → core-DQ6rDTyH.mjs} +10 -9
  17. package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-BuOv-m4Y.mjs} +3 -3
  18. package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-CgJv_BU_.mjs} +1 -1
  19. package/dist/{createApp-Ci7yG5gv.mjs → createApp-CjOeWJ9_.mjs} +11 -2
  20. package/dist/docs/index.d.mts +1 -1
  21. package/dist/docs/index.mjs +1 -1
  22. package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
  23. package/dist/events/index.mjs +5 -2
  24. package/dist/factory/index.d.mts +20 -4
  25. package/dist/factory/index.mjs +31 -11
  26. package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-g_f9omSc.d.mts} +1 -1
  27. package/dist/hooks/index.d.mts +1 -1
  28. package/dist/idempotency/index.d.mts +7 -0
  29. package/dist/idempotency/index.mjs +5 -17
  30. package/dist/{index-DbtgCOCj.d.mts → index-BBsvQt7U.d.mts} +26 -4
  31. package/dist/{index-BEu4wjbL.d.mts → index-Ba10fdKp.d.mts} +11 -2
  32. package/dist/{index-D8MKYGDa.d.mts → index-CC5ruDGh.d.mts} +2 -2
  33. package/dist/{index-ogIHvOjo.d.mts → index-wMoWBLkn.d.mts} +67 -1
  34. package/dist/index.d.mts +4 -4
  35. package/dist/index.mjs +6 -5
  36. package/dist/{init-DleK3p9f.mjs → init-CjO4p_q1.mjs} +5 -2
  37. package/dist/integrations/event-gateway.mjs +1 -1
  38. package/dist/integrations/index.d.mts +1 -1
  39. package/dist/integrations/mcp/index.d.mts +66 -3
  40. package/dist/integrations/mcp/index.mjs +1 -1
  41. package/dist/integrations/mcp/testing.d.mts +1 -1
  42. package/dist/integrations/mcp/testing.mjs +1 -1
  43. package/dist/integrations/webhooks.d.mts +123 -3
  44. package/dist/integrations/webhooks.mjs +160 -5
  45. package/dist/interface-n5hpKg1d.mjs +16 -0
  46. package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
  47. package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-BtzIL_kM.mjs} +308 -39
  48. package/dist/middleware/index.d.mts +1 -1
  49. package/dist/{openapi-BRpMZD7C.mjs → openapi-jSDx-VDv.mjs} +3 -3
  50. package/dist/permissions/index.d.mts +2 -2
  51. package/dist/permissions/index.mjs +3 -2
  52. package/dist/{permissions-BJMuKMl7.mjs → permissions-tbxFHxjZ.mjs} +77 -264
  53. package/dist/pipeline/index.d.mts +1 -1
  54. package/dist/plugins/index.d.mts +6 -6
  55. package/dist/plugins/index.mjs +5 -4
  56. package/dist/plugins/tracing-entry.mjs +1 -1
  57. package/dist/presets/filesUpload.d.mts +1 -1
  58. package/dist/presets/filesUpload.mjs +1 -1
  59. package/dist/presets/index.d.mts +1 -1
  60. package/dist/presets/index.mjs +1 -1
  61. package/dist/presets/multiTenant.d.mts +1 -1
  62. package/dist/presets/search.d.mts +1 -1
  63. package/dist/presets/search.mjs +1 -1
  64. package/dist/{presets-gw0ciIUD.mjs → presets-BOe4Ugkl.mjs} +1 -1
  65. package/dist/realtime-I8OrfnMR.mjs +375 -0
  66. package/dist/{registerRoute-xHc42fwB.mjs → registerRoute-CWO6mdYe.mjs} +1 -1
  67. package/dist/registry/index.d.mts +2 -2
  68. package/dist/registry/index.mjs +1 -1
  69. package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
  70. package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
  71. package/dist/scope/index.mjs +1 -1
  72. package/dist/scope-LONQ3mfZ.mjs +37 -0
  73. package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
  74. package/dist/{sse-CfUjwjDk.mjs → sse-0u4P4-OI.mjs} +7 -30
  75. package/dist/sseStream-BQ7Qaynp.mjs +57 -0
  76. package/dist/testing/index.d.mts +2 -2
  77. package/dist/testing/index.mjs +2 -2
  78. package/dist/types/index.d.mts +1 -1
  79. package/dist/{types-Bc42CUyk.d.mts → types-5OuqeTDX.d.mts} +37 -2
  80. package/dist/utils/index.d.mts +62 -2
  81. package/dist/utils/index.mjs +5 -3
  82. package/dist/{utils-FhJ88TJE.mjs → utils-CZyeRXTh.mjs} +21 -69
  83. package/dist/{versioning-DLwklPlG.d.mts → versioning-D6PvCwpq.d.mts} +43 -4
  84. package/package.json +64 -4
  85. package/skills/arc/references/production.md +734 -696
  86. package/dist/scope-k15RXfcq.mjs +0 -64
@@ -1,696 +1,734 @@
1
- # Arc Production Features
2
-
3
- Health checks, audit trail, idempotency, tracing, SSE, caching, graceful shutdown.
4
-
5
- ## v2.9 Security Defaults
6
-
7
- - **Field-write perms reject by default** — `defineResource({ fields, onFieldWriteDenied })`. Default `'reject'` → 403 with denied field list. Legacy silent-strip: `onFieldWriteDenied: 'strip'`.
8
- - **Elevation always emits `arc.scope.elevated`** — subscribe via `fastify.events.subscribe('arc.scope.elevated', handler)` for audit. `onElevation` callback still supported.
9
- - **multiTenant injects org on UPDATE** — closes cross-tenant hop vector. Body-supplied `organizationId` overwritten with caller's scope.
10
- - **`verifySignature(body, secret, sig)` throws TypeError** if body isn't string/Buffer — register `@fastify/raw-body` before webhook routes; pass `req.rawBody`.
11
- - **Upload `sanitizeFilename` (preset)** — strict by default; `false` / `'*'` / custom fn to relax per adapter needs.
12
- - **Idempotency `namespace`** — fold an env key into the fingerprint when multiple deployments share a Redis (prod + canary, api + jobs).
13
-
14
- ## Health Plugin
15
-
16
- Kubernetes-ready liveness/readiness probes:
17
-
18
- ```typescript
19
- import { healthPlugin } from '@classytic/arc/plugins';
20
-
21
- await fastify.register(healthPlugin, {
22
- prefix: '/_health',
23
- version: '1.0.0',
24
- checks: [
25
- { name: 'mongodb', check: () => mongoose.connection.readyState === 1, critical: true },
26
- { name: 'redis', check: async () => await redis.ping() === 'PONG', timeout: 3000 },
27
- ],
28
- });
29
-
30
- // GET /_health/live → { status: 'ok', timestamp, version }
31
- // GET /_health/ready → { status: 'ok', checks: { mongodb: { status: 'ok', latency: 5 } } }
32
- // Returns 503 if critical check fails
33
- ```
34
-
35
- ## Request ID Plugin
36
-
37
- ```typescript
38
- import { requestIdPlugin } from '@classytic/arc/plugins';
39
-
40
- await fastify.register(requestIdPlugin, {
41
- header: 'x-request-id',
42
- setResponseHeader: true,
43
- generator: () => crypto.randomUUID(),
44
- });
45
- ```
46
-
47
- ## Graceful Shutdown Plugin
48
-
49
- ```typescript
50
- import { gracefulShutdownPlugin } from '@classytic/arc/plugins';
51
-
52
- await fastify.register(gracefulShutdownPlugin, {
53
- timeout: 30000,
54
- signals: ['SIGTERM', 'SIGINT'],
55
- logEvents: true,
56
- onShutdown: async () => {
57
- await mongoose.disconnect();
58
- await redis.quit();
59
- },
60
- });
61
-
62
- // Sequence: receive signal → stop accepting connections → wait for in-flight
63
- // → run onShutdown → close Fastify → exit process
64
- ```
65
-
66
- ## Audit Plugin
67
-
68
- Change tracking. Pass a `RepositoryLike` for persistence, or omit for in-memory dev:
69
-
70
- ```typescript
71
- import { auditPlugin } from '@classytic/arc/audit';
72
- import { Repository } from '@classytic/mongokit';
73
-
74
- // Development (in-memory)
75
- await fastify.register(auditPlugin, { enabled: true });
76
-
77
- // Production — any kit (mongokit / prismakit / custom)
78
- await fastify.register(auditPlugin, {
79
- enabled: true,
80
- repository: new Repository(AuditModel),
81
- });
82
- // ── Retention (audit rows ARE `history: true` timelines — prune together) ──
83
- // Option A (zero code, Mongo): TTL index — the DB does it server-side.
84
- // AuditSchema.index({ timestamp: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 365 });
85
- // Option B (built-in, single instance): retention: { maxAgeMs, purgeIntervalMs }.
86
- // Option C (multi-replica, RECOMMENDED): purgeIntervalMs: 0 + a schedulesPlugin
87
- // entry calling fastify.audit.purge(cutoff) — leader-safe, jittered, observable.
88
- // (The built-in timer runs on EVERY replica — idempotent but wasteful.)
89
- //
90
- // ── Indexes (kit territory — the audit model is yours) ──
91
- // Mongo: TTL index above + compound for the history timeline:
92
- // AuditSchema.index({ resource: 1, documentId: 1, timestamp: -1 });
93
- // SQL kits: index (resource, document_id, timestamp) + the kit's ttl/sweep plugin.
94
- //
95
- // ── Rich search (admin audit-log UI) ──
96
- // audit.query() is deliberately narrow. For full repo-core filter/sort/keyset
97
- // power + MCP tools, mount the audit repo AS a read-only resource:
98
- // defineResource({ name: 'audit-log', crud: { list: true, get: true },
99
- // adapter: { repository: auditRepo },
100
- // queryParser: new QueryParser({ allowedFilterFields: ['resource','documentId','userId','action','organizationId'] }),
101
- // permissions: { list: requireRoles(['auditor']), get: requireRoles(['auditor']) } });
102
-
103
- // Usage
104
- await fastify.audit.create('product', product._id, product, request.auditContext);
105
- await fastify.audit.update('product', id, beforeDoc, afterDoc, request.auditContext);
106
- await fastify.audit.delete('product', id, deletedDoc, request.auditContext);
107
- await fastify.audit.custom('product', id, 'price_changed', { oldPrice: 100, newPrice: 150 }, ctx);
108
-
109
- // Query
110
- const entries = await fastify.audit.query({
111
- resource: 'product', documentId: 'prod-123',
112
- action: 'update', from: new Date('2024-01-01'), limit: 100,
113
- });
114
- ```
115
-
116
- **Audit entry:**
117
-
118
- ```typescript
119
- interface AuditEntry {
120
- id: string; timestamp: Date;
121
- action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
122
- resource: string; documentId: string;
123
- before?: Record<string, unknown>;
124
- after?: Record<string, unknown>;
125
- metadata?: Record<string, unknown>;
126
- context: { user?; organizationId?; requestId?; ipAddress?; userAgent?; };
127
- }
128
- ```
129
-
130
- **Custom store:** Implement `AuditStore` interface (`log`, `query`, `close`).
131
-
132
- ## Idempotency Plugin
133
-
134
- Exactly-once semantics for mutating operations:
135
-
136
- ```typescript
137
- import { idempotencyPlugin } from '@classytic/arc/idempotency';
138
-
139
- await fastify.register(idempotencyPlugin, {
140
- enabled: true,
141
- headerName: 'idempotency-key', // Default
142
- ttlMs: 86400000, // 24 hours
143
- lockTimeoutMs: 30000,
144
- methods: ['POST', 'PUT', 'PATCH'],
145
- include: [/\/orders/], // Only these routes
146
- exclude: [/\/health/],
147
- retryAfterSeconds: 1,
148
- });
149
- ```
150
-
151
- **Client:**
152
-
153
- ```typescript
154
- fetch('/api/orders', {
155
- method: 'POST',
156
- headers: { 'Idempotency-Key': 'order-abc123-uuid' },
157
- body: JSON.stringify({ items: [...] }),
158
- });
159
- // First request: processes, caches response
160
- // Retry: returns cached response + x-idempotency-replayed: true
161
- // Concurrent: returns 409 Conflict + Retry-After: 1
162
- ```
163
-
164
- **Storage backends:**
165
-
166
- ```typescript
167
- // Memory (default, dev) — omit both `repository` and `store`
168
- import { MemoryIdempotencyStore } from '@classytic/arc/idempotency';
169
-
170
- // Redis (production, multi-instance)
171
- import { RedisIdempotencyStore } from '@classytic/arc/idempotency/redis';
172
- store: new RedisIdempotencyStore({ client: redis, prefix: 'idem:', ttlMs: 86400000 })
173
-
174
- // DB-backed via RepositoryLike (mongokit / prismakit / custom)
175
- import { Repository, methodRegistryPlugin, batchOperationsPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
176
- repository: new Repository(IdempotencyModel, [
177
- methodRegistryPlugin(), batchOperationsPlugin(), mongoOperationsPlugin(),
178
- ])
179
- ```
180
-
181
- **IdempotencyStore interface:**
182
-
183
- ```typescript
184
- interface IdempotencyStore {
185
- readonly name: string;
186
- get(key: string): Promise<IdempotencyResult | undefined>;
187
- set(key: string, result): Promise<void>;
188
- tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
189
- unlock(key: string, requestId: string): Promise<void>;
190
- isLocked(key: string): Promise<boolean>;
191
- delete(key: string): Promise<void>;
192
- close?(): Promise<void>;
193
- }
194
- ```
195
-
196
- ## OpenTelemetry Tracing
197
-
198
- ```typescript
199
- import { tracingPlugin } from '@classytic/arc/plugins/tracing';
200
-
201
- await fastify.register(tracingPlugin, {
202
- serviceName: 'my-api',
203
- exporterUrl: 'http://localhost:4318/v1/traces',
204
- sampleRate: 0.1, // Trace 10% of requests
205
- });
206
-
207
- // Custom spans
208
- import { createSpan } from '@classytic/arc/plugins/tracing';
209
-
210
- return createSpan(req, 'processPayment', async (span) => {
211
- span.setAttribute('orderId', order._id);
212
- return await processPayment(order);
213
- });
214
- ```
215
-
216
- ## QueryCache (Server Cache)
217
-
218
- TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
219
-
220
- ```typescript
221
- // Enable globally
222
- const app = await createApp({
223
- arcPlugins: { queryCache: true }, // Memory store, zero config
224
- });
225
-
226
- // Per-resource config
227
- defineResource({
228
- name: 'product',
229
- cache: {
230
- staleTime: 30, // seconds fresh (no revalidation)
231
- gcTime: 300, // seconds stale data kept (SWR window)
232
- tags: ['catalog'], // cross-resource grouping
233
- invalidateOn: { 'category.*': ['catalog'] }, // event → tag invalidation
234
- },
235
- });
236
- ```
237
-
238
- **How it works:**
239
- - `GET` → cached with `x-cache: HIT | STALE | MISS` header
240
- - `POST/PATCH/DELETE` → auto-bumps resource version, invalidating cached queries
241
- - Cross-resource: category mutation bumps `catalog` tag → products cache invalidated
242
- - Multi-tenant safe: cache keys scoped by userId + orgId
243
-
244
- **Runtime modes:**
245
-
246
- | Mode | Store | Config |
247
- |------|-------|--------|
248
- | `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
249
- | `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
250
-
251
- ## Response Cache Plugin
252
-
253
- ```typescript
254
- import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
255
-
256
- await fastify.register(responseCachePlugin, {
257
- // ETag generation + Cache-Control headers per route
258
- });
259
- ```
260
-
261
- **Note:** When QueryCache is active for a resource, response-cache is automatically skipped for that resource's GET routes.
262
-
263
- ## SSE Plugin (Server-Sent Events)
264
-
265
- Bridges Arc domain events to SSE streams. Requires `eventPlugin` (auto-registered by factory).
266
-
267
- ```typescript
268
- // Via factory (recommended)
269
- const app = await createApp({
270
- arcPlugins: {
271
- sse: {
272
- path: '/events/stream', // SSE endpoint (default: '/events/stream')
273
- requireAuth: true, // Fail-closed auth (default: true)
274
- patterns: ['order.*', 'product.*'], // Event patterns to stream (default: ['*'])
275
- orgScoped: false, // Filter events by org from request.scope (default: false)
276
- heartbeat: 30000, // Heartbeat interval in ms (default: 30000)
277
- },
278
- },
279
- });
280
-
281
- // Manual registration
282
- import { ssePlugin } from '@classytic/arc/plugins';
283
- await fastify.register(ssePlugin, { requireAuth: true, orgScoped: true });
284
- ```
285
-
286
- **Fail-closed auth:** When `requireAuth: true` (default), throws at registration if `fastify.authenticate` is missing — prevents exposing SSE without auth.
287
-
288
- **Org-scoped:** When `orgScoped: true`, events with `organizationId` are only sent to clients whose `request.scope` matches. Prevents cross-tenant leakage.
289
-
290
- ## Error Handler Plugin
291
-
292
- ```typescript
293
- import { errorHandlerPlugin } from '@classytic/arc/plugins';
294
-
295
- // Global error handling — catches all errors, logs, formats response
296
- // Auto-registered by createApp()
297
- ```
298
-
299
- ### Error Mappers (v2.7.3)
300
-
301
- Class-based domain error → HTTP response mapping. Register once, handlers just throw:
302
-
303
- ```typescript
304
- class AccountingError extends Error {
305
- constructor(message: string, public status: number, public code: string) { super(message); }
306
- }
307
-
308
- const app = await createApp({
309
- errorHandler: {
310
- errorMappers: [{
311
- type: AccountingError,
312
- toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
313
- }],
314
- },
315
- });
316
- // Now handlers just throw — Arc catches and maps automatically
317
- ```
318
-
319
- Mappers are checked via `instanceof` before all other error classification. Multiple mappers supported — first match wins.
320
-
321
- ## Reply Helpers
322
-
323
- Opt-in `sendList` + `stream` decorators: `createApp({ replyHelpers: true })`. Arc emits raw data on success — no `{ success, data }` envelope; HTTP status discriminates — so single-doc handlers just `return doc` (or `reply.send(doc)`) and errors throw `ArcError` (the global handler serialises to `ErrorContract`). The two decorators below cover the cases that DO need framework support:
324
-
325
- ```typescript
326
- // List response — any kit-shaped paginated result OR a bare array → canonical wire shape
327
- return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
328
- return reply.sendList(rows); // bare array endpoints
329
- ```
330
-
331
- ### Streaming Responses
332
-
333
- ```typescript
334
- // CSV export
335
- return reply.stream(csvReadableStream, { contentType: 'text/csv', filename: 'export.csv' });
336
- // PDF download
337
- return reply.stream(pdfBuffer, { contentType: 'application/pdf', filename: 'report.pdf' });
338
- // Raw stream (Fastify native — works without reply helpers too)
339
- return reply.header('content-type', 'text/csv').send(readableStream);
340
- ```
341
-
342
- ### BigInt Serialization
343
-
344
- Opt-in: `createApp({ serializeBigInt: true })` — auto-converts BigInt → Number in all JSON responses.
345
-
346
- ## Multipart Body Middleware (v2.7.3)
347
-
348
- Opt-in file upload support for CRUD routes — parses multipart form fields into `req.body`, attaches files to `req.body._files`:
349
-
350
- ```typescript
351
- import { multipartBody } from '@classytic/arc/middleware';
352
-
353
- defineResource({
354
- middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5_000_000 })] },
355
- hooks: {
356
- 'before:create': async (data) => {
357
- if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
358
- return data;
359
- },
360
- },
361
- });
362
- ```
363
-
364
- No-op for JSON requests — safe to always add. Options: `maxFileSize`, `maxFiles`, `allowedMimeTypes`, `filesKey`.
365
-
366
- ## Migrations
367
-
368
- ```typescript
369
- import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
370
-
371
- const v2 = defineMigration({
372
- version: 2,
373
- resource: 'product',
374
- up: async (db) => {
375
- await db.collection('products').updateMany({}, { $rename: { oldField: 'newField' } });
376
- },
377
- down: async (db) => {
378
- await db.collection('products').updateMany({}, { $rename: { newField: 'oldField' } });
379
- },
380
- });
381
-
382
- const runner = new MigrationRunner(mongoose.connection.db);
383
- await runner.up([v2]);
384
- ```
385
-
386
- ## OpenAPI & External Paths
387
-
388
- Arc auto-generates OpenAPI 3.0 specs from resource definitions. External integrations (auth adapters, custom routes) inject their paths via `ExternalOpenApiPaths`.
389
-
390
- ```typescript
391
- import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
392
-
393
- const externalPaths: ExternalOpenApiPaths = {
394
- paths: { '/api/auth/sign-in': { post: { summary: 'Sign in', ... } } },
395
- schemas: { User: { type: 'object', properties: { ... } } },
396
- securitySchemes: {
397
- cookieAuth: { type: 'apiKey', in: 'cookie', name: 'session_token' },
398
- },
399
- tags: [{ name: 'Authentication' }],
400
- // Declare additional security alternatives for Arc resource paths
401
- resourceSecurity: [{ apiKeyAuth: [], orgHeader: [] }],
402
- };
403
- ```
404
-
405
- **`resourceSecurity`** — declarative registration of auth alternatives for resource paths:
406
- - Each array item is **OR**'d with `bearerAuth` (the default)
407
- - Keys within the same object are **AND**'d (all required together)
408
- - Example: `[{ apiKeyAuth: [], orgHeader: [] }]` → "bearer OR (api-key AND org-header)"
409
-
410
- Arc's Better Auth adapter (`extractBetterAuthOpenApi`) auto-populates `resourceSecurity` when the `apiKey()` plugin is detected — no manual configuration needed.
411
-
412
- **Security scheme definitions:**
413
-
414
- | Scheme | Type | Source | Always present? |
415
- |--------|------|--------|-----------------|
416
- | `bearerAuth` | HTTP Bearer | Arc core | Yes |
417
- | `orgHeader` | API Key (`x-organization-id`) | Arc core | Yes (multi-tenant) |
418
- | `cookieAuth` | API Key (cookie) | Better Auth adapter | When Better Auth active |
419
- | `apiKeyAuth` | API Key (`x-api-key`) | Better Auth adapter | When `apiKey()` plugin active |
420
-
421
- ## Deployment
422
-
423
- Arc requires Node.js APIs (`node:crypto`, `AsyncLocalStorage`). Use `toFetchHandler()` for serverless/edge.
424
-
425
- | Environment | Preset | Handler | Notes |
426
- |-------------|--------|---------|-------|
427
- | Docker/K8s | `production` | `app.listen()` | Full production |
428
- | Google Cloud Run | `production` | `app.listen()` | Set min-instances > 0 for WebSocket |
429
- | Railway/Render/Fly.io | `production` | `app.listen()` | Works with zero config |
430
- | AWS Lambda | `edge` | `toFetchHandler()` | Node.js runtime |
431
- | Vercel Serverless | `edge` | `toFetchHandler()` | Node.js runtime |
432
- | Cloudflare Workers | `edge` | `toFetchHandler()` | Enable `nodejs_compat` in wrangler.toml |
433
-
434
- **Edge handler:**
435
- ```typescript
436
- import { createApp, toFetchHandler } from '@classytic/arc/factory';
437
- const app = await createApp({ preset: 'edge', auth: { type: 'jwt', jwt: { secret } } });
438
- export default { fetch: toFetchHandler(app) }; // Cloudflare Workers / any fetch-based runtime
439
- ```
440
-
441
- **Production checklist:**
442
-
443
- ```typescript
444
- // Validate env vars at startup
445
- if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error('...');
446
-
447
- const app = await createApp({
448
- preset: 'production',
449
- auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
450
- cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [], credentials: true },
451
- rateLimit: { max: 100, timeWindow: '1 minute' },
452
- arcPlugins: { queryCache: true },
453
- });
454
-
455
- process.on('SIGTERM', () => app.close());
456
- process.on('SIGINT', () => app.close());
457
- ```
458
-
459
- ## Distributed Runtime
460
-
461
- `runtime: 'distributed'` only validates stores you actually enable:
462
-
463
- - `stores.events` — always required
464
- - `stores.cache` — only when `arcPlugins.caching` enabled
465
- - `stores.queryCache` — only when `arcPlugins.queryCache` enabled
466
- - `stores.idempotency` — never validated (per-resource opt-in)
467
-
468
- ## Under-Pressure
469
-
470
- Production preset: `maxEventLoopDelay: 3000` (avoids false 503s on Render/Railway/Fly.io). Override: `underPressure: { maxEventLoopDelay: 500 }`. Disable: `underPressure: false`.
471
-
472
- ## CORS
473
-
474
- Production warns (not throws) when origin missing. `origin: '*'` from env is allowed. Smart CORS auto-converts `credentials: true` + `origin: '*'` to `origin: true`, and (2.22) auto-merges arc's protocol headers (`x-organization-id`, `x-arc-scope`, `x-request-id`) into any host-declared `allowedHeaders` allow-list — hosts never enumerate framework internals.
475
-
476
- ### Mobile apps (Capacitor / Ionic / React Native webviews)
477
-
478
- Arc needs no special mode — mobile is a CORS + auth-transport recipe:
479
-
480
- ```typescript
481
- cors: {
482
- // Webview origins alongside your web domains. Also add these to
483
- // Better Auth's trustedOrigins when using BA.
484
- origin: [...webOrigins, 'https://localhost', 'capacitor://localhost'],
485
- credentials: true,
486
- // Optional allow-list — arc's own headers auto-merge (2.22); you only
487
- // list YOUR custom ones. Omit entirely to use reflection mode.
488
- allowedHeaders: ['Content-Type', 'Authorization'],
489
- // BA bearer plugin returns the fresh session token in this RESPONSE
490
- // header — without exposing it, cross-origin JS cannot READ it and
491
- // app login dies silently. The one mobile knob arc can't infer.
492
- exposedHeaders: ['set-auth-token'],
493
- },
494
- trustProxy: true, // webviews arrive through your LB/tunnel
495
- ```
496
-
497
- Native (non-webview) HTTP clients send no `Origin` header — CORS doesn't apply to them at all; bearer auth just works.
498
-
499
- ## Metrics Plugin
500
-
501
- Prometheus-compatible metrics endpoint — zero external dependencies:
502
-
503
- ```typescript
504
- import { metricsPlugin } from '@classytic/arc/plugins';
505
-
506
- await fastify.register(metricsPlugin, {
507
- path: '/_metrics', // default
508
- prefix: 'arc', // metric name prefix (default: 'arc')
509
- onCollect: (metrics) => pushToOTLP(metrics), // optional OTLP push
510
- });
511
-
512
- // GET /_metrics → Prometheus text format
513
- ```
514
-
515
- **Built-in counters**: `arc_http_requests_total`, `arc_http_request_duration_seconds`, `arc_crud_operations_total`, `arc_cache_hits_total`, `arc_cache_misses_total`, `arc_events_published_total`, `arc_events_consumed_total`, `arc_circuit_breaker_state`.
516
-
517
- **Programmatic access**: `fastify.metrics.recordOperation(resource, op, status, durationMs)`, `.recordCacheHit(resource)`, `.recordEventPublish(type)`, `.reset()`.
518
-
519
- ## API Versioning Plugin
520
-
521
- Header-based or URL prefix-based versioning with deprecation warnings:
522
-
523
- ```typescript
524
- import { versioningPlugin } from '@classytic/arc/plugins';
525
-
526
- // Header-based: clients send Accept-Version: 2
527
- await fastify.register(versioningPlugin, {
528
- type: 'header',
529
- deprecated: ['1'],
530
- sunset: '2025-12-01',
531
- });
532
-
533
- // Prefix-based: /v2/products
534
- await fastify.register(versioningPlugin, { type: 'prefix' });
535
- ```
536
-
537
- **Headers set**: `x-api-version` on every response. `Deprecation: true` + `Sunset` for deprecated versions. Access via `request.apiVersion`.
538
-
539
- ## Per-Tenant Rate Limiting
540
-
541
- Scope-aware rate limit key generator — isolates rate limits by org, user, or IP:
542
-
543
- ```typescript
544
- import { createTenantKeyGenerator } from '@classytic/arc/scope';
545
-
546
- const app = await createApp({
547
- rateLimit: {
548
- max: 100,
549
- timeWindow: '1 minute',
550
- keyGenerator: createTenantKeyGenerator(),
551
- },
552
- });
553
- ```
554
-
555
- **Key resolution**: `member` → `organizationId`, `authenticated` → `userId`, `elevated` → `organizationId ?? userId`, `public` → IP. Custom strategy: `createTenantKeyGenerator({ strategy: (ctx) => ctx.ip })`.
556
-
557
- ## Event Outbox
558
-
559
- Transactional outbox pattern — at-least-once delivery even if transport is down:
560
-
561
- ```typescript
562
- import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
563
- import { Repository } from '@classytic/mongokit';
564
-
565
- // Dev
566
- const outbox = new EventOutbox({ store: new MemoryOutboxStore(), transport: redisTransport });
567
-
568
- // Production — any RepositoryLike
569
- const outbox = new EventOutbox({
570
- repository: new Repository(OutboxModel),
571
- transport: redisTransport,
572
- });
573
-
574
- // In business logic (same DB transaction via `{ session }`)
575
- await outbox.store(event, { session });
576
-
577
- // Relay cron (runs every few seconds)
578
- const relayed = await outbox.relay(); // publishes pending → transport
579
- ```
580
-
581
- **OutboxStore interface**: `save(event)`, `getPending(limit)`, `acknowledge(eventId)` (+ optional `claimPending`, `fail`, `getDeadLettered`, `purge`). When you pass `repository`, arc adapts it internally.
582
-
583
- ## Bulk Operations Preset
584
-
585
- Adds bulk CRUD routes — repository must provide `createMany`, `updateMany`, `deleteMany`:
586
-
587
- ```typescript
588
- import { createMongooseAdapter } from '@classytic/mongokit/adapter';
589
- import { buildCrudSchemasFromModel } from '@classytic/mongokit';
590
-
591
- defineResource({
592
- name: 'product',
593
- adapter: createMongooseAdapter({ model, repository, schemaGenerator: buildCrudSchemasFromModel }),
594
- presets: ['bulk'], // adds POST/PATCH/DELETE /{resource}/bulk
595
- });
596
-
597
- // Or with options
598
- presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems: 500 })]
599
- ```
600
-
601
- **Routes**: `POST /bulk` (body: `{ items }`) → `createMany`, `PATCH /bulk` (body: `{ filter, data }`) → `updateMany`, `DELETE /bulk` (body: `{ filter }`) → `deleteMany`. Permissions inherit from `create`/`update`/`delete`.
602
-
603
- ## Compensating Transaction
604
-
605
- In-process rollback primitive — runs steps in order, compensates in reverse on failure.
606
- For distributed sagas across services, use Temporal, Inngest, or Streamline.
607
-
608
- ```typescript
609
- import { withCompensation } from '@classytic/arc/utils';
610
-
611
- const result = await withCompensation('place-order', [
612
- {
613
- name: 'reserve-inventory',
614
- execute: async (ctx) => {
615
- const res = await inventoryService.reserve(ctx.items);
616
- ctx.reservationId = res.id;
617
- return res;
618
- },
619
- compensate: async (_ctx, result) => {
620
- await inventoryService.release(result.id);
621
- },
622
- },
623
- {
624
- name: 'charge-payment',
625
- execute: async (ctx) => await paymentService.charge(ctx.total),
626
- compensate: async (_ctx, result) => await paymentService.refund(result.chargeId),
627
- },
628
- {
629
- name: 'send-confirmation',
630
- execute: async (ctx) => await emailService.send(ctx.email),
631
- // No compensate — emails can't be unsent
632
- },
633
- ], { items: cart.items, total: cart.total, email: user.email });
634
-
635
- if (!result.success) {
636
- console.error(`Saga failed at ${result.failedStep}: ${result.error}`);
637
- // Compensation already ran for completed steps
638
- }
639
- ```
640
-
641
- **`defineCompensation()`** — reusable definition:
642
-
643
- ```typescript
644
- const placeOrder = defineCompensation('place-order', steps);
645
- await placeOrder.execute({ items, total, email });
646
- ```
647
-
648
- **Fire-and-forget steps** — don't block, don't compensate, errors swallowed:
649
-
650
- ```typescript
651
- await withCompensation('checkout', [
652
- { name: 'save-order', execute: saveOrder, compensate: cancelOrder },
653
- { name: 'send-email', execute: sendEmail, fireAndForget: true }, // non-blocking
654
- { name: 'charge', execute: chargeCard, compensate: refundCard },
655
- ]);
656
- // 'charge' runs immediately after 'save-order' — doesn't wait for email
657
- ```
658
-
659
- **Lifecycle hooks** — wire to Arc events, logging, or metrics:
660
-
661
- ```typescript
662
- await withCompensation('checkout', steps, { orderId }, {
663
- onStepComplete: (stepName, result) => {
664
- fastify.events.publish(`checkout.${stepName}.completed`, result);
665
- },
666
- onStepFailed: (stepName, error) => {
667
- fastify.events.publish(`checkout.${stepName}.failed`, { error: error.message });
668
- },
669
- onCompensate: (stepName) => {
670
- fastify.log.warn(`Compensated: ${stepName}`);
671
- },
672
- });
673
- ```
674
-
675
- **In a custom route with Arc auth:**
676
-
677
- ```typescript
678
- defineResource({
679
- name: 'order',
680
- routes: [{
681
- method: 'POST',
682
- path: '/:id/checkout',
683
- permissions: requireAuth(),
684
- raw: true,
685
- handler: async (request, reply) => {
686
- const result = await withCompensation('checkout', steps, { orderId: request.params.id });
687
- if (!result.success) return reply.code(422).send({ error: result.error });
688
- return reply.send({ data: result.results });
689
- },
690
- }],
691
- });
692
- ```
693
-
694
- **Compensation errors**: collected in `result.compensationErrors[]` without stopping rollback. **Context**: mutable `Record<string, unknown>` shared across all steps.
695
-
696
- **Scope**: In-process primitive. Process crash = no compensation. For durable distributed workflows, use Temporal, Inngest, or `@classytic/streamline`.
1
+ # Arc Production Features
2
+
3
+ Health checks, audit trail, idempotency, tracing, SSE, caching, graceful shutdown.
4
+
5
+ ## v2.9 Security Defaults
6
+
7
+ - **Field-write perms reject by default** — `defineResource({ fields, onFieldWriteDenied })`. Default `'reject'` → 403 with denied field list. Legacy silent-strip: `onFieldWriteDenied: 'strip'`.
8
+ - **Elevation always emits `arc.scope.elevated`** — subscribe via `fastify.events.subscribe('arc.scope.elevated', handler)` for audit. `onElevation` callback still supported.
9
+ - **multiTenant injects org on UPDATE** — closes cross-tenant hop vector. Body-supplied `organizationId` overwritten with caller's scope.
10
+ - **`verifySignature(body, secret, sig)` throws TypeError** if body isn't string/Buffer — register `@fastify/raw-body` before webhook routes; pass `req.rawBody`.
11
+ - **Upload `sanitizeFilename` (preset)** — strict by default; `false` / `'*'` / custom fn to relax per adapter needs.
12
+ - **Idempotency `namespace`** — fold an env key into the fingerprint when multiple deployments share a Redis (prod + canary, api + jobs).
13
+
14
+ ## Health Plugin
15
+
16
+ Kubernetes-ready liveness/readiness probes:
17
+
18
+ ```typescript
19
+ import { healthPlugin } from '@classytic/arc/plugins';
20
+
21
+ await fastify.register(healthPlugin, {
22
+ prefix: '/_health',
23
+ version: '1.0.0',
24
+ checks: [
25
+ { name: 'mongodb', check: () => mongoose.connection.readyState === 1, critical: true },
26
+ { name: 'redis', check: async () => await redis.ping() === 'PONG', timeout: 3000 },
27
+ ],
28
+ });
29
+
30
+ // GET /_health/live → { status: 'ok', timestamp, version }
31
+ // GET /_health/ready → { status: 'ok', checks: { mongodb: { status: 'ok', latency: 5 } } }
32
+ // Returns 503 if critical check fails
33
+ ```
34
+
35
+ ## Request ID Plugin
36
+
37
+ ```typescript
38
+ import { requestIdPlugin } from '@classytic/arc/plugins';
39
+
40
+ await fastify.register(requestIdPlugin, {
41
+ header: 'x-request-id',
42
+ setResponseHeader: true,
43
+ generator: () => crypto.randomUUID(),
44
+ });
45
+ ```
46
+
47
+ ## Graceful Shutdown Plugin
48
+
49
+ ```typescript
50
+ import { gracefulShutdownPlugin } from '@classytic/arc/plugins';
51
+
52
+ await fastify.register(gracefulShutdownPlugin, {
53
+ timeout: 30000,
54
+ signals: ['SIGTERM', 'SIGINT'],
55
+ logEvents: true,
56
+ onShutdown: async () => {
57
+ await mongoose.disconnect();
58
+ await redis.quit();
59
+ },
60
+ });
61
+
62
+ // Sequence: receive signal → stop accepting connections → wait for in-flight
63
+ // → run onShutdown → close Fastify → exit process
64
+ ```
65
+
66
+ ## Audit Plugin
67
+
68
+ Change tracking. Pass a `RepositoryLike` for persistence, or omit for in-memory dev:
69
+
70
+ ```typescript
71
+ import { auditPlugin } from '@classytic/arc/audit';
72
+ import { Repository } from '@classytic/mongokit';
73
+
74
+ // Development (in-memory)
75
+ await fastify.register(auditPlugin, { enabled: true });
76
+
77
+ // Production — any kit (mongokit / prismakit / custom)
78
+ await fastify.register(auditPlugin, {
79
+ enabled: true,
80
+ repository: new Repository(AuditModel),
81
+ });
82
+ // ── Retention (audit rows ARE `history: true` timelines — prune together) ──
83
+ // Option A (zero code, Mongo): TTL index — the DB does it server-side.
84
+ // AuditSchema.index({ timestamp: 1 }, { expireAfterSeconds: 60 * 60 * 24 * 365 });
85
+ // Option B (built-in, single instance): retention: { maxAgeMs, purgeIntervalMs }.
86
+ // Option C (multi-replica, RECOMMENDED): purgeIntervalMs: 0 + a schedulesPlugin
87
+ // entry calling fastify.audit.purge(cutoff) — leader-safe, jittered, observable.
88
+ // (The built-in timer runs on EVERY replica — idempotent but wasteful.)
89
+ //
90
+ // ── Indexes (kit territory — the audit model is yours) ──
91
+ // Mongo: TTL index above + compound for the history timeline:
92
+ // AuditSchema.index({ resource: 1, documentId: 1, timestamp: -1 });
93
+ // SQL kits: index (resource, document_id, timestamp) + the kit's ttl/sweep plugin.
94
+ //
95
+ // ── Rich search (admin audit-log UI) ──
96
+ // audit.query() is deliberately narrow. For full repo-core filter/sort/keyset
97
+ // power + MCP tools, mount the audit repo AS a read-only resource:
98
+ // defineResource({ name: 'audit-log', crud: { list: true, get: true },
99
+ // adapter: { repository: auditRepo },
100
+ // queryParser: new QueryParser({ allowedFilterFields: ['resource','documentId','userId','action','organizationId'] }),
101
+ // permissions: { list: requireRoles(['auditor']), get: requireRoles(['auditor']) } });
102
+
103
+ // Usage
104
+ await fastify.audit.create('product', product._id, product, request.auditContext);
105
+ await fastify.audit.update('product', id, beforeDoc, afterDoc, request.auditContext);
106
+ await fastify.audit.delete('product', id, deletedDoc, request.auditContext);
107
+ await fastify.audit.custom('product', id, 'price_changed', { oldPrice: 100, newPrice: 150 }, ctx);
108
+
109
+ // Query
110
+ const entries = await fastify.audit.query({
111
+ resource: 'product', documentId: 'prod-123',
112
+ action: 'update', from: new Date('2024-01-01'), limit: 100,
113
+ });
114
+ ```
115
+
116
+ **Audit entry:**
117
+
118
+ ```typescript
119
+ interface AuditEntry {
120
+ id: string; timestamp: Date;
121
+ action: 'create' | 'update' | 'delete' | 'restore' | 'custom';
122
+ resource: string; documentId: string;
123
+ before?: Record<string, unknown>;
124
+ after?: Record<string, unknown>;
125
+ metadata?: Record<string, unknown>;
126
+ context: { user?; organizationId?; requestId?; ipAddress?; userAgent?; };
127
+ }
128
+ ```
129
+
130
+ **Custom store:** Implement `AuditStore` interface (`log`, `query`, `close`).
131
+
132
+ ## Idempotency Plugin
133
+
134
+ Exactly-once semantics for mutating operations:
135
+
136
+ ```typescript
137
+ import { idempotencyPlugin } from '@classytic/arc/idempotency';
138
+
139
+ await fastify.register(idempotencyPlugin, {
140
+ enabled: true,
141
+ headerName: 'idempotency-key', // Default
142
+ ttlMs: 86400000, // 24 hours
143
+ lockTimeoutMs: 30000,
144
+ methods: ['POST', 'PUT', 'PATCH'],
145
+ include: [/\/orders/], // Only these routes
146
+ exclude: [/\/health/],
147
+ retryAfterSeconds: 1,
148
+ });
149
+ ```
150
+
151
+ **Client:**
152
+
153
+ ```typescript
154
+ fetch('/api/orders', {
155
+ method: 'POST',
156
+ headers: { 'Idempotency-Key': 'order-abc123-uuid' },
157
+ body: JSON.stringify({ items: [...] }),
158
+ });
159
+ // First request: processes, caches response
160
+ // Retry: returns cached response + x-idempotency-replayed: true
161
+ // Concurrent: returns 409 Conflict + Retry-After: 1
162
+ ```
163
+
164
+ **Storage backends:**
165
+
166
+ ```typescript
167
+ // Memory (default, dev) — omit both `repository` and `store`
168
+ import { MemoryIdempotencyStore } from '@classytic/arc/idempotency';
169
+
170
+ // Redis (production, multi-instance)
171
+ import { RedisIdempotencyStore } from '@classytic/arc/idempotency/redis';
172
+ store: new RedisIdempotencyStore({ client: redis, prefix: 'idem:', ttlMs: 86400000 })
173
+
174
+ // DB-backed via RepositoryLike (mongokit / prismakit / custom)
175
+ import { Repository, methodRegistryPlugin, batchOperationsPlugin, mongoOperationsPlugin } from '@classytic/mongokit';
176
+ repository: new Repository(IdempotencyModel, [
177
+ methodRegistryPlugin(), batchOperationsPlugin(), mongoOperationsPlugin(),
178
+ ])
179
+ ```
180
+
181
+ **IdempotencyStore interface:**
182
+
183
+ ```typescript
184
+ interface IdempotencyStore {
185
+ readonly name: string;
186
+ get(key: string): Promise<IdempotencyResult | undefined>;
187
+ set(key: string, result): Promise<void>;
188
+ tryLock(key: string, requestId: string, ttlMs: number): Promise<boolean>;
189
+ unlock(key: string, requestId: string): Promise<void>;
190
+ isLocked(key: string): Promise<boolean>;
191
+ delete(key: string): Promise<void>;
192
+ close?(): Promise<void>;
193
+ }
194
+ ```
195
+
196
+ ## OpenTelemetry Tracing
197
+
198
+ ```typescript
199
+ import { tracingPlugin } from '@classytic/arc/plugins/tracing';
200
+
201
+ await fastify.register(tracingPlugin, {
202
+ serviceName: 'my-api',
203
+ exporterUrl: 'http://localhost:4318/v1/traces',
204
+ sampleRate: 0.1, // Trace 10% of requests
205
+ });
206
+
207
+ // Custom spans
208
+ import { createSpan } from '@classytic/arc/plugins/tracing';
209
+
210
+ return createSpan(req, 'processPayment', async (span) => {
211
+ span.setAttribute('orderId', order._id);
212
+ return await processPayment(order);
213
+ });
214
+ ```
215
+
216
+ ## QueryCache (Server Cache)
217
+
218
+ TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
219
+
220
+ ```typescript
221
+ // Enable globally
222
+ const app = await createApp({
223
+ arcPlugins: { queryCache: true }, // Memory store, zero config
224
+ });
225
+
226
+ // Per-resource config
227
+ defineResource({
228
+ name: 'product',
229
+ cache: {
230
+ staleTime: 30, // seconds fresh (no revalidation)
231
+ gcTime: 300, // seconds stale data kept (SWR window)
232
+ tags: ['catalog'], // cross-resource grouping
233
+ invalidateOn: { 'category.*': ['catalog'] }, // event → tag invalidation
234
+ },
235
+ });
236
+ ```
237
+
238
+ **How it works:**
239
+ - `GET` → cached with `x-cache: HIT | STALE | MISS` header
240
+ - `POST/PATCH/DELETE` → auto-bumps resource version, invalidating cached queries
241
+ - Cross-resource: category mutation bumps `catalog` tag → products cache invalidated
242
+ - Multi-tenant safe: cache keys scoped by userId + orgId
243
+
244
+ **Runtime modes:**
245
+
246
+ | Mode | Store | Config |
247
+ |------|-------|--------|
248
+ | `memory` (default) | `MemoryCacheStore` (50 MiB budget) | Zero config |
249
+ | `distributed` | `RedisCacheStore` | `stores: { queryCache: new RedisCacheStore({ client: redis }) }` |
250
+
251
+ ## Response Cache Plugin
252
+
253
+ ```typescript
254
+ import { responseCachePlugin } from '@classytic/arc/plugins/response-cache';
255
+
256
+ await fastify.register(responseCachePlugin, {
257
+ // ETag generation + Cache-Control headers per route
258
+ });
259
+ ```
260
+
261
+ **Note:** When QueryCache is active for a resource, response-cache is automatically skipped for that resource's GET routes.
262
+
263
+ ## SSE Plugin (Server-Sent Events)
264
+
265
+ Bridges Arc domain events to SSE streams. Requires `eventPlugin` (auto-registered by factory).
266
+
267
+ ```typescript
268
+ // Via factory (recommended)
269
+ const app = await createApp({
270
+ arcPlugins: {
271
+ sse: {
272
+ path: '/events/stream', // SSE endpoint (default: '/events/stream')
273
+ requireAuth: true, // Fail-closed auth (default: true)
274
+ patterns: ['order.*', 'product.*'], // Event patterns to stream (default: ['*'])
275
+ orgScoped: false, // Filter events by org from request.scope (default: false)
276
+ heartbeat: 30000, // Heartbeat interval in ms (default: 30000)
277
+ },
278
+ },
279
+ });
280
+
281
+ // Manual registration
282
+ import { ssePlugin } from '@classytic/arc/plugins';
283
+ await fastify.register(ssePlugin, { requireAuth: true, orgScoped: true });
284
+ ```
285
+
286
+ ### Realtime resource change feed (2.22)
287
+
288
+ `arcPlugins: { realtime: true }` → `GET /realtime/:resource` (resource NAME) and
289
+ multiplexed `GET /realtime?resources=a,b,c` (cap 20): SSE change feed gated by the
290
+ resource's own `list` permission with per-event row filtering, tenant guard, and
291
+ field masking. Client = plain `EventSource` with `?token=`. No replay — reconnect
292
+ refetches the list. Choosing: per-user resource views → realtime; bidirectional/geo
293
+ → `integrations/websocket` (its CRUD bridge is org-coarse — never expose
294
+ row-permissioned resources through it); event firehose → sse. Massive fan-out
295
+ (viral feeds, live scores) → publish once to an edge layer (Ably/Centrifugo/CF)
296
+ via `app.events.subscribe(...)`; full tiering guide: docs/production-ops/realtime.mdx.
297
+
298
+ ```typescript
299
+ ```
300
+
301
+ **Fail-closed auth:** When `requireAuth: true` (default), throws at registration if `fastify.authenticate` is missing — prevents exposing SSE without auth.
302
+
303
+ **Org-scoped:** When `orgScoped: true`, events with `organizationId` are only sent to clients whose `request.scope` matches. Prevents cross-tenant leakage.
304
+
305
+ ## Error Handler Plugin
306
+
307
+ ```typescript
308
+ import { errorHandlerPlugin } from '@classytic/arc/plugins';
309
+
310
+ // Global error handling — catches all errors, logs, formats response
311
+ // Auto-registered by createApp()
312
+ ```
313
+
314
+ ### Error Mappers (v2.7.3)
315
+
316
+ Class-based domain error → HTTP response mapping. Register once, handlers just throw:
317
+
318
+ ```typescript
319
+ class AccountingError extends Error {
320
+ constructor(message: string, public status: number, public code: string) { super(message); }
321
+ }
322
+
323
+ const app = await createApp({
324
+ errorHandler: {
325
+ errorMappers: [{
326
+ type: AccountingError,
327
+ toResponse: (err) => ({ status: err.status, code: err.code, message: err.message }),
328
+ }],
329
+ },
330
+ });
331
+ // Now handlers just throw — Arc catches and maps automatically
332
+ ```
333
+
334
+ Mappers are checked via `instanceof` before all other error classification. Multiple mappers supported — first match wins.
335
+
336
+ ## Reply Helpers
337
+
338
+ Opt-in `sendList` + `stream` decorators: `createApp({ replyHelpers: true })`. Arc emits raw data on success — no `{ success, data }` envelope; HTTP status discriminates — so single-doc handlers just `return doc` (or `reply.send(doc)`) and errors throw `ArcError` (the global handler serialises to `ErrorContract`). The two decorators below cover the cases that DO need framework support:
339
+
340
+ ```typescript
341
+ // List response — any kit-shaped paginated result OR a bare array → canonical wire shape
342
+ return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
343
+ return reply.sendList(rows); // bare array endpoints
344
+ ```
345
+
346
+ ### Streaming Responses
347
+
348
+ ```typescript
349
+ // CSV export
350
+ return reply.stream(csvReadableStream, { contentType: 'text/csv', filename: 'export.csv' });
351
+ // PDF download
352
+ return reply.stream(pdfBuffer, { contentType: 'application/pdf', filename: 'report.pdf' });
353
+ // Raw stream (Fastify native — works without reply helpers too)
354
+ return reply.header('content-type', 'text/csv').send(readableStream);
355
+ ```
356
+
357
+ ### BigInt Serialization
358
+
359
+ Opt-in: `createApp({ serializeBigInt: true })` — auto-converts BigInt → Number in all JSON responses.
360
+
361
+ ## Multipart Body Middleware (v2.7.3)
362
+
363
+ Opt-in file upload support for CRUD routes — parses multipart form fields into `req.body`, attaches files to `req.body._files`:
364
+
365
+ ```typescript
366
+ import { multipartBody } from '@classytic/arc/middleware';
367
+
368
+ defineResource({
369
+ middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5_000_000 })] },
370
+ hooks: {
371
+ 'before:create': async (data) => {
372
+ if (data._files?.image) { data.imageUrl = await uploadToS3(data._files.image); delete data._files; }
373
+ return data;
374
+ },
375
+ },
376
+ });
377
+ ```
378
+
379
+ No-op for JSON requests — safe to always add. Options: `maxFileSize`, `maxFiles`, `allowedMimeTypes`, `filesKey`.
380
+
381
+ ## Migrations
382
+
383
+ ```typescript
384
+ import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
385
+
386
+ const v2 = defineMigration({
387
+ version: 2,
388
+ resource: 'product',
389
+ up: async (db) => {
390
+ await db.collection('products').updateMany({}, { $rename: { oldField: 'newField' } });
391
+ },
392
+ down: async (db) => {
393
+ await db.collection('products').updateMany({}, { $rename: { newField: 'oldField' } });
394
+ },
395
+ });
396
+
397
+ const runner = new MigrationRunner(mongoose.connection.db);
398
+ await runner.up([v2]);
399
+ ```
400
+
401
+ ## OpenAPI & External Paths
402
+
403
+ Arc auto-generates OpenAPI 3.0 specs from resource definitions. External integrations (auth adapters, custom routes) inject their paths via `ExternalOpenApiPaths`.
404
+
405
+ ```typescript
406
+ import type { ExternalOpenApiPaths } from '@classytic/arc/docs';
407
+
408
+ const externalPaths: ExternalOpenApiPaths = {
409
+ paths: { '/api/auth/sign-in': { post: { summary: 'Sign in', ... } } },
410
+ schemas: { User: { type: 'object', properties: { ... } } },
411
+ securitySchemes: {
412
+ cookieAuth: { type: 'apiKey', in: 'cookie', name: 'session_token' },
413
+ },
414
+ tags: [{ name: 'Authentication' }],
415
+ // Declare additional security alternatives for Arc resource paths
416
+ resourceSecurity: [{ apiKeyAuth: [], orgHeader: [] }],
417
+ };
418
+ ```
419
+
420
+ **`resourceSecurity`** — declarative registration of auth alternatives for resource paths:
421
+ - Each array item is **OR**'d with `bearerAuth` (the default)
422
+ - Keys within the same object are **AND**'d (all required together)
423
+ - Example: `[{ apiKeyAuth: [], orgHeader: [] }]` → "bearer OR (api-key AND org-header)"
424
+
425
+ Arc's Better Auth adapter (`extractBetterAuthOpenApi`) auto-populates `resourceSecurity` when the `apiKey()` plugin is detected — no manual configuration needed.
426
+
427
+ **Security scheme definitions:**
428
+
429
+ | Scheme | Type | Source | Always present? |
430
+ |--------|------|--------|-----------------|
431
+ | `bearerAuth` | HTTP Bearer | Arc core | Yes |
432
+ | `orgHeader` | API Key (`x-organization-id`) | Arc core | Yes (multi-tenant) |
433
+ | `cookieAuth` | API Key (cookie) | Better Auth adapter | When Better Auth active |
434
+ | `apiKeyAuth` | API Key (`x-api-key`) | Better Auth adapter | When `apiKey()` plugin active |
435
+
436
+ ## Deployment
437
+
438
+ Arc requires Node.js APIs (`node:crypto`, `AsyncLocalStorage`). Use `toFetchHandler()` for serverless/edge.
439
+
440
+ | Environment | Preset | Handler | Notes |
441
+ |-------------|--------|---------|-------|
442
+ | Docker/K8s | `production` | `app.listen()` | Full production |
443
+ | Google Cloud Run | `production` | `app.listen()` | Set min-instances > 0 for WebSocket |
444
+ | Railway/Render/Fly.io | `production` | `app.listen()` | Works with zero config |
445
+ | AWS Lambda | `edge` | `toFetchHandler()` | Node.js runtime |
446
+ | Vercel Serverless | `edge` | `toFetchHandler()` | Node.js runtime |
447
+ | Cloudflare Workers | `edge` | `toFetchHandler()` | Enable `nodejs_compat` in wrangler.toml |
448
+
449
+ **Edge handler:**
450
+ ```typescript
451
+ import { createApp, toFetchHandler } from '@classytic/arc/factory';
452
+ const app = await createApp({ preset: 'edge', auth: { type: 'jwt', jwt: { secret } } });
453
+ export default { fetch: toFetchHandler(app) }; // Cloudflare Workers / any fetch-based runtime
454
+ ```
455
+
456
+ **Production checklist:**
457
+
458
+ ```typescript
459
+ // Validate env vars at startup
460
+ if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error('...');
461
+
462
+ const app = await createApp({
463
+ preset: 'production',
464
+ auth: { type: 'jwt', jwt: { secret: process.env.JWT_SECRET } },
465
+ cors: { origin: process.env.ALLOWED_ORIGINS?.split(',') ?? [], credentials: true },
466
+ rateLimit: { max: 100, timeWindow: '1 minute' },
467
+ arcPlugins: { queryCache: true },
468
+ });
469
+
470
+ process.on('SIGTERM', () => app.close());
471
+ process.on('SIGINT', () => app.close());
472
+ ```
473
+
474
+ ## Distributed Runtime
475
+
476
+ `runtime: 'distributed'` only validates stores you actually enable:
477
+
478
+ - `stores.events` — always required
479
+ - `stores.cache` — only when `arcPlugins.caching` enabled
480
+ - `stores.queryCache` — only when `arcPlugins.queryCache` enabled
481
+ - `stores.idempotency` — never validated (per-resource opt-in)
482
+
483
+ ## Under-Pressure
484
+
485
+ Production preset: `maxEventLoopDelay: 3000` (avoids false 503s on Render/Railway/Fly.io). Override: `underPressure: { maxEventLoopDelay: 500 }`. Disable: `underPressure: false`.
486
+
487
+ ## CORS
488
+
489
+ Production warns (not throws) when origin missing. `origin: '*'` from env is allowed. Smart CORS auto-converts `credentials: true` + `origin: '*'` to `origin: true`, and (2.22) auto-merges arc's protocol headers (`x-organization-id`, `x-arc-scope`, `x-request-id`) into any host-declared `allowedHeaders` allow-list — hosts never enumerate framework internals.
490
+
491
+ ### Mobile apps (Capacitor / Ionic / React Native webviews)
492
+
493
+ Arc needs no special mode — mobile is a CORS + auth-transport recipe:
494
+
495
+ ```typescript
496
+ cors: {
497
+ // Webview origins alongside your web domains. Also add these to
498
+ // Better Auth's trustedOrigins when using BA.
499
+ origin: [...webOrigins, 'https://localhost', 'capacitor://localhost'],
500
+ credentials: true,
501
+ // Optional allow-list — arc's own headers auto-merge (2.22); you only
502
+ // list YOUR custom ones. Omit entirely to use reflection mode.
503
+ allowedHeaders: ['Content-Type', 'Authorization'],
504
+ // BA bearer plugin returns the fresh session token in this RESPONSE
505
+ // header — without exposing it, cross-origin JS cannot READ it and
506
+ // app login dies silently. The one mobile knob arc can't infer.
507
+ exposedHeaders: ['set-auth-token'],
508
+ },
509
+ trustProxy: true, // webviews arrive through your LB/tunnel
510
+ ```
511
+
512
+ Native (non-webview) HTTP clients send no `Origin` header — CORS doesn't apply to them at all; bearer auth just works.
513
+
514
+ ## Metrics Plugin
515
+
516
+ Prometheus-compatible metrics endpoint — zero external dependencies:
517
+
518
+ ```typescript
519
+ import { metricsPlugin } from '@classytic/arc/plugins';
520
+
521
+ await fastify.register(metricsPlugin, {
522
+ path: '/_metrics', // default
523
+ prefix: 'arc', // metric name prefix (default: 'arc')
524
+ onCollect: (metrics) => pushToOTLP(metrics), // optional OTLP push
525
+ });
526
+
527
+ // GET /_metrics → Prometheus text format
528
+ ```
529
+
530
+ **Built-in counters**: `arc_http_requests_total`, `arc_http_request_duration_seconds`, `arc_crud_operations_total`, `arc_cache_hits_total`, `arc_cache_misses_total`, `arc_events_published_total`, `arc_events_consumed_total`, `arc_circuit_breaker_state`.
531
+
532
+ **Programmatic access**: `fastify.metrics.recordOperation(resource, op, status, durationMs)`, `.recordCacheHit(resource)`, `.recordEventPublish(type)`, `.reset()`.
533
+
534
+ ## API Versioning Plugin
535
+
536
+ Header-based or URL prefix-based versioning with deprecation warnings:
537
+
538
+ ```typescript
539
+ import { versioningPlugin } from '@classytic/arc/plugins';
540
+
541
+ // Header-based: clients send Accept-Version: 2
542
+ await fastify.register(versioningPlugin, {
543
+ type: 'header',
544
+ deprecated: ['1'],
545
+ sunset: '2025-12-01',
546
+ });
547
+
548
+ // Prefix-based: /v2/products
549
+ await fastify.register(versioningPlugin, { type: 'prefix' });
550
+ ```
551
+
552
+ **Headers set**: `x-api-version` on every response. `Deprecation: true` + `Sunset` for deprecated versions. Access via `request.apiVersion`.
553
+
554
+ ## Per-Tenant Rate Limiting
555
+
556
+ Scope-aware rate limit key generator — isolates rate limits by org, user, or IP:
557
+
558
+ ```typescript
559
+ import { createTenantKeyGenerator } from '@classytic/arc/scope';
560
+
561
+ const app = await createApp({
562
+ rateLimit: {
563
+ max: 100,
564
+ timeWindow: '1 minute',
565
+ keyGenerator: createTenantKeyGenerator(),
566
+ },
567
+ });
568
+ ```
569
+
570
+ **Key resolution**: `member` → `organizationId`, `authenticated` → `userId`, `elevated` → `organizationId ?? userId`, `public` → IP. Custom strategy: `createTenantKeyGenerator({ strategy: (ctx) => ctx.ip })`.
571
+
572
+ ## Event Outbox
573
+
574
+ Transactional outbox pattern — at-least-once delivery even if transport is down:
575
+
576
+ ```typescript
577
+ import { EventOutbox, MemoryOutboxStore } from '@classytic/arc/events';
578
+ import { Repository } from '@classytic/mongokit';
579
+
580
+ // Dev
581
+ const outbox = new EventOutbox({ store: new MemoryOutboxStore(), transport: redisTransport });
582
+
583
+ // Production — any RepositoryLike
584
+ const outbox = new EventOutbox({
585
+ repository: new Repository(OutboxModel),
586
+ transport: redisTransport,
587
+ });
588
+
589
+ // In business logic (same DB transaction via `{ session }`)
590
+ await outbox.store(event, { session });
591
+
592
+ // Relay cron (runs every few seconds)
593
+ const relayed = await outbox.relay(); // publishes pending → transport
594
+ ```
595
+
596
+ **OutboxStore interface**: `save(event)`, `getPending(limit)`, `acknowledge(eventId)` (+ optional `claimPending`, `fail`, `getDeadLettered`, `purge`). When you pass `repository`, arc adapts it internally.
597
+
598
+ ## Bulk Operations Preset
599
+
600
+ Adds bulk CRUD routes — repository must provide `createMany`, `updateMany`, `deleteMany`:
601
+
602
+ ```typescript
603
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
604
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
605
+
606
+ defineResource({
607
+ name: 'product',
608
+ adapter: createMongooseAdapter({ model, repository, schemaGenerator: buildCrudSchemasFromModel }),
609
+ presets: ['bulk'], // adds POST/PATCH/DELETE /{resource}/bulk
610
+ });
611
+
612
+ // Or with options
613
+ presets: [bulkPreset({ operations: ['createMany', 'updateMany'], maxCreateItems: 500 })]
614
+ ```
615
+
616
+ **Routes**: `POST /bulk` (body: `{ items }`) → `createMany`, `PATCH /bulk` (body: `{ filter, data }`) → `updateMany`, `DELETE /bulk` (body: `{ filter }`) → `deleteMany`. Permissions inherit from `create`/`update`/`delete`.
617
+
618
+ ## Compensating Transaction
619
+
620
+ In-process rollback primitive — runs steps in order, compensates in reverse on failure.
621
+ For distributed sagas across services, use Temporal, Inngest, or Streamline.
622
+
623
+ ```typescript
624
+ import { withCompensation } from '@classytic/arc/utils';
625
+
626
+ const result = await withCompensation('place-order', [
627
+ {
628
+ name: 'reserve-inventory',
629
+ execute: async (ctx) => {
630
+ const res = await inventoryService.reserve(ctx.items);
631
+ ctx.reservationId = res.id;
632
+ return res;
633
+ },
634
+ compensate: async (_ctx, result) => {
635
+ await inventoryService.release(result.id);
636
+ },
637
+ },
638
+ {
639
+ name: 'charge-payment',
640
+ execute: async (ctx) => await paymentService.charge(ctx.total),
641
+ compensate: async (_ctx, result) => await paymentService.refund(result.chargeId),
642
+ },
643
+ {
644
+ name: 'send-confirmation',
645
+ execute: async (ctx) => await emailService.send(ctx.email),
646
+ // No compensate — emails can't be unsent
647
+ },
648
+ ], { items: cart.items, total: cart.total, email: user.email });
649
+
650
+ if (!result.success) {
651
+ console.error(`Saga failed at ${result.failedStep}: ${result.error}`);
652
+ // Compensation already ran for completed steps
653
+ }
654
+ ```
655
+
656
+ **`defineCompensation()`** — reusable definition:
657
+
658
+ ```typescript
659
+ const placeOrder = defineCompensation('place-order', steps);
660
+ await placeOrder.execute({ items, total, email });
661
+ ```
662
+
663
+ **Fire-and-forget steps** — don't block, don't compensate, errors swallowed:
664
+
665
+ ```typescript
666
+ await withCompensation('checkout', [
667
+ { name: 'save-order', execute: saveOrder, compensate: cancelOrder },
668
+ { name: 'send-email', execute: sendEmail, fireAndForget: true }, // non-blocking
669
+ { name: 'charge', execute: chargeCard, compensate: refundCard },
670
+ ]);
671
+ // 'charge' runs immediately after 'save-order' — doesn't wait for email
672
+ ```
673
+
674
+ **Lifecycle hooks** — wire to Arc events, logging, or metrics:
675
+
676
+ ```typescript
677
+ await withCompensation('checkout', steps, { orderId }, {
678
+ onStepComplete: (stepName, result) => {
679
+ fastify.events.publish(`checkout.${stepName}.completed`, result);
680
+ },
681
+ onStepFailed: (stepName, error) => {
682
+ fastify.events.publish(`checkout.${stepName}.failed`, { error: error.message });
683
+ },
684
+ onCompensate: (stepName) => {
685
+ fastify.log.warn(`Compensated: ${stepName}`);
686
+ },
687
+ });
688
+ ```
689
+
690
+ **In a custom route with Arc auth:**
691
+
692
+ ```typescript
693
+ defineResource({
694
+ name: 'order',
695
+ routes: [{
696
+ method: 'POST',
697
+ path: '/:id/checkout',
698
+ permissions: requireAuth(),
699
+ raw: true,
700
+ handler: async (request, reply) => {
701
+ const result = await withCompensation('checkout', steps, { orderId: request.params.id });
702
+ if (!result.success) return reply.code(422).send({ error: result.error });
703
+ return reply.send({ data: result.results });
704
+ },
705
+ }],
706
+ });
707
+ ```
708
+
709
+ **Compensation errors**: collected in `result.compensationErrors[]` without stopping rollback. **Context**: mutable `Record<string, unknown>` shared across all steps.
710
+
711
+ **Scope**: In-process primitive. Process crash = no compensation. For durable distributed workflows, use Temporal, Inngest, or `@classytic/streamline`.
712
+
713
+ ## Multi-Domain Deployment
714
+
715
+ Arc supports multi-domain applications through COMPOSITION — it deliberately owns no domain routing, domain databases, certificate provisioning, or wildcard-domain management. The deployment shape:
716
+
717
+ ```
718
+ api.example.com → arc API
719
+ auth.example.com → Better Auth (or auth module)
720
+ events.example.com → SSE/WebSocket edge tier
721
+ admin.example.com → frontend
722
+ customer-domain.com → gateway/domain resolver → tenant context
723
+ ```
724
+
725
+ The primitives are already there: CORS allowlists, Better Auth trusted origins, tenant scope, request context, `trustProxy` configuration, resource/module prefixes.
726
+
727
+ **Custom tenant domains**: resolve the domain at a gateway or an early host plugin, then convert it into a trusted `RequestScope` — a small host-provided resolver is enough:
728
+
729
+ ```typescript
730
+ // onRequest, before auth: map host → tenant hint
731
+ const tenant = await resolveTenantDomain(request.hostname); // → { organizationId } | null
732
+ ```
733
+
734
+ Then validate membership or service scope through the NORMAL permission path. **Never treat raw `Host` / `X-Forwarded-Host` as tenant authorization by itself** — behind a misconfigured proxy those are attacker-controlled (see the `trustProxy` boot warning). The header selects the tenant *candidate*; membership resolution authorizes it — same rule `resolveOrgFromHeader` follows for `x-organization-id`.