@classytic/arc 2.8.5 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +88 -5
  2. package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-Vu2yc56T.mjs} +188 -102
  3. package/dist/EventTransport-CqZ8FyM_.d.mts +293 -0
  4. package/dist/adapters/index.d.mts +2 -2
  5. package/dist/audit/index.d.mts +100 -11
  6. package/dist/audit/index.mjs +71 -18
  7. package/dist/auth/index.d.mts +16 -8
  8. package/dist/auth/index.mjs +13 -6
  9. package/dist/auth/redis-session.d.mts +1 -1
  10. package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi--rdY15Ld.mjs} +1 -1
  11. package/dist/cache/index.d.mts +2 -2
  12. package/dist/cache/index.mjs +2 -2
  13. package/dist/cli/commands/docs.mjs +2 -2
  14. package/dist/cli/commands/introspect.mjs +1 -1
  15. package/dist/core/index.d.mts +3 -3
  16. package/dist/core/index.mjs +4 -5
  17. package/dist/{core-F0QoWBt2.mjs → core-DNncu0xF.mjs} +1 -1
  18. package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-DH1YFL9m.mjs} +3 -3
  19. package/dist/{createApp-B1EY8zxa.mjs → createApp-CBJUJKGP.mjs} +13 -12
  20. package/dist/{defineResource-tcgySDo1.mjs → defineResource-C__jkwvs.mjs} +22 -57
  21. package/dist/docs/index.d.mts +2 -2
  22. package/dist/docs/index.mjs +1 -1
  23. package/dist/dynamic/index.d.mts +1 -1
  24. package/dist/dynamic/index.mjs +3 -3
  25. package/dist/{elevation-DtFxrG0s.mjs → elevation-DxQ6ACbt.mjs} +21 -7
  26. package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-CZDW4EXS.mjs} +59 -7
  27. package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DixGcttC.d.mts} +37 -2
  28. package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-BxvaCIZF.d.mts} +14 -2
  29. package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-Dl7MoVWH.mjs} +83 -5
  30. package/dist/events/index.d.mts +147 -36
  31. package/dist/events/index.mjs +338 -101
  32. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  33. package/dist/events/transports/redis.d.mts +1 -1
  34. package/dist/factory/index.d.mts +1 -1
  35. package/dist/factory/index.mjs +2 -2
  36. package/dist/{fields-DpZQa_Q3.d.mts → fields-BC7zcmI9.d.mts} +15 -3
  37. package/dist/{fields-ipsbIRPK.mjs → fields-CU6FlaDV.mjs} +18 -5
  38. package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-q8oHt--L.mjs} +65 -7
  39. package/dist/hooks/index.d.mts +1 -1
  40. package/dist/hooks/index.mjs +1 -1
  41. package/dist/idempotency/index.d.mts +29 -5
  42. package/dist/idempotency/index.mjs +111 -2
  43. package/dist/idempotency/redis.d.mts +1 -1
  44. package/dist/{index-BLXBmWud.d.mts → index-C-xjcA6F.d.mts} +1 -1
  45. package/dist/{index-DtDzOBn8.d.mts → index-Cibkchnx.d.mts} +3 -134
  46. package/dist/{index-C1meYuDn.d.mts → index-CtGKT0lf.d.mts} +1 -1
  47. package/dist/index.d.mts +7 -7
  48. package/dist/index.mjs +9 -9
  49. package/dist/integrations/event-gateway.d.mts +1 -1
  50. package/dist/integrations/event-gateway.mjs +1 -1
  51. package/dist/integrations/index.d.mts +1 -1
  52. package/dist/integrations/mcp/index.d.mts +26 -8
  53. package/dist/integrations/mcp/index.mjs +96 -17
  54. package/dist/integrations/mcp/testing.d.mts +1 -1
  55. package/dist/integrations/mcp/testing.mjs +1 -1
  56. package/dist/integrations/webhooks.d.mts +5 -0
  57. package/dist/integrations/webhooks.mjs +6 -0
  58. package/dist/{interface-CMRutPfe.d.mts → interface-YrWsmKqE.d.mts} +287 -179
  59. package/dist/{openapi-CbKUJY_m.mjs → openapi-CXuTG1M9.mjs} +2 -2
  60. package/dist/org/index.d.mts +1 -1
  61. package/dist/permissions/index.d.mts +2 -2
  62. package/dist/permissions/index.mjs +3 -3
  63. package/dist/{permissions-CH4cNwJi.mjs → permissions-oNZawnkR.mjs} +1 -1
  64. package/dist/plugins/index.d.mts +7 -7
  65. package/dist/plugins/index.mjs +11 -11
  66. package/dist/plugins/response-cache.mjs +1 -1
  67. package/dist/plugins/tracing-entry.d.mts +1 -1
  68. package/dist/plugins/tracing-entry.mjs +1 -1
  69. package/dist/policies/index.d.mts +25 -32
  70. package/dist/presets/filesUpload.d.mts +26 -4
  71. package/dist/presets/filesUpload.mjs +1 -1
  72. package/dist/presets/index.d.mts +3 -2
  73. package/dist/presets/index.mjs +4 -3
  74. package/dist/presets/multiTenant.d.mts +1 -1
  75. package/dist/presets/multiTenant.mjs +1 -1
  76. package/dist/presets/search.d.mts +91 -0
  77. package/dist/presets/search.mjs +150 -0
  78. package/dist/{presets-C2xgzW6x.mjs → presets-hM4WhNWY.mjs} +1 -1
  79. package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-CnTZZTC5.d.mts} +1 -1
  80. package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DbUVroUG.mjs} +2 -2
  81. package/dist/{redis-BM00zaPB.d.mts → redis-MXLp1oOf.d.mts} +1 -1
  82. package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-Bz-4q96t.d.mts} +1 -1
  83. package/dist/registry/index.d.mts +1 -1
  84. package/dist/registry/index.mjs +2 -2
  85. package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-C3cWymnW.mjs} +64 -47
  86. package/dist/rpc/index.d.mts +1 -1
  87. package/dist/rpc/index.mjs +1 -1
  88. package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
  89. package/dist/scope/index.mjs +1 -1
  90. package/dist/{sse-Ad7ypl9e.mjs → sse-CJpt7LGI.mjs} +1 -1
  91. package/dist/store-helpers-DFiZl5TL.mjs +57 -0
  92. package/dist/testing/index.d.mts +5 -14
  93. package/dist/testing/index.mjs +21 -75
  94. package/dist/testing/storageContract.d.mts +1 -1
  95. package/dist/types/index.d.mts +2 -2
  96. package/dist/types/storage.d.mts +1 -1
  97. package/dist/{types-BsbNMEDR.d.mts → types-CoSzA-s-.d.mts} +1 -1
  98. package/dist/{types-Ch9pTQbf.d.mts → types-CunEX4UX.d.mts} +10 -8
  99. package/dist/utils/index.d.mts +4 -4
  100. package/dist/utils/index.mjs +6 -6
  101. package/dist/{utils-yYT3HDXt.mjs → utils-B7FuRr9w.mjs} +1 -1
  102. package/package.json +8 -11
  103. package/skills/arc/SKILL.md +92 -14
  104. package/skills/arc/references/auth.md +94 -0
  105. package/skills/arc/references/events.md +200 -12
  106. package/skills/arc/references/mcp.md +4 -17
  107. package/skills/arc/references/multi-tenancy.md +43 -0
  108. package/skills/arc/references/production.md +34 -19
  109. package/dist/EventTransport-BXja8NOc.d.mts +0 -135
  110. package/dist/audit/mongodb.d.mts +0 -2
  111. package/dist/audit/mongodb.mjs +0 -2
  112. package/dist/idempotency/mongodb.d.mts +0 -2
  113. package/dist/idempotency/mongodb.mjs +0 -123
  114. package/dist/mongodb-BsP-WbhN.d.mts +0 -127
  115. package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
  116. package/dist/mongodb-Utc5k_-0.mjs +0 -90
  117. /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BjFu7zf1.mjs} +0 -0
  118. /package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-Dq3_zBQP.mjs} +0 -0
  119. /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-bqGpo9ML.mjs} +0 -0
  120. /package/dist/{caching-IMuYVjTL.mjs → caching-CjybdRwx.mjs} +0 -0
  121. /package/dist/{circuitBreaker-dTtG-UyS.d.mts → circuitBreaker-CvXkjfrW.d.mts} +0 -0
  122. /package/dist/{circuitBreaker-cmi5XDv5.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
  123. /package/dist/{errors-Ck2h67pm.d.mts → errors-BI8kEKsO.d.mts} +0 -0
  124. /package/dist/{errors-BF2bIOIS.mjs → errors-CqWnSqM-.mjs} +0 -0
  125. /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
  126. /package/dist/{interface-DfLGcus7.d.mts → interface-B-pe8fhj.d.mts} +0 -0
  127. /package/dist/{interface-4y979v99.d.mts → interface-DplgQO2e.d.mts} +0 -0
  128. /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-Bksk8ydA.mjs} +0 -0
  129. /package/dist/{logger-D1YrIImS.mjs → logger-CDjpjySd.mjs} +0 -0
  130. /package/dist/{memory-Cp7_cAko.mjs → memory-BFAYkf8H.mjs} +0 -0
  131. /package/dist/{metrics-B-PU4-Yu.mjs → metrics-TuOmguhi.mjs} +0 -0
  132. /package/dist/{queryParser-CgCtsjti.mjs → queryParser-Cs-6SHQK.mjs} +0 -0
  133. /package/dist/{registry-BiTKT1Dg.mjs → registry-B0Wl7uVV.mjs} +0 -0
  134. /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-BLojtuvR.mjs} +0 -0
  135. /package/dist/{requestContext-DYvHl113.mjs → requestContext-DYtmNpm5.mjs} +0 -0
  136. /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
  137. /package/dist/{storage-Dfzt4VTl.d.mts → storage-BwGQXUpd.d.mts} +0 -0
  138. /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-xqXzWeaf.d.mts} +0 -0
  139. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
  140. /package/dist/{versioning-CDugduqI.mjs → versioning-Cm8qoFDg.mjs} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc",
3
- "version": "2.8.5",
3
+ "version": "2.9.1",
4
4
  "description": "Resource-oriented backend framework for Fastify — clean, minimal, powerful, tree-shakable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/presets/filesUpload.d.mts",
45
45
  "default": "./dist/presets/filesUpload.mjs"
46
46
  },
47
+ "./presets/search": {
48
+ "types": "./dist/presets/search.d.mts",
49
+ "default": "./dist/presets/search.mjs"
50
+ },
47
51
  "./auth": {
48
52
  "types": "./dist/auth/index.d.mts",
49
53
  "default": "./dist/auth/index.mjs"
@@ -84,10 +88,6 @@
84
88
  "types": "./dist/audit/index.d.mts",
85
89
  "default": "./dist/audit/index.mjs"
86
90
  },
87
- "./audit/mongodb": {
88
- "types": "./dist/audit/mongodb.d.mts",
89
- "default": "./dist/audit/mongodb.mjs"
90
- },
91
91
  "./docs": {
92
92
  "types": "./dist/docs/index.d.mts",
93
93
  "default": "./dist/docs/index.mjs"
@@ -100,10 +100,6 @@
100
100
  "types": "./dist/idempotency/redis.d.mts",
101
101
  "default": "./dist/idempotency/redis.mjs"
102
102
  },
103
- "./idempotency/mongodb": {
104
- "types": "./dist/idempotency/mongodb.d.mts",
105
- "default": "./dist/idempotency/mongodb.mjs"
106
- },
107
103
  "./events": {
108
104
  "types": "./dist/events/index.d.mts",
109
105
  "default": "./dist/events/index.mjs"
@@ -239,7 +235,7 @@
239
235
  "node": ">=22"
240
236
  },
241
237
  "peerDependencies": {
242
- "@classytic/mongokit": ">=3.6.0",
238
+ "@classytic/mongokit": ">=3.8.0",
243
239
  "@classytic/streamline": ">=2.1.0",
244
240
  "@fastify/cors": ">=11.0.0",
245
241
  "@fastify/helmet": ">=13.0.0",
@@ -358,7 +354,7 @@
358
354
  "devDependencies": {
359
355
  "@better-auth/mongo-adapter": "^1.6.2",
360
356
  "@biomejs/biome": "^2.4.11",
361
- "@classytic/mongokit": "file:../../packages/mongokit/classytic-mongokit-3.6.0.tgz",
357
+ "@classytic/mongokit": "^3.8.0",
362
358
  "@classytic/streamline": "^2.1.0",
363
359
  "@fastify/cors": "^11.2.0",
364
360
  "@fastify/helmet": "^13.0.2",
@@ -378,6 +374,7 @@
378
374
  "better-auth": "^1.6.2",
379
375
  "bullmq": "^5.73.5",
380
376
  "dotenv": "^17.4.2",
377
+ "fast-check": "^4.6.0",
381
378
  "fastify-raw-body": "^5.0.0",
382
379
  "ioredis": "^5.10.1",
383
380
  "jsonwebtoken": "^9.0.0",
@@ -8,11 +8,11 @@ description: |
8
8
  Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
9
9
  arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
10
10
  arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
11
- version: 2.8.1
11
+ version: 2.9.1
12
12
  license: MIT
13
13
  metadata:
14
14
  author: Classytic
15
- version: "2.8.0"
15
+ version: "2.9.1"
16
16
  tags:
17
17
  - fastify
18
18
  - rest-api
@@ -68,6 +68,22 @@ await app.register(productResource.toPlugin());
68
68
  await app.listen({ port: 8040, host: '0.0.0.0' });
69
69
  ```
70
70
 
71
+ ## v2.9 Security Defaults (breaking)
72
+
73
+ - **Field-write perms: `reject` (default)** — requests carrying non-writable fields get 403 with denied-field list. Opt into silent strip: `defineResource({ onFieldWriteDenied: 'strip' })`.
74
+ - **multiTenant injects org on UPDATE** — body-supplied `organizationId` overwritten with caller's scope. Closes tenant-hop vector.
75
+ - **Elevation always emits `arc.scope.elevated` event** — audit via `fastify.events.subscribe(...)`.
76
+ - **`verifySignature(body, ...)` throws on parsed body** — pass `req.rawBody`, not `req.body`.
77
+ - **Upload `sanitizeFilename`** — strict by default (no `/ \ .. \0 >255ch`). Pass `false` / `'*'` / custom fn to relax.
78
+ - **Idempotency `namespace`** option for shared-store prod+canary deployments.
79
+
80
+ ## Removed in v2.9
81
+
82
+ - `createActionRouter`, `buildActionBodySchema` — use `actions` on `defineResource`
83
+ - `ResourceConfig.onRegister` — use `actions` or resource `hooks`
84
+ - `AdditionalRoute` type + `resource.additionalRoutes` field — use `RouteDefinition` and `resource.routes` (single source of truth; no normalised mirror)
85
+ - `wrapHandler` on route defs — derived from `!route.raw` at use-site (set `raw: true` to opt out of the arc pipeline)
86
+
71
87
  ## defineResource()
72
88
 
73
89
  Single API to define a full REST resource:
@@ -109,7 +125,7 @@ const productResource = defineResource({
109
125
  { method: 'POST', path: '/webhook', handler: webhookFn, raw: true, permissions: auth() },
110
126
  ],
111
127
 
112
- // Actions (replaces onRegister + createActionRouter)
128
+ // Actions single POST /:id/action endpoint, discriminated on `action` body field
113
129
  actions: {
114
130
  approve: async (id, data, req) => service.approve(id, req.user._id),
115
131
  cancel: {
@@ -489,6 +505,8 @@ permissions: { list: acl.canAction('product', 'read') }
489
505
  | `multiTenant` | none (middleware) | — | `{ tenantField }` OR `{ tenantFields: TenantFieldSpec[] }` (2.7.1+) |
490
506
  | `audited` | none (middleware) | — | — |
491
507
  | `bulk` | POST/PATCH/DELETE /bulk | — | `{ operations?, maxCreateItems? }` |
508
+ | `filesUpload` | POST /upload, GET /:id, DELETE /:id | — (uses `Storage` adapter) | `{ storage, sanitizeFilename?, allowedMimeTypes?, maxFileSize? }` |
509
+ | `search` | POST /search, /search-similar, /embed (opt-in) | — | `{ repository?, search?, similar?, embed?, routes? }` |
492
510
 
493
511
  ```typescript
494
512
  // Single-field (default, backwards compatible)
@@ -546,7 +564,7 @@ Default is `'_id'`. Override for resources keyed by a business identifier (UUID,
546
564
  defineResource({
547
565
  name: 'job',
548
566
  adapter: createMongooseAdapter(JobModel, jobRepository),
549
- idField: 'jobId', // ← one line
567
+ idField: 'jobId', // ← one line (or omit and auto-derive from repository.idField — see below)
550
568
  });
551
569
 
552
570
  // GET /jobs/job-5219f346-a4d → controller runs { jobId: 'job-5219f346-a4d' }
@@ -559,10 +577,66 @@ Changes all three layers:
559
577
  - **OpenAPI docs** — `spec.paths['/jobs/{id}']` emits a plain string `id` with description
560
578
  - **MCP tools** — auto-generated CRUD tools use `idField` transparently
561
579
 
562
- URL path segment stays `:id` (not `:jobId`) clients send the ID value, Arc maps it server-side. User-provided `openApiSchemas.params` still overrides everything.
580
+ **Auto-derive from repository** (2.7.x+). If you don't set `idField` on `defineResource` but your `adapter.repository` exposes one (e.g. `new Repository(Model, [], {}, { idField: 'slug' })`), Arc picks it up automatically. Configure in one place, not two.
581
+
582
+ **URL path segment is ALWAYS `:id` and `req.params.id` is ALWAYS named `id`** — regardless of what `idField` is set to. `idField` controls the *lookup field*, not the *URL parameter name*. This is the same convention Stripe / GitHub / most REST APIs use:
583
+
584
+ ```typescript
585
+ // idField: 'slug'
586
+ // Client sends: POST /agents/sadman/action
587
+ // In handler: req.params.id === 'sadman' ← always keyed 'id'
588
+ // repo.update(id, data) ← mongokit resolves by slug via repo.idField
589
+ ```
590
+
591
+ This applies to CRUD routes (`GET/PATCH/DELETE /:id`), action routes (`POST /:id/action`), and any `routes: [...]` entries that use `:id`.
592
+
593
+ **Common confusion pattern.** A 404 on `PATCH /agents/sadman` when `GET /agents/sadman` returns 200 looks like an `idField` bug but usually isn't. Check whether your **update permission** returns `filters` — arc merges those into the compound DB lookup (`{ slug: 'sadman', ...filters }`), and a filter that excludes the doc is what's returning null. Fix is in the permission, not `idField`.
594
+
595
+ User-provided `openApiSchemas.params` still overrides everything.
563
596
 
564
597
  For custom adapters, honor the new `AdapterSchemaContext` passed to `generateSchemas(options, context?)` to emit the right `params.id` pattern from the start. Legacy adapters still work — Arc's safety net strips mismatched ObjectId patterns automatically.
565
598
 
599
+ ## searchPreset (text + vector + embed)
600
+
601
+ Backend-agnostic routes for Elasticsearch / OpenSearch / Algolia / Typesense / Atlas `$vectorSearch` / Pinecone / Qdrant / Milvus. Opt-in per section; `mcp: false` skips per path.
602
+
603
+ ```typescript
604
+ import { searchPreset } from '@classytic/arc/presets/search';
605
+
606
+ // A — auto-wire from a repo with search/searchSimilar/embed methods
607
+ // (mongokit's elasticSearchPlugin + vectorPlugin register exactly these).
608
+ // Each method's native calling convention is honoured:
609
+ // search(query, options) — positional (elasticSearchPlugin)
610
+ // searchSimilar(VectorSearchParams) — single object (vectorPlugin)
611
+ // embed(input) — single arg (vectorPlugin)
612
+ searchPreset({
613
+ repository: productRepo,
614
+ search: true, // POST /search → repo.search(body.query, body)
615
+ similar: true, // POST /search-similar → repo.searchSimilar(body)
616
+ // embed omitted → /embed not mounted
617
+ })
618
+
619
+ // B — external backends + custom path + Zod schema
620
+ searchPreset({
621
+ search: {
622
+ path: '/full-text',
623
+ schema: { body: z.object({ q: z.string().min(1) }) },
624
+ handler: (req) => elastic.search({ index: 'products', q: req.body.q }),
625
+ },
626
+ similar: { handler: (req) => pinecone.query({ vector: req.body.vector, topK: 10 }), mcp: false },
627
+ routes: [ // bespoke paths
628
+ { method: 'GET', path: '/autocomplete', permissions: allowPublic(),
629
+ handler: (req) => algolia.suggest((req.query as { q: string }).q) },
630
+ ],
631
+ })
632
+ ```
633
+
634
+ **Defaults:** search/similar → POST, permissions fall back to resource `list` → `allowPublic()`. Embed → POST + `requireAuth()`. Every field (`path`, `method`, `schema`, `permissions`, `mcp`, `summary`, `tags`, `operation`) is overridable per section. Zod v4 schemas auto-convert to JSON Schema for both Fastify validation and OpenAPI.
635
+
636
+ **MCP namespacing:** tool names are `{op}_{resource}` — many resources can register their own searchPreset under one `mcpPlugin` endpoint without colliding (`product_search`, `order_search`, …).
637
+
638
+ **When to use `routes` directly instead:** one-off search endpoints, or when you want full control without the preset's defaults.
639
+
566
640
  ## QueryCache
567
641
 
568
642
  TanStack Query-inspired server cache with stale-while-revalidate and auto-invalidation on mutations.
@@ -652,7 +726,11 @@ CRUD events auto-emit: `{resource}.created`, `{resource}.updated`, `{resource}.d
652
726
 
653
727
  **Transports:** Memory (default) | Redis Pub/Sub (fire-and-forget) | Redis Streams (durable, at-least-once, consumer groups, DLQ)
654
728
 
655
- **Event Outbox** — at-least-once delivery via transactional outbox pattern. Arc ships `OutboxStore` interface + `MemoryOutboxStore` (dev). You implement the store for your DB (Mongoose, Drizzle, Knex, etc.). Cleanup via optional `purge()` contract or native DB tools (TTL index, pg_cron, key expiry).
729
+ **Event Outbox** — at-least-once delivery via transactional outbox pattern. Pass `repository: RepositoryLike` (mongokit / prismakit / custom) for production, or `store: MemoryOutboxStore()` for dev. Arc adapts the repo to the `OutboxStore` contract internally `create` / `findAll` / `deleteMany` / `findOneAndUpdate` cover save, claim, ack, fail, DLQ.
730
+
731
+ **Event contract (v2.9):** `EventMeta` = `id`, `timestamp`, optional `schemaVersion`, `correlationId`, `causationId`, `partitionKey`, `source`, `idempotencyKey`, `resource`, `resourceId`, `userId`, `organizationId`, `aggregate: { type, id }`. `createChildEvent(parent, ...)` inherits correlation/causation/source/idempotencyKey; aggregate stays explicit. `DeadLetteredEvent<T>` + optional `transport.deadLetter()` for typed DLQ. `withRetry({ transport })` auto-routes exhausted events — no custom plumbing for Kafka/SQS. `@classytic/primitives` mirrors these shapes — arc is source of truth.
732
+
733
+ **Outbox (v2.9):** `EventOutbox.store()` auto-maps `meta.idempotencyKey` → `dedupeKey`. `new EventOutbox({ failurePolicy: ({ attempts }) => ({ retryAt, deadLetter }) })` centralises retry/DLQ. `outbox.getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`. `RelayResult.deadLettered` for per-batch DLQ count. Durable store: `new EventOutbox({ repository: new Repository(OutboxModel), transport })` (v2.9.1) — multi-worker claim, session-threaded writes, and dedupe semantics come from the repo's backing kit.
656
734
 
657
735
  ## Factory — createApp()
658
736
 
@@ -948,7 +1026,7 @@ defineResource({ name: 'order', audit: true });
948
1026
  defineResource({ name: 'payment', audit: { operations: ['delete'] } });
949
1027
  defineResource({ name: 'product' }); // not audited
950
1028
 
951
- // Manual custom() for MCP/additionalRoutes/read auditing
1029
+ // Manual custom() for MCP tools / custom routes / read auditing
952
1030
  app.post('/orders/:id/refund', async (req) => {
953
1031
  await app.audit.custom('order', req.params.id, 'refund', { reason }, { user });
954
1032
  });
@@ -980,7 +1058,6 @@ permissions: {
980
1058
 
981
1059
  ```typescript
982
1060
  // Typed request for raw routes — no more (req as any).user
983
- // v2.8: `raw: true` replaces `wrapHandler: false` (wrapHandler still works but is deprecated)
984
1061
  import type { ArcRequest } from '@classytic/arc';
985
1062
  handler: async (req: ArcRequest, reply) => { req.user?.id; req.scope; req.signal; }
986
1063
 
@@ -996,15 +1073,16 @@ const { userId, organizationId, roles, orgRoles } = getOrgContext(request);
996
1073
  import { createDomainError } from '@classytic/arc';
997
1074
  throw createDomainError('SELF_REFERRAL', 'Cannot refer yourself', 422);
998
1075
 
999
- // Resource lifecycle hook wire singletons during registration
1000
- // v2.8: for action routes, use `actions` config instead of onRegister + createActionRouter
1001
- defineResource({ name: 'notification', onRegister: (f) => setSseManager(f.sseManager) });
1076
+ // Custom routesalways `routes: [...]` with optional `raw: true` for non-JSON
1077
+ defineResource({
1078
+ routes: [{ method: 'GET', path: '/stats', handler: 'getStats', permissions: allowPublic() }],
1079
+ });
1002
1080
 
1003
1081
  // SSE auth — preAuth runs BEFORE auth middleware (EventSource can't set headers)
1004
- additionalRoutes: [{ preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] }]
1082
+ routes: [{ preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }] }]
1005
1083
 
1006
- // SSE streaming — auto headers + bypasses response wrapper
1007
- additionalRoutes: [{ streamResponse: true, handler: async (req, reply) => reply.send(stream) }]
1084
+ // SSE streaming — raw: true + stream the response
1085
+ routes: [{ method: 'GET', path: '/stream', raw: true, handler: async (req, reply) => reply.send(stream) }]
1008
1086
  ```
1009
1087
 
1010
1088
  ## DX Helpers (v2.7.3+)
@@ -225,6 +225,100 @@ auth: {
225
225
  }
226
226
  ```
227
227
 
228
+ ## Service Identity (Machine Principals)
229
+
230
+ Arc distinguishes **machine callers** (API clients, spawned agents, cron workers) from **human users** via `RequestScope.service` — a first-class scope kind with `clientId` + OAuth-style `scopes`. Any auth mechanism (arc JWT, API keys, Better Auth client credentials, mTLS, gateway headers) can install it; arc does not ship a "service token" issuer — the app picks its credential flow and maps the result to scope.
231
+
232
+ Install service scope from your `authenticate` callback — arc's default JWT verify installs `member` scope by design, so custom authenticators are the extension point:
233
+
234
+ ```typescript
235
+ await createApp({
236
+ auth: {
237
+ type: 'jwt',
238
+ jwt: { secret: process.env.JWT_SECRET! },
239
+ authenticate: async (request, { jwt }) => {
240
+ const token = request.headers.authorization?.slice(7);
241
+ if (!token) return null;
242
+ const decoded = jwt.verify(token) as {
243
+ clientId?: string; userId?: string;
244
+ organizationId?: string; scopes?: string[];
245
+ };
246
+ // Machine principal — clientId present, no human user record.
247
+ if (decoded.clientId) {
248
+ request.scope = {
249
+ kind: 'service',
250
+ clientId: decoded.clientId,
251
+ organizationId: decoded.organizationId ?? '',
252
+ scopes: decoded.scopes ?? [],
253
+ };
254
+ return { clientId: decoded.clientId }; // returned value → request.user
255
+ }
256
+ // Human principal — fall through to user lookup.
257
+ return await User.findById(decoded.userId);
258
+ },
259
+ },
260
+ });
261
+ ```
262
+
263
+ Gate routes on service identity using the scope helpers + a permission builder:
264
+
265
+ ```typescript
266
+ import { isService, getClientId, getServiceScopes } from '@classytic/arc/scope';
267
+ import type { PermissionCheck } from '@classytic/arc/permissions';
268
+
269
+ const requireScope = (scope: string): PermissionCheck => (ctx) => {
270
+ if (!isService(ctx.request.scope)) return { granted: false, reason: 'service only' };
271
+ const has = getServiceScopes(ctx.request.scope).includes(scope);
272
+ return has ? { granted: true } : { granted: false, reason: `missing scope: ${scope}` };
273
+ };
274
+
275
+ defineResource({
276
+ name: 'order',
277
+ permissions: { create: requireScope('orders:write') },
278
+ });
279
+ ```
280
+
281
+ **Mixing humans and services on the same route** — compose arc's builders:
282
+
283
+ ```typescript
284
+ // Humans with 'editor' role OR services with 'orders:write' scope
285
+ permissions: {
286
+ create: anyOf(requireOrgRole('editor'), requireScope('orders:write')),
287
+ }
288
+ ```
289
+
290
+ **Audit differentiation** — because `scope.kind === 'service'` carries `clientId` instead of `userId`, your audit plugin's `actor` resolver can tag machine actions distinctly:
291
+
292
+ ```typescript
293
+ auditPlugin({
294
+ actor: (request) =>
295
+ isService(request.scope)
296
+ ? { kind: 'service', clientId: getClientId(request.scope) }
297
+ : { kind: 'user', userId: getUserId(request.scope) },
298
+ });
299
+ ```
300
+
301
+ **Rate-limit separation** — gate a separate bucket per `clientId` so one noisy agent can't starve humans in the same org:
302
+
303
+ ```typescript
304
+ rateLimit: {
305
+ keyGenerator: (req) =>
306
+ isService(req.scope) ? `svc:${getClientId(req.scope)}` : `org:${getOrgId(req.scope)}`,
307
+ }
308
+ ```
309
+
310
+ **Credential-flow choice is the app's** — arc does not pick for you:
311
+
312
+ | Flow | Wire via `authenticate` callback |
313
+ |---|---|
314
+ | Arc JWT with `clientId` claim | example above |
315
+ | API key in header (DB lookup) | read `x-api-key`, look up client, set `scope` to service |
316
+ | Better Auth client credentials | `auth.api.getSession({ headers })`, detect client-credential grant |
317
+ | mTLS cert DN → client | read cert from terminator header, map DN → `clientId` |
318
+ | OAuth2 client-credentials | verify token via introspection endpoint |
319
+
320
+ All five produce the same downstream primitive: `RequestScope.service`. Permission builders, audit hooks, and rate-limit keys compose the same way regardless.
321
+
228
322
  ## Microservice Gateway Pattern
229
323
 
230
324
  ```
@@ -116,22 +116,73 @@ const unsub = await fastify.events.subscribe('order.created', handler);
116
116
  unsub();
117
117
  ```
118
118
 
119
- ## Event Structure
119
+ ## Event Structure (v2.9)
120
120
 
121
121
  ```typescript
122
+ interface EventMeta {
123
+ id: string; // UUID v4 (fresh per emit; a retry gets a new id)
124
+ timestamp: Date;
125
+ schemaVersion?: number; // bump on payload breaking change
126
+ correlationId?: string; // stable across causal chain
127
+ causationId?: string; // direct parent event id
128
+ partitionKey?: string; // ordering hint (Kafka/Kinesis/Streams)
129
+ source?: string; // originating service/package ('commerce', 'billing')
130
+ idempotencyKey?: string; // cross-transport dedupe hint — stable per operation
131
+ resource?: string;
132
+ resourceId?: string;
133
+ userId?: string;
134
+ organizationId?: string;
135
+ aggregate?: { type: string; id: string }; // DDD aggregate marker
136
+ }
137
+
122
138
  interface DomainEvent<T> {
123
- type: string; // e.g., 'order.created'
139
+ type: string; // e.g., 'order.created'
124
140
  payload: T;
125
- meta: {
126
- id: string; // Unique event ID
127
- timestamp: Date;
128
- source?: string;
129
- resource?: string;
130
- resourceId?: string;
131
- userId?: string;
132
- organizationId?: string;
133
- correlationId?: string;
134
- };
141
+ meta: EventMeta;
142
+ }
143
+ ```
144
+
145
+ **Arc is source of truth** — `@classytic/primitives/events` mirrors these shapes. Downstream packages import from primitives; arc owns evolution.
146
+
147
+ ### DDD aggregate narrowing
148
+
149
+ `aggregate.type` is `string` in arc's base contract so it stays framework-neutral. Domain packages narrow it to a closed union via interface extension:
150
+
151
+ ```typescript
152
+ // @classytic/cart
153
+ type CartAggregateType = 'cart' | 'cart-item';
154
+
155
+ interface CartEventMeta extends EventMeta {
156
+ aggregate?: { type: CartAggregateType; id: string };
157
+ }
158
+ ```
159
+
160
+ Unlike `correlationId` / `causationId`, `aggregate` is **not inherited** by `createChildEvent`. Child events usually belong to a different aggregate (e.g. an `order.placed` event emitted by the order aggregate spawns `inventory.reserved` owned by the inventory aggregate). Each event names its own aggregate explicitly.
161
+
162
+ ### Causation chains
163
+
164
+ ```typescript
165
+ import { createEvent, createChildEvent } from '@classytic/arc/events';
166
+
167
+ const placed = createEvent('order.placed', { orderId: 'o1' }, {
168
+ correlationId: req.id, userId: user.id,
169
+ });
170
+
171
+ // Downstream handler emits child — causation linked, correlation inherited:
172
+ const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
173
+ // reserved.meta.causationId === placed.meta.id
174
+ // reserved.meta.correlationId === placed.meta.correlationId
175
+ ```
176
+
177
+ ### Dead-letter contract
178
+
179
+ ```typescript
180
+ import type { DeadLetteredEvent, EventTransport } from '@classytic/arc/events';
181
+
182
+ class KafkaTransport implements EventTransport {
183
+ async deadLetter(dlq: DeadLetteredEvent) {
184
+ await producer.send({ topic: `${dlq.event.type}.DLQ`, messages: [{ value: JSON.stringify(dlq) }] });
185
+ }
135
186
  }
136
187
  ```
137
188
 
@@ -299,3 +350,140 @@ const retriedHandler = withRetry(async (event) => {
299
350
 
300
351
  await fastify.events.subscribe('order.created', retriedHandler);
301
352
  ```
353
+
354
+ ### Auto-route exhausted events to transport.deadLetter() (v2.9)
355
+
356
+ For transports with a native DLQ (Kafka DLQ topic, SQS DLQ queue, etc.), pass
357
+ `transport` to skip custom `$deadLetter` plumbing:
358
+
359
+ ```typescript
360
+ await fastify.events.subscribe(
361
+ 'order.created',
362
+ withRetry(handler, {
363
+ maxRetries: 3,
364
+ transport: fastify.events.transport, // any EventTransport with deadLetter()
365
+ name: 'emailProcessor', // populates DeadLetteredEvent.handlerName
366
+ }),
367
+ );
368
+ ```
369
+
370
+ On exhaustion, a typed `DeadLetteredEvent` envelope (original event + error +
371
+ attempts + first/last failure timestamps) is handed to `transport.deadLetter()`.
372
+ `onDead` still works — both fire when both are configured.
373
+
374
+ ## Transactional Outbox (v2.9)
375
+
376
+ **Why the outbox exists:** you write a row + publish an event in the same user
377
+ request. If the transport (Redis/Kafka) is down at that moment, the row
378
+ commits but the event vanishes — silent data divergence. The outbox persists
379
+ the event in the **same DB transaction** as the row, then a background relayer
380
+ guarantees at-least-once delivery to the transport. Multi-worker claim +
381
+ retry/DLQ policy + dedupe make it scale.
382
+
383
+ `EventOutbox` now offers centralised retry/DLQ and typed DLQ query:
384
+
385
+ ```typescript
386
+ import { EventOutbox, MemoryOutboxStore, exponentialBackoff } from '@classytic/arc/events';
387
+
388
+ const outbox = new EventOutbox({
389
+ store: new MemoryOutboxStore(), // swap for durable store in prod
390
+ transport: fastify.events.transport,
391
+
392
+ // Centralised retry/DLQ — no more hand-rolled exponentialBackoff at every fail site
393
+ failurePolicy: ({ attempts, error }) => {
394
+ if (attempts >= 5) return { deadLetter: true };
395
+ return { retryAt: exponentialBackoff({ attempt: attempts }) };
396
+ },
397
+ });
398
+
399
+ // meta.idempotencyKey auto-maps to OutboxWriteOptions.dedupeKey — duplicate
400
+ // saves with the same key are silently absorbed.
401
+ await outbox.store(
402
+ createEvent('order.placed', payload, { idempotencyKey: `order:${id}:placed` }),
403
+ );
404
+
405
+ // Rich per-batch outcome — deadLettered is new in v2.9
406
+ const result = await outbox.relayBatch();
407
+ // { relayed, attempted, publishFailed, ackFailed, ownershipMismatches,
408
+ // malformed, failHookErrors, deadLettered, usedPublishMany }
409
+
410
+ // Read DLQ state as typed DeadLetteredEvent[]
411
+ const dlq = await outbox.getDeadLettered(100);
412
+ for (const envelope of dlq) {
413
+ await alertOps(envelope); // event, error, attempts, firstFailedAt, lastFailedAt
414
+ }
415
+ ```
416
+
417
+ **Store capability tiers:**
418
+
419
+ | Method | Required | What you lose without it |
420
+ |---|---|---|
421
+ | `save`, `getPending`, `acknowledge` | ✅ | — |
422
+ | `claimPending` | — | Multi-worker relay safety |
423
+ | `fail` | — | Retry / DLQ / per-event failure reporting |
424
+ | `getDeadLettered` | — | `outbox.getDeadLettered()` returns `[]` |
425
+ | `purge` | — | App owns retention (TTL index, cron DELETE, etc.) |
426
+
427
+ `MemoryOutboxStore` implements all capabilities — use it as a reference when
428
+ writing a durable store for Postgres / DynamoDB / your DB of choice.
429
+
430
+ ### Durable store — pass a `RepositoryLike`
431
+
432
+ Arc adapts any `Repository` (mongokit / prismakit / your own kit) to the
433
+ `OutboxStore` contract — no dedicated subpath, no store class to
434
+ instantiate:
435
+
436
+ ```typescript
437
+ import mongoose from 'mongoose';
438
+ import { Repository } from '@classytic/mongokit';
439
+ import { EventOutbox, exponentialBackoff, createEvent } from '@classytic/arc/events';
440
+
441
+ const OutboxModel = mongoose.model('ArcOutbox', OutboxSchema, 'arc_outbox_events');
442
+
443
+ const outbox = new EventOutbox({
444
+ repository: new Repository(OutboxModel),
445
+ transport: redisTransport,
446
+ failurePolicy: ({ attempts }) =>
447
+ attempts >= 5 ? { deadLetter: true } : { retryAt: exponentialBackoff({ attempt: attempts }) },
448
+ });
449
+
450
+ // Persist event in the same DB transaction as the row
451
+ await mongoose.connection.transaction(async (session) => {
452
+ await Order.create([orderDoc], { session });
453
+ await outbox.store(
454
+ createEvent('order.placed', { orderId }, { idempotencyKey: `order:${orderId}:placed` }),
455
+ { session },
456
+ );
457
+ });
458
+
459
+ // Background relayer
460
+ setInterval(async () => {
461
+ const r = await outbox.relayBatch();
462
+ metrics.gauge('outbox.deadLettered', r.deadLettered);
463
+ }, 1000);
464
+
465
+ // Ops: read dead-letter envelopes for alerting / replay
466
+ const dlq = await outbox.getDeadLettered(100);
467
+ ```
468
+
469
+ **What you get:**
470
+
471
+ - **Atomic multi-worker claim** — arc's adapter uses `findOneAndUpdate`
472
+ on `{ status: 'pending', visibleAt ≤ now, lease free }`. Two racing
473
+ relayers never see the same event; expired leases auto-recover.
474
+ - **Session threading** — `outbox.store(event, { session })` flows
475
+ through `Repository.create(doc, { session })` so the event commits
476
+ with your business write.
477
+ - **Dedupe** — `meta.idempotencyKey` maps to `dedupeKey`; your kit's
478
+ unique index (or equivalent) enforces idempotency.
479
+ - **DLQ** — `getDeadLettered(limit)` returns typed `DeadLetteredEvent[]`.
480
+ `RelayResult.deadLettered` counts per-batch transitions.
481
+ - **Purge** — `outbox.purge(olderThanMs)` deletes delivered rows; define
482
+ retention via a TTL index (`deliveredAt`), a cron, or a scheduler —
483
+ your kit's choice.
484
+
485
+ You own the schema and indexes. Recommended shape:
486
+ `{ eventId (unique), type, payload, meta, status, attempts, leaseOwner,
487
+ leaseExpiresAt, visibleAt, dedupeKey (unique sparse), lastError,
488
+ createdAt, deliveredAt }` with indexes on `{ status, visibleAt }` and
489
+ `{ deliveredAt }` (TTL).
@@ -537,7 +537,7 @@ Use to verify the MCP server is alive before configuring Claude CLI.
537
537
 
538
538
  ### ArcRequest — Typed Fastify Request
539
539
 
540
- For `wrapHandler: false` routes, use `ArcRequest` instead of `(req as any).user`:
540
+ For `raw: true` routes, use `ArcRequest` instead of `(req as any).user`:
541
541
 
542
542
  ```typescript
543
543
  import type { ArcRequest } from '@classytic/arc';
@@ -589,28 +589,15 @@ throw createDomainError('INSUFFICIENT_BALANCE', 'Not enough credits', 402, { bal
589
589
  // Arc's error handler auto-maps statusCode to HTTP response
590
590
  ```
591
591
 
592
- ### onRegister — Resource Lifecycle Hook
593
-
594
- Called during plugin registration with the scoped Fastify instance:
595
-
596
- ```typescript
597
- defineResource({
598
- name: 'notification',
599
- onRegister: (fastify) => {
600
- setSseManager(fastify.sseManager);
601
- },
602
- })
603
- ```
604
-
605
592
  ### preAuth — Pre-Auth Handlers for SSE/WebSocket
606
593
 
607
594
  Run before auth middleware. Use for promoting `?token=` to `Authorization` header (EventSource can't set headers):
608
595
 
609
596
  ```typescript
610
- additionalRoutes: [{
597
+ routes: [{
611
598
  method: 'GET',
612
599
  path: '/stream',
613
- wrapHandler: false,
600
+ raw: true,
614
601
  permissions: requireAuth(),
615
602
  preAuth: [(req) => {
616
603
  const token = req.query?.token;
@@ -625,7 +612,7 @@ additionalRoutes: [{
625
612
  Auto-sets SSE headers and bypasses Arc's response wrapper:
626
613
 
627
614
  ```typescript
628
- additionalRoutes: [{
615
+ routes: [{
629
616
  method: 'POST',
630
617
  path: '/stream',
631
618
  streamResponse: true, // SSE headers + no { success, data } wrapper