@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.
- package/dist/{BaseController-C6FlRWxF.mjs → BaseController-BQ8KNvWC.mjs} +3 -2
- package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
- package/dist/{actionPermissions-ze70xXJi.mjs → actionPermissions-lszoJle1.mjs} +1 -1
- package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +2 -2
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -3
- package/dist/{core-b6fgFgnd.mjs → core-DQ6rDTyH.mjs} +10 -9
- package/dist/{createActionRouter-h1cEEaEX.mjs → createActionRouter-BuOv-m4Y.mjs} +3 -3
- package/dist/{createAggregationRouter-B1C0kvTL.mjs → createAggregationRouter-CgJv_BU_.mjs} +1 -1
- package/dist/{createApp-Ci7yG5gv.mjs → createApp-CjOeWJ9_.mjs} +11 -2
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{errorHandler-BTzXSwIg.mjs → errorHandler-Bq2OeEQL.mjs} +10 -7
- package/dist/events/index.mjs +5 -2
- package/dist/factory/index.d.mts +20 -4
- package/dist/factory/index.mjs +31 -11
- package/dist/{fastify-augmentation-9_fuetB4.d.mts → fastify-augmentation-g_f9omSc.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +7 -0
- package/dist/idempotency/index.mjs +5 -17
- package/dist/{index-DbtgCOCj.d.mts → index-BBsvQt7U.d.mts} +26 -4
- package/dist/{index-BEu4wjbL.d.mts → index-Ba10fdKp.d.mts} +11 -2
- package/dist/{index-D8MKYGDa.d.mts → index-CC5ruDGh.d.mts} +2 -2
- package/dist/{index-ogIHvOjo.d.mts → index-wMoWBLkn.d.mts} +67 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +6 -5
- package/dist/{init-DleK3p9f.mjs → init-CjO4p_q1.mjs} +5 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +66 -3
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +123 -3
- package/dist/integrations/webhooks.mjs +160 -5
- package/dist/interface-n5hpKg1d.mjs +16 -0
- package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
- package/dist/{mcpPlugin-ByA2tf6D.mjs → mcpPlugin-BtzIL_kM.mjs} +308 -39
- package/dist/middleware/index.d.mts +1 -1
- package/dist/{openapi-BRpMZD7C.mjs → openapi-jSDx-VDv.mjs} +3 -3
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +3 -2
- package/dist/{permissions-BJMuKMl7.mjs → permissions-tbxFHxjZ.mjs} +77 -264
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/plugins/index.d.mts +6 -6
- package/dist/plugins/index.mjs +5 -4
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-gw0ciIUD.mjs → presets-BOe4Ugkl.mjs} +1 -1
- package/dist/realtime-I8OrfnMR.mjs +375 -0
- package/dist/{registerRoute-xHc42fwB.mjs → registerRoute-CWO6mdYe.mjs} +1 -1
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/{schemaConverter-Ds5reSGs.mjs → schemaConverter-DJvodiHj.mjs} +4 -1
- package/dist/{schemaIR-B-6SPe7g.mjs → schemaIR-Ca7ySNPx.mjs} +1 -1
- package/dist/scope/index.mjs +1 -1
- package/dist/scope-LONQ3mfZ.mjs +37 -0
- package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
- package/dist/{sse-CfUjwjDk.mjs → sse-0u4P4-OI.mjs} +7 -30
- package/dist/sseStream-BQ7Qaynp.mjs +57 -0
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +2 -2
- package/dist/types/index.d.mts +1 -1
- package/dist/{types-Bc42CUyk.d.mts → types-5OuqeTDX.d.mts} +37 -2
- package/dist/utils/index.d.mts +62 -2
- package/dist/utils/index.mjs +5 -3
- package/dist/{utils-FhJ88TJE.mjs → utils-CZyeRXTh.mjs} +21 -69
- package/dist/{versioning-DLwklPlG.d.mts → versioning-D6PvCwpq.d.mts} +43 -4
- package/package.json +64 -4
- package/skills/arc/references/production.md +734 -696
- 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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
|
430
|
-
|
|
431
|
-
|
|
|
432
|
-
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
},
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
```
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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`.
|