@classytic/arc 2.41.0 → 2.43.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/{EventTransport-CbUKDzZZ.d.mts → EventTransport-CiicKz2Z.d.mts} +1 -1
- package/dist/{EventTransport-n7qO5Cz3.mjs → EventTransport-tzA7CAB4.mjs} +22 -17
- package/dist/{HookSystem-CxvZBo77.mjs → HookSystem-B6UaKRbF.mjs} +2 -2
- package/dist/{QueryCache-DFA-QrU9.d.mts → QueryCache--5uAuYPW.d.mts} +7 -3
- package/dist/{ResourceRegistry-Cu7uTJp9.mjs → ResourceRegistry-CuCARu2B.mjs} +44 -13
- package/dist/actionPermissions-BotX1YOa.mjs +776 -0
- package/dist/audit/index.d.mts +43 -5
- package/dist/audit/index.mjs +48 -26
- package/dist/auth/audit.mjs +1 -1
- package/dist/auth/index.d.mts +43 -13
- package/dist/auth/index.mjs +130 -155
- package/dist/auth/redis-session.mjs +1 -1
- package/dist/{authorizationDecision-2YY_Kaj1.mjs → authorizationDecision-DQYrkl5f.mjs} +7 -102
- package/dist/{base-CxrV1LmG.d.mts → base-CZyixEQR.d.mts} +27 -5
- package/dist/boundedMap-BULzfXYU.mjs +57 -0
- package/dist/{buildHandler-CUhjGHGZ.mjs → buildHandler-CRQ8EcX5.mjs} +4 -4
- package/dist/cache/index.d.mts +22 -34
- package/dist/cache/index.mjs +4 -4
- package/dist/{caching-DsWDjIxx.mjs → caching-Cc2M1DwO.mjs} +3 -3
- package/dist/claimed-B10RTfkM.mjs +8 -0
- package/dist/cleanup/index.d.mts +6 -10
- package/dist/cleanup/index.mjs +27 -73
- package/dist/cli/commands/describe.d.mts +4 -2
- package/dist/cli/commands/describe.mjs +7 -4
- package/dist/cli/commands/docs.mjs +3 -3
- package/dist/cli/commands/doctor.mjs +1 -1
- package/dist/cli/commands/generate.mjs +2 -2
- package/dist/cli/commands/init.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +2 -2
- package/dist/cli/index.mjs +3 -3
- package/dist/{constants-BxLgNFF7.mjs → constants-Cu7Mx7_U.mjs} +4 -2
- package/dist/context/index.d.mts +2 -5
- package/dist/context/index.mjs +18 -38
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +8 -8
- package/dist/{core-iNGqYerO.mjs → core-Cb0LjccA.mjs} +5 -4
- package/dist/{createActionRouter-BBlbPs3k.mjs → createActionRouter-xjqonMRO.mjs} +87 -39
- package/dist/{createAggregationRouter-Dv5SuK2x.mjs → createAggregationRouter-zRYWw_5D.mjs} +11 -8
- package/dist/{preloadResources-Bbp4QJ8V.mjs → createApp-D-953ZAq.mjs} +299 -390
- package/dist/{createMcpServer-CbDWkZ1T.d.mts → createMcpServer-C9cxS8zt.d.mts} +1 -1
- package/dist/{defineAggregation-UyeurGkp.mjs → defineAggregation-Bm8RjMbq.mjs} +1 -1
- package/dist/{defineResource-DzGT-2LP.mjs → defineResource-DIC7JZGU.mjs} +118 -1024
- package/dist/discovery/index.mjs +1 -1
- package/dist/docs/index.d.mts +10 -4
- package/dist/docs/index.mjs +4 -4
- package/dist/{elevation-BXCfVcNG.d.mts → elevation-BW2J3tNj.d.mts} +1 -2
- package/dist/{elevation-BF-vhGxY.mjs → elevation-DG8SrLN-.mjs} +41 -12
- package/dist/encryption/index.mjs +3 -3
- package/dist/{errorHandler-CIMkCOWm.mjs → errorHandler-BnfDviL1.mjs} +26 -5
- package/dist/{errors-C_YhELhe.mjs → errors-CtrywMHP.mjs} +58 -23
- package/dist/{errors-BGdVwP25.d.mts → errors-JYc9vwqE.d.mts} +9 -0
- package/dist/{eventPlugin-B52abJ8W.d.mts → eventPlugin-BtmA4-3P.d.mts} +65 -9
- package/dist/{eventPlugin-CmXaPWLv.mjs → eventPlugin-Q6BhzHzs.mjs} +82 -21
- package/dist/{subscribe-helpers-AVYPgqTl.mjs → eventTypes-Cdhm6ZlV.mjs} +151 -17
- package/dist/events/index.d.mts +33 -8
- package/dist/events/index.mjs +49 -53
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +139 -157
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +2 -2
- package/dist/factory/index.d.mts +32 -17
- package/dist/factory/index.mjs +213 -32
- package/dist/{fastify-augmentation-Cq2nHVaW.d.mts → fastify-augmentation-DhUJ-mvo.d.mts} +11 -25
- package/dist/{fields-I_EeRWND.d.mts → fields-BBSpf8pS.d.mts} +1 -1
- package/dist/{fields-BTyPLHK0.mjs → fields-ToPYpMqS.mjs} +1 -1
- package/dist/filter-merge-IpGywvym.mjs +99 -0
- package/dist/{generate-D_YmiCNf.mjs → generate-DRTkLlIl.mjs} +2 -2
- package/dist/{headers-CNECDE6q.mjs → headers-BbkIF6vA.mjs} +2 -2
- package/dist/health-D-Ft6JM6.mjs +22 -0
- package/dist/{health-CxQzbJmr.mjs → health-DqbsG-_w.mjs} +115 -55
- package/dist/hooks/index.d.mts +2 -2
- package/dist/hooks/index.mjs +2 -2
- package/dist/idempotency/index.d.mts +31 -7
- package/dist/idempotency/index.mjs +155 -98
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +48 -11
- package/dist/{index-BHdZD8zK.d.mts → index-Bxriq3so.d.mts} +84 -21
- package/dist/{preloadResources-CjS2OfpU.d.mts → index-C8zVGYsk.d.mts} +37 -38
- package/dist/{index-D4u76eQW.d.mts → index-CRoPtISf.d.mts} +23 -73
- package/dist/{index-CbJiYrfv.d.mts → index-Chgz3d0i.d.mts} +1 -1
- package/dist/index-JdbZAIsm.d.mts +510 -0
- package/dist/{index-DHbyPo5j.d.mts → index-mqaqZNpe.d.mts} +253 -254
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +11 -11
- package/dist/{init-Y2MlSN9n.mjs → init-B_oUYDXI.mjs} +1 -1
- package/dist/integrations/event-gateway.d.mts +8 -3
- package/dist/integrations/event-gateway.mjs +4 -4
- package/dist/integrations/index.d.mts +3 -3
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs/index.d.mts +1 -1
- package/dist/integrations/jobs/index.mjs +18 -5
- package/dist/integrations/mcp/index.d.mts +72 -59
- package/dist/integrations/mcp/index.mjs +19 -4
- package/dist/integrations/mcp/testing.d.mts +2 -2
- package/dist/integrations/mcp/testing.mjs +2 -2
- package/dist/integrations/streamline.d.mts +78 -136
- package/dist/integrations/streamline.mjs +116 -54
- package/dist/integrations/webhooks.d.mts +60 -120
- package/dist/integrations/webhooks.mjs +36 -80
- package/dist/integrations/websocket-pushref-redis.d.mts +1 -1
- package/dist/integrations/websocket-pushref-redis.mjs +1 -1
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket-redis.mjs +1 -1
- package/dist/integrations/websocket.d.mts +3 -3
- package/dist/integrations/websocket.mjs +2 -2
- package/dist/{interface-DEWNOMza.d.mts → interface-C0395yUN.d.mts} +13 -1
- package/dist/{jsonBody-Cq9KYl2Z.mjs → jsonBody-B4YVGcql.mjs} +2 -2
- package/dist/{keys-Cmv4cBRa.mjs → keys-B2KtgMLJ.mjs} +7 -18
- package/dist/{loadResources-CblF7xt2.mjs → loadResources-Dn4lalw2.mjs} +3 -3
- package/dist/{loadResourcesFromEntry-99fIMC3p.mjs → loadResourcesFromEntry-3sLYbuse.mjs} +2 -2
- package/dist/logger/index.d.mts +1 -8
- package/dist/logger/index.mjs +3 -153
- package/dist/logger-DXjfIqjV.mjs +160 -0
- package/dist/{mcpPlugin-xF3Gw-HW.mjs → mcpPlugin-C08HalN5.mjs} +433 -170
- package/dist/{memory-CpDZ9NPw.mjs → memory-9Cp7611C.mjs} +36 -34
- package/dist/{metrics-BaKnq8ag.mjs → metrics-CWI11eRu.mjs} +39 -72
- package/dist/middleware/index.d.mts +2 -2
- package/dist/middleware/index.mjs +3 -3
- package/dist/migrations/index.mjs +2 -2
- package/dist/{module-BkQEesNQ.mjs → module-BSvOQC7E.mjs} +8 -8
- package/dist/multiTenant-cY0FkDiA.d.mts +104 -0
- package/dist/{multipartBody-C92aJ8am.mjs → multipartBody-CXM2a-Dy.mjs} +46 -7
- package/dist/{openapi-C3LFRzPq.mjs → openapi-DT_G4VhY.mjs} +48 -10
- package/dist/outbox-admin/index.d.mts +3 -3
- package/dist/outbox-admin/index.mjs +6 -18
- package/dist/{outbox-module-dsCyLxXH.mjs → outbox-module-Bjgzl6tg.mjs} +203 -33
- package/dist/{outbox-module-BHdUnuuK.d.mts → outbox-module-gZCHoE4H.d.mts} +79 -6
- package/dist/permissions/index.d.mts +5 -4
- package/dist/permissions/index.mjs +8 -6
- package/dist/{permissions-DiHXy8Ck.mjs → permissions-Cm0uYHvd.mjs} +149 -33
- package/dist/{pipe-CBVmUJ-j.mjs → pipe-BqeGT6UO.mjs} +2 -2
- package/dist/pipeline/index.d.mts +2 -2
- package/dist/pipeline/index.mjs +2 -2
- package/dist/plan-FVR6LP59.mjs +565 -0
- package/dist/{BaseController-DYJRZ0EJ.mjs → plugin-BccET1tL.mjs} +1256 -344
- package/dist/plugins/index.d.mts +14 -11
- package/dist/plugins/index.mjs +44 -22
- package/dist/plugins/response-cache.mjs +8 -14
- package/dist/plugins/tracing-entry.mjs +2 -2
- package/dist/{pluralize-2_U6QZQg.mjs → pluralize-CVIOcgMF.mjs} +1 -1
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +5 -5
- package/dist/presets/index.d.mts +26 -31
- package/dist/presets/index.mjs +4 -4
- package/dist/presets/multiTenant.d.mts +2 -107
- package/dist/presets/multiTenant.mjs +22 -162
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +2 -2
- package/dist/{presets-BdlugYTc.mjs → presets-Cpvj6fGO.mjs} +99 -107
- package/dist/{projection-Uvgp-EEj.mjs → projection-Ce4aQhQk.mjs} +2 -2
- package/dist/{pushref-registry-Dov9l4qE.d.mts → pushref-registry-UxCkoA4C.d.mts} +1 -1
- package/dist/{queryCachePlugin-BzqxconG.d.mts → queryCachePlugin-C2lZjTxk.d.mts} +1 -1
- package/dist/{queryCachePlugin-B5F09bYr.mjs → queryCachePlugin-CFWNH8aT.mjs} +12 -14
- package/dist/{rateLimitKey-CK-EU2L8.mjs → rateLimitKey-C4O-kPm0.mjs} +1 -1
- package/dist/{realtime-FnnD0n3Z.mjs → realtime-DmPE9S54.mjs} +25 -21
- package/dist/redis-stream-BMk-bpQS.d.mts +219 -0
- package/dist/{redis-Bzi1y3F9.d.mts → redis-vLvtcGy_.d.mts} +13 -24
- package/dist/redisShapes-CQksY67q.d.mts +55 -0
- package/dist/registry/index.d.mts +9 -4
- package/dist/registry/index.mjs +4 -4
- package/dist/{registry-D3y8VEJ1.mjs → registry-Bcz9G1bh.mjs} +10 -8
- package/dist/{renewingLease-B3VYh5aU.mjs → renewingLease-qODa7KJQ.mjs} +1 -1
- package/dist/{replyHelpers-DPHsdw-5.mjs → replyHelpers-CG_VtVgS.mjs} +2 -2
- package/dist/{requestContext-cSVKnYPk.mjs → requestContext-Dsu9P1bV.mjs} +1 -1
- package/dist/requirement-CbiLdQ1n.d.mts +22 -0
- package/dist/requirement-KDdoRcOP.mjs +35 -0
- package/dist/resourceRateLimit-Wf6d6FL9.d.mts +30 -0
- package/dist/resourceRateLimit-dSjVr1pk.mjs +180 -0
- package/dist/{rolldown-runtime-DjDuyglE.mjs → rolldown-runtime-D1w9Y1VJ.mjs} +1 -1
- package/dist/routeValidator-D36SW8qt.mjs +70 -0
- package/dist/{runtimeCapabilities-DSA_dH1_.mjs → runtimeCapabilities-0tccjRlj.mjs} +1 -1
- package/dist/{schedules-BktJb3Y3.mjs → schedules-C-OpmHBq.mjs} +46 -37
- package/dist/{schemaConverter-BLCSmNbr.mjs → schemaConverter-BORek3O-.mjs} +2 -2
- package/dist/{schemaIR-K16Bb5BX.mjs → schemaIR-C7Vbp71I.mjs} +2 -2
- package/dist/schemas/index.d.mts +10 -0
- package/dist/schemas/index.mjs +8 -3
- package/dist/scim/index.mjs +2 -2
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +6 -6
- package/dist/{scope-jSxuBTFM.mjs → scope-DQthe88b.mjs} +4 -4
- package/dist/shutdown-MqA81z_P.mjs +59 -0
- package/dist/{simpleEqualityMatcher-BiOnyflP.mjs → simpleEqualityMatcher-auW8QjXN.mjs} +1 -1
- package/dist/{sse-CH7Slxc1.mjs → sse-CYzQBVFe.mjs} +11 -15
- package/dist/{sseStream-CN-Lh1Tu.mjs → sseStream-B5_Afiqn.mjs} +2 -2
- package/dist/{store-helpers-9DX9-1j_.mjs → store-helpers-BOzk-CuX.mjs} +1 -1
- package/dist/{streaming-dvppnxsr.mjs → streaming-DjTQhZrN.mjs} +1 -1
- package/dist/subscriberIdentity-CW39uA8G.mjs +28 -0
- package/dist/tenancy--ed9eH_z.mjs +43 -0
- package/dist/tenantPreparation-BKon6Yox.mjs +67 -0
- package/dist/testing/cleanupStoreContract.mjs +1 -1
- package/dist/testing/index.d.mts +193 -175
- package/dist/testing/index.mjs +286 -212
- package/dist/testing/outboxStoreContract.mjs +137 -2
- package/dist/testing/requiredPorts.d.mts +17 -0
- package/dist/testing/requiredPorts.mjs +65 -0
- package/dist/testing/storageContract.mjs +1 -1
- package/dist/timing-qczZ0YC-.mjs +46 -0
- package/dist/{transactionContext-DCWMI6hV.mjs → transactionContext-DjFkrNG6.mjs} +1 -1
- package/dist/{typeGuards-D7tHFdH6.mjs → typeGuards-CySF12G-.mjs} +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/index.mjs +1 -1
- package/dist/types/storage.mjs +1 -1
- package/dist/{types-CWQn9-IC.d.mts → types-B4Aq4Sdj.d.mts} +22 -9
- package/dist/{types-DfVDvkdP.d.mts → types-BJ-2XqVF.d.mts} +189 -204
- package/dist/{types-CDy3-zw1.mjs → types-Bk9NOgOC.mjs} +4 -4
- package/dist/usage/index.d.mts +2 -3
- package/dist/usage/index.mjs +5 -5
- package/dist/{userHelpers-DDMOZ_Pm.mjs → userHelpers-aklawRaK.mjs} +1 -1
- package/dist/utils/index.d.mts +84 -223
- package/dist/utils/index.mjs +13 -12
- package/dist/{utils-B-zj_b__.mjs → utils-ec1oTo1n.mjs} +315 -404
- package/dist/{validate-R0G_JrnK.mjs → validate-9yzHC1e2.mjs} +6 -14
- package/dist/{versioning-D6JIK8Tk.d.mts → versioning-Bsb51gGT.d.mts} +28 -13
- package/dist/{versioning-DuVWcu-F.mjs → versioning-fhhWHV74.mjs} +4 -4
- package/dist/{websocket-BOXVggMf.d.mts → websocket-Cs4awVyD.d.mts} +1 -1
- package/dist/{workScope-BhsWde4k.mjs → workScope-zpPyge4t.mjs} +2 -2
- package/package.json +578 -574
- package/skills/arc/SKILL.md +13 -4
- package/skills/arc/references/auth.md +8 -5
- package/skills/arc/references/integrations.md +1 -1
- package/skills/arc/references/mcp.md +8 -6
- package/skills/arc/references/multi-tenancy.md +19 -14
- package/skills/arc/references/production.md +27 -10
- package/dist/actionPermissions-DPwPWPOV.mjs +0 -350
- package/dist/index-CFfLKMH4.d.mts +0 -290
- package/dist/interface-BIpgzbZl.mjs +0 -17
- package/dist/redis-stream-BPKscsTB.d.mts +0 -260
- package/dist/registerRoute-DRsSHPSM.mjs +0 -364
- package/dist/sync/changelog.d.mts +0 -3
- package/dist/sync/changelog.mjs +0 -3
package/skills/arc/SKILL.md
CHANGED
|
@@ -77,7 +77,7 @@ createApp({ role: process.env.ARC_ROLE as 'api' | 'worker' | 'relay' | 'schedule
|
|
|
77
77
|
|
|
78
78
|
`api` and `worker` mount NO module schedule arms; `relay` mounts only `kind: 'relay'` arms (the outbox relay, durable webhook delivery); `scheduler` mounts all of them. Declaring `relay`/`scheduler` together with `resources`/`resourceDir` is BOOT-FATAL — a relay process must not serve routes. Default `all` is the pre-2.33 behaviour, unchanged. `createWorker()` forwards `role`; without one it still runs everything non-HTTP.
|
|
79
79
|
|
|
80
|
-
`beforeBoot()` (2.21) runs before Fastify exists and before module thunks / resource files import — connect the DB there instead of dynamic-import ordering hacks.
|
|
80
|
+
`beforeBoot()` (2.21) runs before Fastify exists and before module thunks / resource files import — connect the DB there instead of dynamic-import ordering hacks. Return a disposer (`() => mongoose.disconnect()`) and arc runs it on close and on a failed boot. A bridge declared as `{ kind: 'bridge', name, dependsOn, load }` is never imported when inactive.
|
|
81
81
|
|
|
82
82
|
For async-booted engines, pass `resources` as a factory so it runs after `bootstrap[]`:
|
|
83
83
|
|
|
@@ -210,7 +210,14 @@ presets: ['softDelete', { name: 'multiTenant', tenantField: 'organizationId' }]
|
|
|
210
210
|
|
|
211
211
|
## Permissions
|
|
212
212
|
|
|
213
|
-
A `PermissionCheck` returns `boolean |
|
|
213
|
+
A `PermissionCheck` returns `boolean | AuthorizationDecision` — build one with `allow({ policy?, scope? })` / `deny(reason?)`. `policy` is ANDed into the repo query (row-level ABAC); `scope` stamps attributes downstream. The pre-2.30 `{ granted, filters }` shape THROWS — never emit it.
|
|
214
|
+
|
|
215
|
+
**Public but row-filtered** — use `allowPublic({ policy })`, not a hand-written `allow({ policy })`: a hand-written check lacks the `_isPublic` flag, so anonymous callers get 401.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
list: allowPublic({ policy: { status: 'published' } }), // strangers: published only
|
|
219
|
+
list: allowPublic({ policy: (ctx) => isElevated(scopeOf(ctx)) ? {} : { status: 'published' } }), // {} = unrestricted
|
|
220
|
+
```
|
|
214
221
|
|
|
215
222
|
```typescript
|
|
216
223
|
import {
|
|
@@ -233,9 +240,11 @@ permissions: {
|
|
|
233
240
|
**Custom check:**
|
|
234
241
|
|
|
235
242
|
```typescript
|
|
243
|
+
import { allow, deny, type PermissionCheck } from '@classytic/arc/permissions';
|
|
244
|
+
|
|
236
245
|
const requirePro = (): PermissionCheck => async (ctx) => {
|
|
237
|
-
if (!ctx.user) return
|
|
238
|
-
return
|
|
246
|
+
if (!ctx.user) return deny('Auth required');
|
|
247
|
+
return ctx.user.plan === 'pro' ? allow() : deny('Pro plan required');
|
|
239
248
|
};
|
|
240
249
|
```
|
|
241
250
|
|
|
@@ -497,6 +497,7 @@ import {
|
|
|
497
497
|
| Function | Description |
|
|
498
498
|
|----------|-------------|
|
|
499
499
|
| `allowPublic()` | No authentication |
|
|
500
|
+
| `allowPublic({ policy })` | No authentication, rows narrowed by a filter (or a per-caller resolver; `{}` = unrestricted) |
|
|
500
501
|
| `requireAuth()` | Any authenticated user |
|
|
501
502
|
| `requireRoles(['admin'])` | At least one role matches |
|
|
502
503
|
| `requireOwnership('userId')` | Resource owner only (elevated bypasses) |
|
|
@@ -510,9 +511,11 @@ import {
|
|
|
510
511
|
**Custom permission:**
|
|
511
512
|
|
|
512
513
|
```typescript
|
|
514
|
+
import { allow, deny, type PermissionCheck } from '@classytic/arc/permissions';
|
|
515
|
+
|
|
513
516
|
const requirePro = (): PermissionCheck => async (ctx) => {
|
|
514
|
-
if (!ctx.user) return
|
|
515
|
-
return
|
|
517
|
+
if (!ctx.user) return deny('Auth required');
|
|
518
|
+
return ctx.user.plan === 'pro' ? allow() : deny('Pro plan required');
|
|
516
519
|
};
|
|
517
520
|
```
|
|
518
521
|
|
|
@@ -646,12 +649,12 @@ Gate routes on service identity using the scope helpers + a permission builder:
|
|
|
646
649
|
|
|
647
650
|
```typescript
|
|
648
651
|
import { isService, getClientId, getServiceScopes } from '@classytic/arc/scope';
|
|
649
|
-
import type
|
|
652
|
+
import { allow, deny, type PermissionCheck } from '@classytic/arc/permissions';
|
|
650
653
|
|
|
651
654
|
const requireScope = (scope: string): PermissionCheck => (ctx) => {
|
|
652
|
-
if (!isService(ctx.request.scope)) return
|
|
655
|
+
if (!isService(ctx.request.scope)) return deny('service only');
|
|
653
656
|
const has = getServiceScopes(ctx.request.scope).includes(scope);
|
|
654
|
-
return has ?
|
|
657
|
+
return has ? allow() : deny(`missing scope: ${scope}`);
|
|
655
658
|
};
|
|
656
659
|
|
|
657
660
|
defineResource({
|
|
@@ -238,7 +238,7 @@ Single configuration point for both SSE and WebSocket with shared auth, org-scop
|
|
|
238
238
|
```typescript
|
|
239
239
|
await fastify.register(eventGatewayPlugin, {
|
|
240
240
|
auth: true, // Fail-closed for both SSE and WebSocket
|
|
241
|
-
orgScoped: true
|
|
241
|
+
// orgScoped: omitted → SSE's default (true). Set false only for a cross-tenant ops stream.
|
|
242
242
|
roomPolicy: (client, room) => {
|
|
243
243
|
return ['product', 'order', 'invoice'].includes(room);
|
|
244
244
|
},
|
|
@@ -101,18 +101,20 @@ await app.register(mcpPlugin, {
|
|
|
101
101
|
|
|
102
102
|
### Permission Filters (v2.4.2)
|
|
103
103
|
|
|
104
|
-
Resource permissions
|
|
104
|
+
Resource permissions that return a row `policy` are automatically enforced in MCP tools — same as REST:
|
|
105
105
|
|
|
106
106
|
```typescript
|
|
107
|
+
import { allow, deny } from '@classytic/arc/permissions';
|
|
108
|
+
|
|
107
109
|
defineResource({
|
|
108
110
|
name: 'task',
|
|
109
111
|
permissions: {
|
|
110
|
-
list: (ctx) =>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
list: (ctx) =>
|
|
113
|
+
ctx.user
|
|
114
|
+
? allow({ policy: { orgId: ctx.user.orgId, branchId: ctx.user.branchId } })
|
|
115
|
+
: deny('Auth required'),
|
|
114
116
|
create: (ctx) => !!ctx.user, // boolean works too
|
|
115
|
-
delete: (
|
|
117
|
+
delete: () => deny('Read-only'),
|
|
116
118
|
},
|
|
117
119
|
});
|
|
118
120
|
|
|
@@ -38,15 +38,21 @@ Every request goes through this ladder — if any step is missed, tenant isolati
|
|
|
38
38
|
|
|
39
39
|
## Where `tenantField` is read
|
|
40
40
|
|
|
41
|
-
Every resource defines `tenantField` (default: `'organizationId'`, or `false` to disable).
|
|
41
|
+
Every resource defines `tenantField` (default: `'organizationId'`, or `false` to disable). **One function decides tenancy — `resolveTenancy()` in `src/core/tenancy.ts`** — and every layer switches on its result:
|
|
42
42
|
|
|
43
|
-
|
|
|
44
|
-
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
43
|
+
| Result | Meaning |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `{ mode: 'scoped', field, orgId }` | filter by `field = orgId` (a member, a service key, or an elevated admin who SELECTED an org) |
|
|
46
|
+
| `unscoped: 'platform'` | elevated admin with NO org — sees and writes across tenants |
|
|
47
|
+
| `unscoped: 'cross-tenant'` | the route declared `multiTenantPreset({ crossTenant: [...] })` — reads only; the row policy still applies |
|
|
48
|
+
| `unscoped: 'no-org'` | anonymous, or signed in with no org selected — reads get no tenant filter (the preset refuses earlier where one is required); **writes are refused** |
|
|
49
|
+
| `unscoped: 'untenanted-resource'` | `tenantField: false` |
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
Consumers: `QueryResolver` (list), `AccessControl.buildIdFilter` (get/update/delete by id) and `checkOrgScope` (post-fetch), `buildTenantRepoOptions` (what the kit is told, `bypassTenant`), the bulk mixin (`bulkCreate` stamping + `buildBulkFilter`), and — via `eventAudienceOf` / `isEventVisible` — the `sse` and `realtime` streams.
|
|
52
|
+
|
|
53
|
+
**Never re-derive the org from the scope in a new layer.** Each layer used to, with its own condition, and they drifted: `crossTenant` was taught to three of four read layers (`get` silently ignored it for signed-in members), bulk writes skipped the org for ANY elevated admin (an admin acting as org-9 deleted across every tenant), and `realtime` withheld the platform view that reads grant. The union is exhaustive, so a new mode is a compile error in every consumer that has not handled it.
|
|
54
|
+
|
|
55
|
+
The cache key (`deriveCacheScope`) deliberately does NOT use it: it answers "whose cache partition", which on a cross-tenant read is still the caller's org — dropping it could let one org's cached page answer another's.
|
|
50
56
|
|
|
51
57
|
## Filter composition
|
|
52
58
|
|
|
@@ -101,19 +107,18 @@ Return the scope directly from your permission check. This is the cleanest integ
|
|
|
101
107
|
|
|
102
108
|
```ts
|
|
103
109
|
// src/permissions/apiKey.ts
|
|
104
|
-
import type
|
|
110
|
+
import { allow, deny, type PermissionCheck } from '@classytic/arc/permissions';
|
|
105
111
|
import { ClientModel } from '../models/Client.js';
|
|
106
112
|
|
|
107
113
|
export function requireApiKey(): PermissionCheck {
|
|
108
114
|
return async ({ request }) => {
|
|
109
115
|
const apiKey = request.headers['x-api-key'] as string | undefined;
|
|
110
|
-
if (!apiKey) return
|
|
116
|
+
if (!apiKey) return deny('Missing API key');
|
|
111
117
|
|
|
112
118
|
const client = await ClientModel.findOne({ apiKey });
|
|
113
|
-
if (!client) return
|
|
119
|
+
if (!client) return deny('Invalid API key');
|
|
114
120
|
|
|
115
|
-
return {
|
|
116
|
-
granted: true,
|
|
121
|
+
return allow({
|
|
117
122
|
scope: {
|
|
118
123
|
kind: 'service',
|
|
119
124
|
clientId: String(client._id),
|
|
@@ -121,8 +126,8 @@ export function requireApiKey(): PermissionCheck {
|
|
|
121
126
|
scopes: client.allowedScopes, // optional OAuth-style scope strings
|
|
122
127
|
},
|
|
123
128
|
// Optional additional row-level narrowing
|
|
124
|
-
|
|
125
|
-
};
|
|
129
|
+
...(client.projectId ? { policy: { projectId: client.projectId } } : {}),
|
|
130
|
+
});
|
|
126
131
|
};
|
|
127
132
|
}
|
|
128
133
|
```
|
|
@@ -76,8 +76,8 @@ Default `0`, which is the pre-2.41 behaviour: close the moment SIGTERM arrives.
|
|
|
76
76
|
|
|
77
77
|
`drainDelayMs` is the window between "start failing readiness" and "stop accepting":
|
|
78
78
|
|
|
79
|
-
- `/
|
|
80
|
-
- `/
|
|
79
|
+
- `/_health/ready` → `503 { status: 'draining' }` immediately on signal
|
|
80
|
+
- `/_health/live` → stays `200` (a draining process is alive — that's the point; a 503 here gets the pod **killed**, not drained)
|
|
81
81
|
- everything already routed here is still served for the whole window
|
|
82
82
|
|
|
83
83
|
Set it **above** your LB's deregistration window — typically 5000–15000ms. On Kubernetes, also keep `terminationGracePeriodSeconds` > `drainDelayMs + timeout`, or the kubelet SIGKILLs you mid-drain.
|
|
@@ -299,7 +299,7 @@ const app = await createApp({
|
|
|
299
299
|
path: '/events/stream', // SSE endpoint (default: '/events/stream')
|
|
300
300
|
requireAuth: true, // Fail-closed auth (default: true)
|
|
301
301
|
patterns: ['order.*', 'product.*'], // Event patterns to stream (default: ['*'])
|
|
302
|
-
orgScoped
|
|
302
|
+
// orgScoped defaults to TRUE (2.41.1) — a caller only sees its own org's events.
|
|
303
303
|
heartbeat: 30000, // Heartbeat interval in ms (default: 30000)
|
|
304
304
|
},
|
|
305
305
|
},
|
|
@@ -307,7 +307,7 @@ const app = await createApp({
|
|
|
307
307
|
|
|
308
308
|
// Manual registration
|
|
309
309
|
import { ssePlugin } from '@classytic/arc/plugins';
|
|
310
|
-
await fastify.register(ssePlugin, { requireAuth: true
|
|
310
|
+
await fastify.register(ssePlugin, { requireAuth: true });
|
|
311
311
|
```
|
|
312
312
|
|
|
313
313
|
### Realtime resource change feed (2.22)
|
|
@@ -322,12 +322,9 @@ row-permissioned resources through it); event firehose → sse. Massive fan-out
|
|
|
322
322
|
(viral feeds, live scores) → publish once to a dedicated edge fan-out layer
|
|
323
323
|
via `app.events.subscribe(...)`; full tiering guide: docs/production-ops/realtime.mdx.
|
|
324
324
|
|
|
325
|
-
```typescript
|
|
326
|
-
```
|
|
327
|
-
|
|
328
325
|
**Fail-closed auth:** When `requireAuth: true` (default), throws at registration if `fastify.authenticate` is missing — prevents exposing SSE without auth.
|
|
329
326
|
|
|
330
|
-
**Org-scoped:**
|
|
327
|
+
**Org-scoped by default (2.41.1):** an event carrying `organizationId` reaches only callers in that org. It defaulted to `false` — with `patterns: ['*']`, one tenant's member received every tenant's events, full documents included. Events with no `organizationId` still reach everyone, so single-tenant apps are unaffected; an elevated admin with no active org sees all tenants (the platform view). `orgScoped: false` is for a deliberately cross-tenant stream only — keep `requireAuth` and add a `filter`.
|
|
331
328
|
|
|
332
329
|
## Error Handler Plugin
|
|
333
330
|
|
|
@@ -383,7 +380,7 @@ return reply.header('content-type', 'text/csv').send(readableStream);
|
|
|
383
380
|
|
|
384
381
|
### BigInt Serialization
|
|
385
382
|
|
|
386
|
-
Opt-in: `createApp({ serializeBigInt:
|
|
383
|
+
Opt-in: `createApp({ serializeBigInt: 'string' })` — BigInt → decimal string in JSON responses (lossless); `'number'` is lossy above 2^53-1.
|
|
387
384
|
|
|
388
385
|
## Multipart Body Middleware (v2.7.3)
|
|
389
386
|
|
|
@@ -678,7 +675,27 @@ await outbox.store(event, { session });
|
|
|
678
675
|
const relayed = await outbox.relay(); // publishes pending → transport
|
|
679
676
|
```
|
|
680
677
|
|
|
681
|
-
**
|
|
678
|
+
**Prefer the module form** — it owns the relay schedule, shutdown, and a readiness check, so none of that is hand-wired:
|
|
679
|
+
|
|
680
|
+
```typescript
|
|
681
|
+
import { createOutboxModule, exponentialBackoff, repositoryAsOutboxStore } from '@classytic/arc/events';
|
|
682
|
+
|
|
683
|
+
const outbox = createOutboxModule({
|
|
684
|
+
store: repositoryAsOutboxStore(new Repository(OutboxModel)),
|
|
685
|
+
// Bounds retries AND log volume. `attempts` is the DURABLE count (it survives relay
|
|
686
|
+
// restarts and is shared across relays), so a poison event really does stop at 5.
|
|
687
|
+
failurePolicy: ({ attempts }) =>
|
|
688
|
+
attempts >= 5 ? { deadLetter: true } : { retryAt: exponentialBackoff({ attempt: attempts }) },
|
|
689
|
+
health: { relayLagUnhealthyMs: 5 * 60_000, probeCacheMs: 5_000 },
|
|
690
|
+
});
|
|
691
|
+
// createApp({ modules: [outbox] }); consumers declare dependsOn: ['outbox'].
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
- `requestDrain()` — nudge a pass after commit (milliseconds, not `relayEveryMs`); runs outside the caller's request scope.
|
|
695
|
+
- `drain()` — await delivery, cascades included. For tests and a shutdown that should empty the queue.
|
|
696
|
+
- `health()` + readiness check `<name>.relay` — **non-critical** (a stalled relay is a cluster problem; pulling one pod would not drain it). Unhealthy on any dead letter or pending lag past the threshold. The probe reuses a reading for `probeCacheMs` so N replicas × probe rate do not become N count queries a second on the outbox table.
|
|
697
|
+
|
|
698
|
+
**OutboxStore contract** (`@classytic/primitives/outbox`): required `save`, `getPending`, `acknowledge`; optional `claimPending` / `claimPendingFenced` (lease + fencing token, and the durable `attempts`), `fail`, `getDeadLettered`, `requeue`, `purge`, `countByStatus`, `oldestPendingAgeMs`. Prove a custom store with `runOutboxStoreContract` from `@classytic/arc/testing/outbox`. When you pass `repository`, arc adapts it internally.
|
|
682
699
|
|
|
683
700
|
## Bulk Operations Preset
|
|
684
701
|
|
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
/*! @classytic/arc v2.41.0 | MIT | https://classytic.com */
|
|
2
|
-
import { n as getUserRoles } from "./userHelpers-DDMOZ_Pm.mjs";
|
|
3
|
-
import { b as isElevated, n as PUBLIC_SCOPE, x as isMember } from "./types-CDy3-zw1.mjs";
|
|
4
|
-
import { a as resolveEffectiveRoles, t as applyFieldReadPermissions } from "./fields-BTyPLHK0.mjs";
|
|
5
|
-
import { t as buildRequestScopeProjection } from "./projection-Uvgp-EEj.mjs";
|
|
6
|
-
import { t as executePipeline } from "./pipe-CBVmUJ-j.mjs";
|
|
7
|
-
import { isPaginatedResult, toCanonicalList } from "@classytic/repo-core/pagination";
|
|
8
|
-
//#region src/core/fastifyAdapter.ts
|
|
9
|
-
/** Type guard for Mongoose-like documents with toObject() */
|
|
10
|
-
function isMongooseDoc(obj) {
|
|
11
|
-
return !!obj && typeof obj === "object" && "toObject" in obj && typeof obj.toObject === "function";
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Apply field mask to a single object
|
|
15
|
-
* Filters fields based on include/exclude rules
|
|
16
|
-
*/
|
|
17
|
-
function applyFieldMaskToObject(obj, fieldMask) {
|
|
18
|
-
if (!obj || typeof obj !== "object") return obj;
|
|
19
|
-
const plain = isMongooseDoc(obj) ? obj.toObject() : obj;
|
|
20
|
-
const { include, exclude } = fieldMask;
|
|
21
|
-
if (include && include.length > 0) {
|
|
22
|
-
const filtered = {};
|
|
23
|
-
for (const field of include) if (field in plain) filtered[field] = plain[field];
|
|
24
|
-
return filtered;
|
|
25
|
-
}
|
|
26
|
-
if (exclude && exclude.length > 0) {
|
|
27
|
-
const filtered = { ...plain };
|
|
28
|
-
for (const field of exclude) delete filtered[field];
|
|
29
|
-
return filtered;
|
|
30
|
-
}
|
|
31
|
-
return plain;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Apply field mask to response data (handles both objects and arrays)
|
|
35
|
-
*/
|
|
36
|
-
function applyFieldMask(data, fieldMask) {
|
|
37
|
-
if (!fieldMask) return data;
|
|
38
|
-
if (Array.isArray(data)) return data.map((item) => applyFieldMaskToObject(item, fieldMask));
|
|
39
|
-
if (data && typeof data === "object") return applyFieldMaskToObject(data, fieldMask);
|
|
40
|
-
return data;
|
|
41
|
-
}
|
|
42
|
-
/**
|
|
43
|
-
* Project `FastifyRequest` into the curated `IRequestContext` shape that
|
|
44
|
-
* arc's controllers receive. This is a developer-facing facade, not a
|
|
45
|
-
* portability boundary — arc is Fastify-only. The projection lifts the
|
|
46
|
-
* fields tenant-scoped controllers reach for (`organizationId`,
|
|
47
|
-
* `teamId`, `scope`, `context`, server accessor) onto a flat, typed
|
|
48
|
-
* surface so handler code doesn't have to dig through Fastify's
|
|
49
|
-
* decorator surface or `req.metadata._scope` internals.
|
|
50
|
-
*/
|
|
51
|
-
function createRequestContext(req) {
|
|
52
|
-
const reqWithExtras = req;
|
|
53
|
-
const requestContext = reqWithExtras.context ?? {};
|
|
54
|
-
const srv = req.server;
|
|
55
|
-
const serverAccessor = {
|
|
56
|
-
events: srv && "events" in srv ? srv.events : void 0,
|
|
57
|
-
audit: srv && "audit" in srv ? srv.audit : void 0,
|
|
58
|
-
queryCache: srv && "queryCache" in srv ? srv.queryCache : void 0,
|
|
59
|
-
log: req.log
|
|
60
|
-
};
|
|
61
|
-
const rawScope = reqWithExtras.scope;
|
|
62
|
-
const scopeProjection = buildRequestScopeProjection(rawScope);
|
|
63
|
-
return {
|
|
64
|
-
query: reqWithExtras.query ?? {},
|
|
65
|
-
body: reqWithExtras.body ?? {},
|
|
66
|
-
params: reqWithExtras.params ?? {},
|
|
67
|
-
headers: reqWithExtras.headers,
|
|
68
|
-
user: reqWithExtras.user ? (() => {
|
|
69
|
-
const user = reqWithExtras.user;
|
|
70
|
-
const rawId = user._id ?? user.id;
|
|
71
|
-
const normalizedId = rawId ? String(rawId) : void 0;
|
|
72
|
-
return {
|
|
73
|
-
...user,
|
|
74
|
-
id: normalizedId,
|
|
75
|
-
_id: normalizedId
|
|
76
|
-
};
|
|
77
|
-
})() : null,
|
|
78
|
-
context: requestContext,
|
|
79
|
-
scope: scopeProjection,
|
|
80
|
-
metadata: {
|
|
81
|
-
...reqWithExtras.context,
|
|
82
|
-
arc: reqWithExtras.arc,
|
|
83
|
-
_scope: rawScope,
|
|
84
|
-
_ownershipCheck: reqWithExtras._ownershipCheck,
|
|
85
|
-
_crossTenantRead: reqWithExtras._crossTenantRead,
|
|
86
|
-
_policyFilters: reqWithExtras._policyFilters ?? {},
|
|
87
|
-
log: reqWithExtras.log
|
|
88
|
-
},
|
|
89
|
-
server: serverAccessor
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Get typed auth context from an IRequestContext.
|
|
94
|
-
* Use this in controller overrides to access request context.
|
|
95
|
-
*
|
|
96
|
-
* For org scope, use `getControllerScope(req)` instead.
|
|
97
|
-
*/
|
|
98
|
-
function getControllerContext(req) {
|
|
99
|
-
return req.context ?? req.metadata ?? {};
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Get request scope from an IRequestContext.
|
|
103
|
-
* Returns the RequestScope set by auth adapters.
|
|
104
|
-
*/
|
|
105
|
-
function getControllerScope(req) {
|
|
106
|
-
return req.metadata?._scope ?? PUBLIC_SCOPE;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Compute per-field capability metadata for the current user.
|
|
110
|
-
* Only includes fields that have restrictions — unrestricted fields
|
|
111
|
-
* are omitted (frontend defaults to { readable: true, writable: true }).
|
|
112
|
-
*/
|
|
113
|
-
function computeFieldCapabilities(fieldPerms, effectiveRoles) {
|
|
114
|
-
const caps = {};
|
|
115
|
-
for (const [field, perm] of Object.entries(fieldPerms)) {
|
|
116
|
-
let readable = true;
|
|
117
|
-
let writable = true;
|
|
118
|
-
switch (perm._type) {
|
|
119
|
-
case "hidden":
|
|
120
|
-
readable = false;
|
|
121
|
-
writable = false;
|
|
122
|
-
break;
|
|
123
|
-
case "visibleTo":
|
|
124
|
-
readable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
|
|
125
|
-
break;
|
|
126
|
-
case "writableBy": writable = perm.roles?.some((r) => effectiveRoles.includes(r)) ?? false;
|
|
127
|
-
}
|
|
128
|
-
caps[field] = {
|
|
129
|
-
readable,
|
|
130
|
-
writable
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
return caps;
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Send a controller's `IControllerResponse` through Fastify's reply.
|
|
137
|
-
*
|
|
138
|
-
* The `IControllerResponse<T>` envelope is arc's internal pipeline
|
|
139
|
-
* contract — it's how custom `status`, `meta`, `headers`, and field
|
|
140
|
-
* permissions thread from a handler through interceptors to the wire
|
|
141
|
-
* without each step having to touch `reply` directly. Not a portability
|
|
142
|
-
* abstraction (arc is Fastify-only); a pipeline-internal one.
|
|
143
|
-
*
|
|
144
|
-
* **Auto-envelope of bare returns.** Custom route handlers are
|
|
145
|
-
* expected to return the envelope shape so this function can apply
|
|
146
|
-
* field permissions, attach `fieldCaps`, and flatten paginated lists.
|
|
147
|
-
* A handler that returns raw `T` (array, plain object, primitive) used
|
|
148
|
-
* to silently produce a broken response — `response.data` was
|
|
149
|
-
* `undefined`, the body sent was `undefined`, and any declared
|
|
150
|
-
* `fields:` permission map was bypassed (the leak shape mentora
|
|
151
|
-
* flagged). 2.17 detects bare returns and wraps them into
|
|
152
|
-
* `{ data: response, status: 200 }` before the field-permission
|
|
153
|
-
* pipeline runs, so `fields:` applies to ALL custom routes whether the
|
|
154
|
-
* handler envelopes its return value or not. The envelope shape is
|
|
155
|
-
* still the canonical contract — auto-wrapping is a safety net, not
|
|
156
|
-
* an invitation to drop the envelope.
|
|
157
|
-
*/
|
|
158
|
-
function sendControllerResponse(reply, response, request) {
|
|
159
|
-
if (response === null || typeof response !== "object" || !Object.hasOwn(response, "data")) response = {
|
|
160
|
-
data: response,
|
|
161
|
-
status: 200
|
|
162
|
-
};
|
|
163
|
-
const reqWithExtras = request;
|
|
164
|
-
const fieldMaskConfig = reqWithExtras?.fieldMask;
|
|
165
|
-
const arcMeta = reqWithExtras?.arc;
|
|
166
|
-
const scope = reqWithExtras?.scope ?? PUBLIC_SCOPE;
|
|
167
|
-
const fieldPerms = isElevated(scope) ? void 0 : arcMeta?.fields;
|
|
168
|
-
const effectiveRoles = fieldPerms ? resolveEffectiveRoles(getUserRoles(reqWithExtras?.user), isMember(scope) ? scope.orgRoles : []) : [];
|
|
169
|
-
const fieldCaps = fieldPerms ? computeFieldCapabilities(fieldPerms, effectiveRoles) : void 0;
|
|
170
|
-
const hasFieldRestrictions = !!(fieldMaskConfig || fieldPerms);
|
|
171
|
-
/** Apply both field mask and field-level permissions to a data item */
|
|
172
|
-
const applyPermissions = (data) => {
|
|
173
|
-
let result = fieldMaskConfig ? applyFieldMask(data, fieldMaskConfig) : data;
|
|
174
|
-
if (fieldPerms && result && typeof result === "object") {
|
|
175
|
-
if (Array.isArray(result)) result = result.map((item) => applyFieldReadPermissions(item, fieldPerms, effectiveRoles));
|
|
176
|
-
else result = applyFieldReadPermissions(result, fieldPerms, effectiveRoles);
|
|
177
|
-
}
|
|
178
|
-
return result;
|
|
179
|
-
};
|
|
180
|
-
if (response.headers) for (const [key, value] of Object.entries(response.headers)) reply.header(key, value);
|
|
181
|
-
if (response.data && typeof response.data === "object" && Array.isArray(response.data.data)) {
|
|
182
|
-
const payload = response.data;
|
|
183
|
-
const filteredRows = hasFieldRestrictions ? applyPermissions(payload.data) : payload.data;
|
|
184
|
-
let canonical;
|
|
185
|
-
if (isPaginatedResult(payload)) canonical = toCanonicalList({
|
|
186
|
-
...payload,
|
|
187
|
-
data: filteredRows
|
|
188
|
-
});
|
|
189
|
-
else if (typeof payload.page === "number" && typeof payload.limit === "number" && typeof payload.total === "number") canonical = toCanonicalList({
|
|
190
|
-
method: "offset",
|
|
191
|
-
data: filteredRows,
|
|
192
|
-
page: payload.page,
|
|
193
|
-
limit: payload.limit,
|
|
194
|
-
total: payload.total,
|
|
195
|
-
pages: payload.pages ?? Math.ceil(payload.total / Math.max(1, payload.limit)),
|
|
196
|
-
hasNext: payload.hasNext ?? false,
|
|
197
|
-
hasPrev: payload.hasPrev ?? false
|
|
198
|
-
});
|
|
199
|
-
else canonical = toCanonicalList(filteredRows);
|
|
200
|
-
reply.code(response.status ?? 200).send({
|
|
201
|
-
...canonical,
|
|
202
|
-
...response.meta ?? {},
|
|
203
|
-
...fieldCaps ? { fieldPermissions: fieldCaps } : {}
|
|
204
|
-
});
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const rawData = hasFieldRestrictions ? applyPermissions(response.data) : response.data;
|
|
208
|
-
const filteredData = rawData !== null && typeof rawData === "object" && typeof rawData.toJSON === "function" ? rawData.toJSON() : rawData;
|
|
209
|
-
const status = response.status ?? 200;
|
|
210
|
-
if (filteredData !== null && typeof filteredData === "object" && !Array.isArray(filteredData) && (response.meta || fieldCaps)) {
|
|
211
|
-
reply.code(status).send({
|
|
212
|
-
...filteredData,
|
|
213
|
-
...response.meta ?? {},
|
|
214
|
-
...fieldCaps ? { fieldPermissions: fieldCaps } : {}
|
|
215
|
-
});
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
reply.code(status).send(filteredData);
|
|
219
|
-
}
|
|
220
|
-
/**
|
|
221
|
-
* Bridge an `IController` method (`(ctx: IRequestContext) => Promise<IControllerResponse<T>>`)
|
|
222
|
-
* into a Fastify route handler. Translates between arc's curated request
|
|
223
|
-
* context surface and Fastify's request/reply, and routes the envelope
|
|
224
|
-
* through `sendControllerResponse` so the pipeline applies field
|
|
225
|
-
* permissions, status, and meta uniformly. Internal plumbing, not a
|
|
226
|
-
* portability layer — arc is Fastify-only.
|
|
227
|
-
*
|
|
228
|
-
* @example
|
|
229
|
-
* ```typescript
|
|
230
|
-
* const controller = new BaseController(repository);
|
|
231
|
-
* const listHandler = createFastifyHandler(controller.list.bind(controller));
|
|
232
|
-
* fastify.get('/products', listHandler);
|
|
233
|
-
* ```
|
|
234
|
-
*/
|
|
235
|
-
function createFastifyHandler(controllerMethod) {
|
|
236
|
-
return async (req, reply) => {
|
|
237
|
-
sendControllerResponse(reply, await controllerMethod(createRequestContext(req)), req);
|
|
238
|
-
return reply;
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Create Fastify adapters for all CRUD methods of an IController
|
|
243
|
-
*
|
|
244
|
-
* Returns Fastify-compatible handlers for each CRUD operation
|
|
245
|
-
*
|
|
246
|
-
* @example
|
|
247
|
-
* ```typescript
|
|
248
|
-
* const controller = new BaseController(repository);
|
|
249
|
-
* const handlers = createCrudHandlers(controller);
|
|
250
|
-
*
|
|
251
|
-
* fastify.get('/', handlers.list);
|
|
252
|
-
* fastify.get('/:id', handlers.get);
|
|
253
|
-
* fastify.post('/', handlers.create);
|
|
254
|
-
* fastify.patch('/:id', handlers.update);
|
|
255
|
-
* fastify.delete('/:id', handlers.delete);
|
|
256
|
-
* ```
|
|
257
|
-
*/
|
|
258
|
-
function createCrudHandlers(controller) {
|
|
259
|
-
return {
|
|
260
|
-
list: createFastifyHandler(controller.list.bind(controller)),
|
|
261
|
-
get: createFastifyHandler(controller.get.bind(controller)),
|
|
262
|
-
create: createFastifyHandler(controller.create.bind(controller)),
|
|
263
|
-
update: createFastifyHandler(controller.update.bind(controller)),
|
|
264
|
-
delete: createFastifyHandler(controller.delete.bind(controller))
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
//#endregion
|
|
268
|
-
//#region src/core/middlewares/pipeline.ts
|
|
269
|
-
/**
|
|
270
|
-
* Resolve pipeline steps for a specific operation.
|
|
271
|
-
* Flat-array config applies to every op; map config applies per-op.
|
|
272
|
-
*/
|
|
273
|
-
function resolvePipelineSteps(pipeline, operation) {
|
|
274
|
-
if (!pipeline) return [];
|
|
275
|
-
if (Array.isArray(pipeline)) return pipeline;
|
|
276
|
-
return pipeline[operation] ?? [];
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Wrap a controller method (one that takes `IRequestContext` and returns
|
|
280
|
-
* `IControllerResponse<T>`) with pipeline execution. Used by CRUD ops and
|
|
281
|
-
* string-handler custom routes.
|
|
282
|
-
*/
|
|
283
|
-
function buildPipelineHandler(controllerMethod, steps, operation, resourceName) {
|
|
284
|
-
return async (req, reply) => {
|
|
285
|
-
const pipeCtx = {
|
|
286
|
-
...createRequestContext(req),
|
|
287
|
-
resource: resourceName,
|
|
288
|
-
operation
|
|
289
|
-
};
|
|
290
|
-
sendControllerResponse(reply, await executePipeline(steps, pipeCtx, (ctx) => controllerMethod(ctx), operation), req);
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* Wrap an action handler (one that takes `(id, data, req)` and returns a raw
|
|
295
|
-
* result) with pipeline execution. Returns a function that produces a full
|
|
296
|
-
* `IControllerResponse<unknown>` — the action router feeds this directly into
|
|
297
|
-
* `sendControllerResponse`, so field masking, custom status codes, `meta`,
|
|
298
|
-
* `details`, and structured error codes from pipeline interceptors flow
|
|
299
|
-
* through to the client unchanged.
|
|
300
|
-
*
|
|
301
|
-
* CRUD and actions now share the same parity invariant: a pipeline that
|
|
302
|
-
* returns `{ success: false, status: 422, error, details, meta }` reaches the
|
|
303
|
-
* client with all four fields intact. Previously the action path stringified
|
|
304
|
-
* failures into a generic `Error` and dropped everything except `statusCode`.
|
|
305
|
-
*
|
|
306
|
-
* Handler throws still bubble out — the caller's try/catch handles `onError`
|
|
307
|
-
* shaping and the generic `ACTION_FAILED` fallback.
|
|
308
|
-
*/
|
|
309
|
-
function buildActionPipelineHandler(handler, steps, operation, resourceName) {
|
|
310
|
-
if (steps.length === 0) return async (id, data, req) => ({
|
|
311
|
-
status: 200,
|
|
312
|
-
data: await handler(id, data, req)
|
|
313
|
-
});
|
|
314
|
-
return async (id, data, req) => {
|
|
315
|
-
const pipeCtx = {
|
|
316
|
-
...createRequestContext(req),
|
|
317
|
-
resource: resourceName,
|
|
318
|
-
operation
|
|
319
|
-
};
|
|
320
|
-
return executePipeline(steps, pipeCtx, async (_ctx) => ({
|
|
321
|
-
status: 200,
|
|
322
|
-
data: await handler(id, data, req)
|
|
323
|
-
}), operation);
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
//#endregion
|
|
327
|
-
//#region src/core/actionPermissions.ts
|
|
328
|
-
/**
|
|
329
|
-
* Return the effective `PermissionCheck` for a single action, or `undefined`
|
|
330
|
-
* when the resource declares no gate at any level.
|
|
331
|
-
*
|
|
332
|
-
* Callers decide what "no gate" means:
|
|
333
|
-
* - HTTP: boot-time throw in `normalizeActionsToRouterConfig`.
|
|
334
|
-
* - MCP: tool-generation throw in `resourceToTools` (mirrors HTTP — the
|
|
335
|
-
* two surfaces fail closed identically so MCP can't expose an
|
|
336
|
-
* unauthenticated mutating tool when the HTTP plugin lifecycle hasn't
|
|
337
|
-
* run).
|
|
338
|
-
* - OpenAPI: docs advertise the endpoint as unauthenticated.
|
|
339
|
-
*/
|
|
340
|
-
function resolveActionPermission(input) {
|
|
341
|
-
const { action, resourcePermissions, resourceActionPermissions, globalAuth } = input;
|
|
342
|
-
const explicit = typeof action !== "function" && action.permissions ? action.permissions : void 0;
|
|
343
|
-
if (explicit) return explicit;
|
|
344
|
-
if (resourceActionPermissions) return resourceActionPermissions;
|
|
345
|
-
if (globalAuth) return globalAuth;
|
|
346
|
-
const updateFallback = resourcePermissions?.update;
|
|
347
|
-
if (updateFallback) return updateFallback;
|
|
348
|
-
}
|
|
349
|
-
//#endregion
|
|
350
|
-
export { createCrudHandlers as a, getControllerContext as c, resolvePipelineSteps as i, getControllerScope as l, buildActionPipelineHandler as n, createFastifyHandler as o, buildPipelineHandler as r, createRequestContext as s, resolveActionPermission as t, sendControllerResponse as u };
|