@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.
- package/README.md +88 -5
- package/dist/{BaseController-DAGGc5Xn.mjs → BaseController-Vu2yc56T.mjs} +188 -102
- package/dist/EventTransport-CqZ8FyM_.d.mts +293 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/audit/index.d.mts +100 -11
- package/dist/audit/index.mjs +71 -18
- package/dist/auth/index.d.mts +16 -8
- package/dist/auth/index.mjs +13 -6
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-BuUcUEJq.mjs → betterAuthOpenApi--rdY15Ld.mjs} +1 -1
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/index.mjs +2 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -5
- package/dist/{core-F0QoWBt2.mjs → core-DNncu0xF.mjs} +1 -1
- package/dist/{createActionRouter-BORM8f17.mjs → createActionRouter-DH1YFL9m.mjs} +3 -3
- package/dist/{createApp-B1EY8zxa.mjs → createApp-CBJUJKGP.mjs} +13 -12
- package/dist/{defineResource-tcgySDo1.mjs → defineResource-C__jkwvs.mjs} +22 -57
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/dynamic/index.d.mts +1 -1
- package/dist/dynamic/index.mjs +3 -3
- package/dist/{elevation-DtFxrG0s.mjs → elevation-DxQ6ACbt.mjs} +21 -7
- package/dist/{errorHandler-f869_8PQ.mjs → errorHandler-CZDW4EXS.mjs} +59 -7
- package/dist/{errorHandler-Bah5JhBd.d.mts → errorHandler-DixGcttC.d.mts} +37 -2
- package/dist/{eventPlugin-D9DKB2zM.d.mts → eventPlugin-BxvaCIZF.d.mts} +14 -2
- package/dist/{eventPlugin-CDjVTM82.mjs → eventPlugin-Dl7MoVWH.mjs} +83 -5
- package/dist/events/index.d.mts +147 -36
- package/dist/events/index.mjs +338 -101
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-DpZQa_Q3.d.mts → fields-BC7zcmI9.d.mts} +15 -3
- package/dist/{fields-ipsbIRPK.mjs → fields-CU6FlaDV.mjs} +18 -5
- package/dist/{filesUpload-C7r7HIeA.mjs → filesUpload-q8oHt--L.mjs} +65 -7
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +29 -5
- package/dist/idempotency/index.mjs +111 -2
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-BLXBmWud.d.mts → index-C-xjcA6F.d.mts} +1 -1
- package/dist/{index-DtDzOBn8.d.mts → index-Cibkchnx.d.mts} +3 -134
- package/dist/{index-C1meYuDn.d.mts → index-CtGKT0lf.d.mts} +1 -1
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +9 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +26 -8
- package/dist/integrations/mcp/index.mjs +96 -17
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/webhooks.d.mts +5 -0
- package/dist/integrations/webhooks.mjs +6 -0
- package/dist/{interface-CMRutPfe.d.mts → interface-YrWsmKqE.d.mts} +287 -179
- package/dist/{openapi-CbKUJY_m.mjs → openapi-CXuTG1M9.mjs} +2 -2
- package/dist/org/index.d.mts +1 -1
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-CH4cNwJi.mjs → permissions-oNZawnkR.mjs} +1 -1
- package/dist/plugins/index.d.mts +7 -7
- package/dist/plugins/index.mjs +11 -11
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/policies/index.d.mts +25 -32
- package/dist/presets/filesUpload.d.mts +26 -4
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +3 -2
- package/dist/presets/index.mjs +4 -3
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +1 -1
- package/dist/presets/search.d.mts +91 -0
- package/dist/presets/search.mjs +150 -0
- package/dist/{presets-C2xgzW6x.mjs → presets-hM4WhNWY.mjs} +1 -1
- package/dist/{queryCachePlugin-BJJGBTlu.d.mts → queryCachePlugin-CnTZZTC5.d.mts} +1 -1
- package/dist/{queryCachePlugin-BH-fidlv.mjs → queryCachePlugin-DbUVroUG.mjs} +2 -2
- package/dist/{redis-BM00zaPB.d.mts → redis-MXLp1oOf.d.mts} +1 -1
- package/dist/{redis-stream-CrsfUmPt.d.mts → redis-stream-Bz-4q96t.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-8s-EsCCe.mjs → resourceToTools-C3cWymnW.mjs} +64 -47
- package/dist/rpc/index.d.mts +1 -1
- package/dist/rpc/index.mjs +1 -1
- package/dist/{schemaConverter-Y7nCYaLJ.mjs → schemaConverter-BxFDdtXu.mjs} +1 -1
- package/dist/scope/index.mjs +1 -1
- package/dist/{sse-Ad7ypl9e.mjs → sse-CJpt7LGI.mjs} +1 -1
- package/dist/store-helpers-DFiZl5TL.mjs +57 -0
- package/dist/testing/index.d.mts +5 -14
- package/dist/testing/index.mjs +21 -75
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +2 -2
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BsbNMEDR.d.mts → types-CoSzA-s-.d.mts} +1 -1
- package/dist/{types-Ch9pTQbf.d.mts → types-CunEX4UX.d.mts} +10 -8
- package/dist/utils/index.d.mts +4 -4
- package/dist/utils/index.mjs +6 -6
- package/dist/{utils-yYT3HDXt.mjs → utils-B7FuRr9w.mjs} +1 -1
- package/package.json +8 -11
- package/skills/arc/SKILL.md +92 -14
- package/skills/arc/references/auth.md +94 -0
- package/skills/arc/references/events.md +200 -12
- package/skills/arc/references/mcp.md +4 -17
- package/skills/arc/references/multi-tenancy.md +43 -0
- package/skills/arc/references/production.md +34 -19
- package/dist/EventTransport-BXja8NOc.d.mts +0 -135
- package/dist/audit/mongodb.d.mts +0 -2
- package/dist/audit/mongodb.mjs +0 -2
- package/dist/idempotency/mongodb.d.mts +0 -2
- package/dist/idempotency/mongodb.mjs +0 -123
- package/dist/mongodb-BsP-WbhN.d.mts +0 -127
- package/dist/mongodb-CTcp0hQZ.d.mts +0 -80
- package/dist/mongodb-Utc5k_-0.mjs +0 -90
- /package/dist/{HookSystem-HprTmvVY.mjs → HookSystem-BjFu7zf1.mjs} +0 -0
- /package/dist/{ResourceRegistry-C6uXlWe3.mjs → ResourceRegistry-Dq3_zBQP.mjs} +0 -0
- /package/dist/{applyPermissionResult-D6GPMsvh.mjs → applyPermissionResult-bqGpo9ML.mjs} +0 -0
- /package/dist/{caching-IMuYVjTL.mjs → caching-CjybdRwx.mjs} +0 -0
- /package/dist/{circuitBreaker-dTtG-UyS.d.mts → circuitBreaker-CvXkjfrW.d.mts} +0 -0
- /package/dist/{circuitBreaker-cmi5XDv5.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
- /package/dist/{errors-Ck2h67pm.d.mts → errors-BI8kEKsO.d.mts} +0 -0
- /package/dist/{errors-BF2bIOIS.mjs → errors-CqWnSqM-.mjs} +0 -0
- /package/dist/{externalPaths-BnkYrNzp.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
- /package/dist/{interface-DfLGcus7.d.mts → interface-B-pe8fhj.d.mts} +0 -0
- /package/dist/{interface-4y979v99.d.mts → interface-DplgQO2e.d.mts} +0 -0
- /package/dist/{loadResources-PWd0OCpV.mjs → loadResources-Bksk8ydA.mjs} +0 -0
- /package/dist/{logger-D1YrIImS.mjs → logger-CDjpjySd.mjs} +0 -0
- /package/dist/{memory-Cp7_cAko.mjs → memory-BFAYkf8H.mjs} +0 -0
- /package/dist/{metrics-B-PU4-Yu.mjs → metrics-TuOmguhi.mjs} +0 -0
- /package/dist/{queryParser-CgCtsjti.mjs → queryParser-Cs-6SHQK.mjs} +0 -0
- /package/dist/{registry-BiTKT1Dg.mjs → registry-B0Wl7uVV.mjs} +0 -0
- /package/dist/{replyHelpers-CxkYGT81.mjs → replyHelpers-BLojtuvR.mjs} +0 -0
- /package/dist/{requestContext-DYvHl113.mjs → requestContext-DYtmNpm5.mjs} +0 -0
- /package/dist/{sessionManager-DDCmiNIo.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
- /package/dist/{storage-Dfzt4VTl.d.mts → storage-BwGQXUpd.d.mts} +0 -0
- /package/dist/{tracing-DdN2-wHJ.d.mts → tracing-xqXzWeaf.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-Cj5Rgvlg.mjs} +0 -0
- /package/dist/{versioning-CDugduqI.mjs → versioning-Cm8qoFDg.mjs} +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Database-agnostic resource framework for Fastify. Define resources, get CRUD routes, permissions, presets, caching, events, OpenAPI, and MCP tools — without boilerplate.
|
|
4
4
|
|
|
5
|
-
**v2.
|
|
5
|
+
**v2.9** | Fastify 5+ | Node.js 22+ | ESM only
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -65,8 +65,13 @@ const app = await createApp({
|
|
|
65
65
|
Clean DX without growing exclude lists:
|
|
66
66
|
|
|
67
67
|
```typescript
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
import { Repository } from '@classytic/mongokit';
|
|
69
|
+
|
|
70
|
+
// app.ts — pass any RepositoryLike (mongokit / prismakit / custom)
|
|
71
|
+
await fastify.register(auditPlugin, {
|
|
72
|
+
autoAudit: { perResource: true },
|
|
73
|
+
repository: new Repository(AuditModel), // or omit for in-memory dev
|
|
74
|
+
});
|
|
70
75
|
|
|
71
76
|
// order.resource.ts — opt in
|
|
72
77
|
defineResource({ name: 'order', audit: true });
|
|
@@ -99,8 +104,8 @@ const productResource = defineResource({
|
|
|
99
104
|
delete: roles('admin'),
|
|
100
105
|
},
|
|
101
106
|
cache: { staleTime: 30, gcTime: 300, tags: ['catalog'] }, // QueryCache (opt-in)
|
|
102
|
-
|
|
103
|
-
{ method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic()
|
|
107
|
+
routes: [
|
|
108
|
+
{ method: 'GET', path: '/featured', handler: 'getFeatured', permissions: allowPublic() },
|
|
104
109
|
],
|
|
105
110
|
});
|
|
106
111
|
|
|
@@ -383,6 +388,29 @@ await app.events.subscribe('order.*', async (event) => { ... });
|
|
|
383
388
|
|
|
384
389
|
CRUD events (`product.created`, `product.updated`, `product.deleted`) emit automatically.
|
|
385
390
|
|
|
391
|
+
### Causation Chains & DLQ (v2.9)
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
import { createEvent, createChildEvent, type DeadLetteredEvent } from '@classytic/arc/events';
|
|
395
|
+
|
|
396
|
+
const placed = createEvent('order.placed', { orderId: 'o1' }, {
|
|
397
|
+
correlationId: req.id, userId: user.id,
|
|
398
|
+
});
|
|
399
|
+
await app.events.publish(placed.type, placed.payload, placed.meta);
|
|
400
|
+
|
|
401
|
+
// Downstream handler emits a child — correlation inherited, causation linked:
|
|
402
|
+
const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
|
|
403
|
+
// reserved.meta.correlationId === placed.meta.correlationId (stays stable across chain)
|
|
404
|
+
// reserved.meta.causationId === placed.meta.id (direct parent)
|
|
405
|
+
|
|
406
|
+
// Transports with native DLQ (Kafka, SQS) implement optional deadLetter():
|
|
407
|
+
class KafkaTransport implements EventTransport {
|
|
408
|
+
async deadLetter(dlq: DeadLetteredEvent) { /* route to .DLQ topic */ }
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`EventMeta` also accepts `schemaVersion` (evolve event payloads) and `partitionKey` (ordered delivery hint for Kafka/Kinesis).
|
|
413
|
+
|
|
386
414
|
### defineEvent — Typed Events with Schema Validation
|
|
387
415
|
|
|
388
416
|
Declare events with schemas for runtime validation and introspection:
|
|
@@ -722,6 +750,61 @@ Arc sets `"sideEffects": false` in [package.json](package.json), so modern bundl
|
|
|
722
750
|
| `@classytic/arc/docs` | OpenAPI generation |
|
|
723
751
|
| `@classytic/arc/cli` | CLI commands (programmatic) |
|
|
724
752
|
|
|
753
|
+
## v2.9.1 Highlights
|
|
754
|
+
|
|
755
|
+
`auditPlugin`, `idempotencyPlugin`, and `EventOutbox` take a
|
|
756
|
+
`repository: RepositoryLike` option — pass any `Repository` (mongokit /
|
|
757
|
+
prismakit / your own kit). Arc calls `create` / `getOne` / `findAll` /
|
|
758
|
+
`deleteMany` / `findOneAndUpdate` on it directly:
|
|
759
|
+
|
|
760
|
+
```typescript
|
|
761
|
+
import { Repository } from '@classytic/mongokit';
|
|
762
|
+
import { auditPlugin } from '@classytic/arc/audit';
|
|
763
|
+
import { idempotencyPlugin } from '@classytic/arc/idempotency';
|
|
764
|
+
import { EventOutbox } from '@classytic/arc/events';
|
|
765
|
+
|
|
766
|
+
await fastify.register(auditPlugin, { repository: new Repository(AuditModel) });
|
|
767
|
+
await fastify.register(idempotencyPlugin, {
|
|
768
|
+
repository: new Repository(IdempotencyModel, [
|
|
769
|
+
methodRegistryPlugin(), batchOperationsPlugin(), mongoOperationsPlugin(),
|
|
770
|
+
]),
|
|
771
|
+
});
|
|
772
|
+
new EventOutbox({ repository: new Repository(OutboxModel), transport });
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
Memory + Redis stores unchanged via `store` / `customStores`. Requires
|
|
776
|
+
`@classytic/mongokit ≥3.8.0`.
|
|
777
|
+
|
|
778
|
+
## v2.9 Highlights
|
|
779
|
+
|
|
780
|
+
**Security defaults (breaking):**
|
|
781
|
+
- **Field-write perms reject by default** — requests with non-writable fields return 403 with denied-field list. Opt into legacy silent-strip via `defineResource({ onFieldWriteDenied: 'strip' })`.
|
|
782
|
+
- **multiTenant preset injects org on UPDATE** — body-supplied `organizationId` is overwritten with caller's scope (prior: CREATE only; a member could hop their own doc to another tenant).
|
|
783
|
+
- **Elevation always emits `arc.scope.elevated`** — privilege elevation can no longer be silently un-audited. Subscribe via `fastify.events.subscribe('arc.scope.elevated', …)`. `onElevation` callback still supported.
|
|
784
|
+
- **`verifySignature` throws on parsed body** — prevents the common `req.body` vs `req.rawBody` footgun that looks like a wrong secret.
|
|
785
|
+
- **Upload `sanitizeFilename` policy** — rejects path separators, NUL, `.`/`..`, >255 chars by default. Flexible: pass `false` / `'*'` / custom function to override.
|
|
786
|
+
- **Idempotency `namespace`** — optional key folded into fingerprint for shared-store deployments (prod + canary on one Redis).
|
|
787
|
+
|
|
788
|
+
**Event contract v2 (additive):**
|
|
789
|
+
- `EventMeta` gets `schemaVersion`, `causationId`, `partitionKey`, `source`, `idempotencyKey`, `aggregate: { type, id }`
|
|
790
|
+
- `createChildEvent(parent, type, payload)` — auto-chains causation + inherits correlation / userId / organizationId / source / idempotencyKey (aggregate stays explicit)
|
|
791
|
+
- `DeadLetteredEvent<T>` type + optional `transport.deadLetter()` for first-class DLQ
|
|
792
|
+
- `withRetry({ transport })` auto-routes exhausted events to `transport.deadLetter()` — no custom `$deadLetter` plumbing
|
|
793
|
+
- Downstream packages narrow `aggregate.type` to a closed DDD union via interface extension
|
|
794
|
+
|
|
795
|
+
**Outbox v2 (additive):**
|
|
796
|
+
- `EventOutbox.store()` auto-maps `event.meta.idempotencyKey` → `OutboxWriteOptions.dedupeKey` (caller's explicit `dedupeKey` still wins)
|
|
797
|
+
- `new EventOutbox({ failurePolicy: ({ attempts, error }) => ({ retryAt?, deadLetter? }) })` — centralises retry + DLQ escalation, no more hand-rolled `exponentialBackoff` at every failure site
|
|
798
|
+
- `outbox.getDeadLettered(limit)` returns typed `DeadLetteredEvent[]` — same shape `withRetry` produces, closes the loop between retry DLQ and outbox DLQ state
|
|
799
|
+
- `RelayResult.deadLettered` — per-batch DLQ transition count for dashboards
|
|
800
|
+
- Durable outbox via any `RepositoryLike` — `new EventOutbox({ repository, transport })` (2.9.1); multi-worker claim, TTL purge, dedupe, and session-threaded writes come from the repository's backing kit
|
|
801
|
+
|
|
802
|
+
**Removed (no replacement kept):**
|
|
803
|
+
- `createActionRouter`, `buildActionBodySchema` — use `defineResource({ actions })`
|
|
804
|
+
- `ResourceConfig.onRegister` — use `actions` or resource `hooks`
|
|
805
|
+
- `PluginResourceResult.additionalRoutes` → `routes: RouteDefinition[]`
|
|
806
|
+
- `PolicyContext` / `PolicyResult` `any` fields → `unknown` (tighten downstream narrowing)
|
|
807
|
+
|
|
725
808
|
## v2.8.4 Highlights
|
|
726
809
|
|
|
727
810
|
- **MCP ↔ AI SDK bridge** — expose AI SDK `tool()` definitions over MCP without duplicating code. `bridgeToMcp(bridge)` adapts any AI SDK tool into an MCP tool with automatic auth, guard delegation, and `{ error } → isError` envelope translation. `buildMcpToolsFromBridges(bridges, { include, exclude })` registers a whole catalog at once with per-environment filtering.
|
|
@@ -2,9 +2,10 @@ import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4
|
|
|
2
2
|
import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
|
|
3
3
|
import { t as buildQueryKey } from "./keys-qcD-TVJl.mjs";
|
|
4
4
|
import { getUserId } from "./types/index.mjs";
|
|
5
|
-
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-
|
|
5
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CU6FlaDV.mjs";
|
|
6
6
|
import { t as getUserRoles } from "./types-ZUu_h0jp.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { r as ForbiddenError } from "./errors-CqWnSqM-.mjs";
|
|
8
|
+
import { t as ArcQueryParser } from "./queryParser-Cs-6SHQK.mjs";
|
|
8
9
|
//#region src/core/AccessControl.ts
|
|
9
10
|
/**
|
|
10
11
|
* AccessControl - Composable access control logic extracted from BaseController.
|
|
@@ -95,20 +96,82 @@ var AccessControl = class AccessControl {
|
|
|
95
96
|
* buildIdFilter -> getOne (or getById + checkOrgScope + checkPolicyFilters)
|
|
96
97
|
*/
|
|
97
98
|
async fetchWithAccessControl(id, req, repository, queryOptions) {
|
|
99
|
+
return (await this.fetchDetailed(id, req, repository, queryOptions)).doc;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Same as `fetchWithAccessControl` but returns a structured result with
|
|
103
|
+
* a denial reason so callers can distinguish "doc doesn't exist" from
|
|
104
|
+
* "doc exists but was filtered by policy/org scope" from "repo threw".
|
|
105
|
+
*
|
|
106
|
+
* Codes:
|
|
107
|
+
* - `null` — doc was found, no denial
|
|
108
|
+
* - `'NOT_FOUND'` — doc genuinely doesn't exist in the DB
|
|
109
|
+
* - `'POLICY_FILTERED'` — doc exists but the request's policy filters exclude it
|
|
110
|
+
* - `'ORG_SCOPE_DENIED'` — doc exists but the caller's org context doesn't match
|
|
111
|
+
* - `'REPO_ERROR'` — the repository threw a "not found" error (mongokit style)
|
|
112
|
+
*/
|
|
113
|
+
async fetchDetailed(id, req, repository, queryOptions) {
|
|
98
114
|
const compoundFilter = this.buildIdFilter(id, req);
|
|
99
|
-
const
|
|
115
|
+
const hasCompoundFilters = Object.keys(compoundFilter).length > 1;
|
|
116
|
+
const needsCompoundLookup = hasCompoundFilters || this.idField !== "_id";
|
|
117
|
+
const translateStatus404 = (error) => {
|
|
118
|
+
if (error && typeof error === "object" && error.status === 404) return {
|
|
119
|
+
doc: null,
|
|
120
|
+
reason: "NOT_FOUND"
|
|
121
|
+
};
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
100
124
|
try {
|
|
101
|
-
if (needsCompoundLookup && typeof repository.getOne === "function")
|
|
125
|
+
if (needsCompoundLookup && typeof repository.getOne === "function") {
|
|
126
|
+
const doc = await repository.getOne(compoundFilter, queryOptions);
|
|
127
|
+
if (doc) return {
|
|
128
|
+
doc,
|
|
129
|
+
reason: null
|
|
130
|
+
};
|
|
131
|
+
if (hasCompoundFilters) {
|
|
132
|
+
const idOnly = { [this.idField]: id };
|
|
133
|
+
const rawDoc = await repository.getOne(idOnly);
|
|
134
|
+
if (rawDoc) {
|
|
135
|
+
const arcContext = this._meta(req);
|
|
136
|
+
if (!this.checkOrgScope(rawDoc, arcContext)) return {
|
|
137
|
+
doc: null,
|
|
138
|
+
reason: "ORG_SCOPE_DENIED"
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
doc: null,
|
|
142
|
+
reason: "POLICY_FILTERED"
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
doc: null,
|
|
148
|
+
reason: "NOT_FOUND"
|
|
149
|
+
};
|
|
150
|
+
}
|
|
102
151
|
if (this.idField !== "_id") {
|
|
103
152
|
if (typeof repository.getOne !== "function") throw new Error(`Resource with idField="${this.idField}" requires repository.getOne() to look up by custom field. Arc's BaseController cannot fall back to getById() because it would query by _id.`);
|
|
104
153
|
}
|
|
105
154
|
const item = await repository.getById(id, queryOptions);
|
|
106
|
-
if (!item) return
|
|
155
|
+
if (!item) return {
|
|
156
|
+
doc: null,
|
|
157
|
+
reason: "NOT_FOUND"
|
|
158
|
+
};
|
|
107
159
|
const arcContext = this._meta(req);
|
|
108
|
-
if (!this.checkOrgScope(item, arcContext)
|
|
109
|
-
|
|
160
|
+
if (!this.checkOrgScope(item, arcContext)) return {
|
|
161
|
+
doc: null,
|
|
162
|
+
reason: "ORG_SCOPE_DENIED"
|
|
163
|
+
};
|
|
164
|
+
if (!this.checkPolicyFilters(item, req)) return {
|
|
165
|
+
doc: null,
|
|
166
|
+
reason: "POLICY_FILTERED"
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
doc: item,
|
|
170
|
+
reason: null
|
|
171
|
+
};
|
|
110
172
|
} catch (error) {
|
|
111
|
-
|
|
173
|
+
const translated = translateStatus404(error);
|
|
174
|
+
if (translated) return translated;
|
|
112
175
|
throw error;
|
|
113
176
|
}
|
|
114
177
|
}
|
|
@@ -224,20 +287,12 @@ var AccessControl = class AccessControl {
|
|
|
224
287
|
}
|
|
225
288
|
}
|
|
226
289
|
};
|
|
227
|
-
//#endregion
|
|
228
|
-
//#region src/core/BodySanitizer.ts
|
|
229
|
-
/**
|
|
230
|
-
* BodySanitizer - Composable body sanitization logic extracted from BaseController.
|
|
231
|
-
*
|
|
232
|
-
* Strips readonly fields, system-managed fields, and applies field-level
|
|
233
|
-
* write permissions from request bodies before create/update operations.
|
|
234
|
-
*
|
|
235
|
-
* Designed to be used standalone or composed into controllers.
|
|
236
|
-
*/
|
|
237
290
|
var BodySanitizer = class {
|
|
238
291
|
schemaOptions;
|
|
292
|
+
onFieldWriteDenied;
|
|
239
293
|
constructor(config) {
|
|
240
294
|
this.schemaOptions = config.schemaOptions;
|
|
295
|
+
this.onFieldWriteDenied = config.onFieldWriteDenied ?? "reject";
|
|
241
296
|
}
|
|
242
297
|
/**
|
|
243
298
|
* Strip readonly and system-managed fields from request body.
|
|
@@ -261,7 +316,9 @@ var BodySanitizer = class {
|
|
|
261
316
|
const fieldPerms = arcContext?.arc?.fields;
|
|
262
317
|
if (fieldPerms) {
|
|
263
318
|
const effectiveRoles = resolveEffectiveRoles(getUserRoles(req.user), isMember(scope) ? scope.orgRoles : []);
|
|
264
|
-
|
|
319
|
+
const { body: filtered, deniedFields } = applyFieldWritePermissions(sanitized, fieldPerms, effectiveRoles);
|
|
320
|
+
if (deniedFields.length > 0 && this.onFieldWriteDenied === "reject") throw new ForbiddenError(`Not permitted to write field${deniedFields.length === 1 ? "" : "s"}: ${deniedFields.join(", ")}`);
|
|
321
|
+
sanitized = filtered;
|
|
265
322
|
}
|
|
266
323
|
}
|
|
267
324
|
}
|
|
@@ -404,6 +461,12 @@ var QueryResolver = class {
|
|
|
404
461
|
//#endregion
|
|
405
462
|
//#region src/core/BaseController.ts
|
|
406
463
|
/**
|
|
464
|
+
* Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
|
|
465
|
+
* available in Bun workers, Deno, Cloudflare Workers, or edge runtimes. Fall
|
|
466
|
+
* back to queueMicrotask (universal) when setImmediate is absent.
|
|
467
|
+
*/
|
|
468
|
+
const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
|
|
469
|
+
/**
|
|
407
470
|
* Framework-agnostic base controller implementing IController.
|
|
408
471
|
*
|
|
409
472
|
* Composes AccessControl, BodySanitizer, and QueryResolver for clean
|
|
@@ -450,7 +513,10 @@ var BaseController = class {
|
|
|
450
513
|
idField: this.idField,
|
|
451
514
|
matchesFilter: this._matchesFilter
|
|
452
515
|
});
|
|
453
|
-
this.bodySanitizer = new BodySanitizer({
|
|
516
|
+
this.bodySanitizer = new BodySanitizer({
|
|
517
|
+
schemaOptions: this.schemaOptions,
|
|
518
|
+
onFieldWriteDenied: options.onFieldWriteDenied
|
|
519
|
+
});
|
|
454
520
|
this.queryResolver = new QueryResolver({
|
|
455
521
|
queryParser: this.queryParser,
|
|
456
522
|
maxLimit: this.maxLimit,
|
|
@@ -506,6 +572,28 @@ var BaseController = class {
|
|
|
506
572
|
if (repoIdField && repoIdField === this.idField) return id;
|
|
507
573
|
return String(existing["_id"] ?? id);
|
|
508
574
|
}
|
|
575
|
+
/**
|
|
576
|
+
* Centralized 404 response builder. Maps the denial reason from
|
|
577
|
+
* `fetchDetailed()` into a structured `details.code` so consumers can
|
|
578
|
+
* programmatically distinguish "doc doesn't exist" from "doc filtered
|
|
579
|
+
* by policy/org scope" without parsing error strings.
|
|
580
|
+
*
|
|
581
|
+
* Error messages are intentionally vague in the `error` field (don't
|
|
582
|
+
* leak whether the doc exists) — the detail is in `details.code` only.
|
|
583
|
+
*/
|
|
584
|
+
notFoundResponse(reason = "NOT_FOUND") {
|
|
585
|
+
const code = reason ?? "NOT_FOUND";
|
|
586
|
+
return {
|
|
587
|
+
success: false,
|
|
588
|
+
error: {
|
|
589
|
+
NOT_FOUND: "Resource not found",
|
|
590
|
+
POLICY_FILTERED: "Resource not found",
|
|
591
|
+
ORG_SCOPE_DENIED: "Resource not found"
|
|
592
|
+
}[code] ?? "Resource not found",
|
|
593
|
+
status: 404,
|
|
594
|
+
details: { code }
|
|
595
|
+
};
|
|
596
|
+
}
|
|
509
597
|
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
510
598
|
resolveCacheConfig(operation) {
|
|
511
599
|
const cfg = this._cacheConfig;
|
|
@@ -547,7 +635,7 @@ var BaseController = class {
|
|
|
547
635
|
headers: { "x-cache": "HIT" }
|
|
548
636
|
};
|
|
549
637
|
if (status === "stale") {
|
|
550
|
-
|
|
638
|
+
scheduleBackground(() => {
|
|
551
639
|
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
552
640
|
});
|
|
553
641
|
return {
|
|
@@ -616,8 +704,8 @@ var BaseController = class {
|
|
|
616
704
|
headers: { "x-cache": "HIT" }
|
|
617
705
|
};
|
|
618
706
|
if (status === "stale") {
|
|
619
|
-
|
|
620
|
-
this.executeGetQuery(id, options, req).then((fresh) => {
|
|
707
|
+
scheduleBackground(() => {
|
|
708
|
+
this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
|
|
621
709
|
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
622
710
|
}).catch(() => {});
|
|
623
711
|
});
|
|
@@ -628,49 +716,42 @@ var BaseController = class {
|
|
|
628
716
|
headers: { "x-cache": "STALE" }
|
|
629
717
|
};
|
|
630
718
|
}
|
|
631
|
-
const
|
|
632
|
-
if (!
|
|
633
|
-
|
|
634
|
-
error: "Resource not found",
|
|
635
|
-
status: 404
|
|
636
|
-
};
|
|
637
|
-
await qc.set(key, item, cacheConfig);
|
|
719
|
+
const { doc: cached, reason: cacheReason } = await this.executeGetQuery(id, options, req);
|
|
720
|
+
if (!cached) return this.notFoundResponse(cacheReason);
|
|
721
|
+
await qc.set(key, cached, cacheConfig);
|
|
638
722
|
return {
|
|
639
723
|
success: true,
|
|
640
|
-
data:
|
|
724
|
+
data: cached,
|
|
641
725
|
status: 200,
|
|
642
726
|
headers: { "x-cache": "MISS" }
|
|
643
727
|
};
|
|
644
728
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return {
|
|
653
|
-
success: true,
|
|
654
|
-
data: item,
|
|
655
|
-
status: 200
|
|
656
|
-
};
|
|
657
|
-
} catch (error) {
|
|
658
|
-
if (error instanceof Error && error.message?.includes("not found")) return {
|
|
659
|
-
success: false,
|
|
660
|
-
error: "Resource not found",
|
|
661
|
-
status: 404
|
|
662
|
-
};
|
|
663
|
-
throw error;
|
|
664
|
-
}
|
|
729
|
+
const { doc, reason } = await this.executeGetQuery(id, options, req);
|
|
730
|
+
if (!doc) return this.notFoundResponse(reason);
|
|
731
|
+
return {
|
|
732
|
+
success: true,
|
|
733
|
+
data: doc,
|
|
734
|
+
status: 200
|
|
735
|
+
};
|
|
665
736
|
}
|
|
666
737
|
/** Execute get query through hooks (extracted for cache revalidation) */
|
|
667
738
|
async executeGetQuery(id, options, req) {
|
|
668
739
|
const hooks = this.getHooks(req);
|
|
669
|
-
const fetchItem = async () =>
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
740
|
+
const fetchItem = async () => {
|
|
741
|
+
return await this.accessControl.fetchDetailed(id, req, this.repository, options);
|
|
742
|
+
};
|
|
743
|
+
if (hooks && this.resourceName) {
|
|
744
|
+
const result = await fetchItem();
|
|
745
|
+
if (!result.doc) return result;
|
|
746
|
+
return {
|
|
747
|
+
doc: await hooks.executeAround(this.resourceName, "read", null, async () => result.doc, {
|
|
748
|
+
user: req.user,
|
|
749
|
+
context: this.meta(req)
|
|
750
|
+
}) ?? null,
|
|
751
|
+
reason: null
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
return fetchItem();
|
|
674
755
|
}
|
|
675
756
|
async create(req) {
|
|
676
757
|
const arcContext = this.meta(req);
|
|
@@ -733,12 +814,8 @@ var BaseController = class {
|
|
|
733
814
|
const user = req.user;
|
|
734
815
|
const userId = getUserId(user);
|
|
735
816
|
if (userId) data.updatedBy = userId;
|
|
736
|
-
const existing = await this.accessControl.
|
|
737
|
-
if (!existing) return
|
|
738
|
-
success: false,
|
|
739
|
-
error: "Resource not found",
|
|
740
|
-
status: 404
|
|
741
|
-
};
|
|
817
|
+
const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
818
|
+
if (!existing) return this.notFoundResponse(updateReason);
|
|
742
819
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
743
820
|
success: false,
|
|
744
821
|
error: "You do not have permission to modify this resource",
|
|
@@ -791,11 +868,7 @@ var BaseController = class {
|
|
|
791
868
|
}
|
|
792
869
|
});
|
|
793
870
|
} else item = await repoUpdate();
|
|
794
|
-
if (!item) return
|
|
795
|
-
success: false,
|
|
796
|
-
error: "Resource not found",
|
|
797
|
-
status: 404
|
|
798
|
-
};
|
|
871
|
+
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
799
872
|
return {
|
|
800
873
|
success: true,
|
|
801
874
|
data: item,
|
|
@@ -812,12 +885,8 @@ var BaseController = class {
|
|
|
812
885
|
};
|
|
813
886
|
const arcContext = this.meta(req);
|
|
814
887
|
const user = req.user;
|
|
815
|
-
const existing = await this.accessControl.
|
|
816
|
-
if (!existing) return
|
|
817
|
-
success: false,
|
|
818
|
-
error: "Resource not found",
|
|
819
|
-
status: 404
|
|
820
|
-
};
|
|
888
|
+
const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
889
|
+
if (!existing) return this.notFoundResponse(deleteReason);
|
|
821
890
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
822
891
|
success: false,
|
|
823
892
|
error: "You do not have permission to delete this resource",
|
|
@@ -862,11 +931,7 @@ var BaseController = class {
|
|
|
862
931
|
if (typeof r.success === "boolean") return r.success;
|
|
863
932
|
if (typeof r.deletedCount === "number") return r.deletedCount > 0;
|
|
864
933
|
return true;
|
|
865
|
-
})()) return
|
|
866
|
-
success: false,
|
|
867
|
-
error: "Resource not found",
|
|
868
|
-
status: 404
|
|
869
|
-
};
|
|
934
|
+
})()) return this.notFoundResponse("NOT_FOUND");
|
|
870
935
|
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
|
|
871
936
|
user,
|
|
872
937
|
context: arcContext,
|
|
@@ -901,11 +966,7 @@ var BaseController = class {
|
|
|
901
966
|
error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
|
|
902
967
|
status: 501
|
|
903
968
|
};
|
|
904
|
-
if (!this.accessControl.validateItemAccess(item, req)) return
|
|
905
|
-
success: false,
|
|
906
|
-
error: "Resource not found",
|
|
907
|
-
status: 404
|
|
908
|
-
};
|
|
969
|
+
if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
|
|
909
970
|
return {
|
|
910
971
|
success: true,
|
|
911
972
|
data: item,
|
|
@@ -958,11 +1019,7 @@ var BaseController = class {
|
|
|
958
1019
|
status: 400
|
|
959
1020
|
};
|
|
960
1021
|
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
|
|
961
|
-
if (!existing) return
|
|
962
|
-
success: false,
|
|
963
|
-
error: "Resource not found",
|
|
964
|
-
status: 404
|
|
965
|
-
};
|
|
1022
|
+
if (!existing) return this.notFoundResponse("NOT_FOUND");
|
|
966
1023
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
967
1024
|
success: false,
|
|
968
1025
|
error: "You do not have permission to restore this resource",
|
|
@@ -998,11 +1055,7 @@ var BaseController = class {
|
|
|
998
1055
|
meta: { id }
|
|
999
1056
|
});
|
|
1000
1057
|
else item = await repoRestore();
|
|
1001
|
-
if (!item) return
|
|
1002
|
-
success: false,
|
|
1003
|
-
error: "Resource not found",
|
|
1004
|
-
status: 404
|
|
1005
|
-
};
|
|
1058
|
+
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
1006
1059
|
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
|
|
1007
1060
|
user,
|
|
1008
1061
|
context: arcContext,
|
|
@@ -1052,13 +1105,15 @@ var BaseController = class {
|
|
|
1052
1105
|
error: "Repository does not support createMany",
|
|
1053
1106
|
status: 501
|
|
1054
1107
|
};
|
|
1055
|
-
const
|
|
1056
|
-
if (!
|
|
1108
|
+
const rawItems = req.body?.items;
|
|
1109
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) return {
|
|
1057
1110
|
success: false,
|
|
1058
1111
|
error: "Bulk create requires a non-empty items array",
|
|
1059
1112
|
status: 400
|
|
1060
1113
|
};
|
|
1114
|
+
const items = rawItems;
|
|
1061
1115
|
const arcContext = this.meta(req);
|
|
1116
|
+
const user = req.user;
|
|
1062
1117
|
const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
|
|
1063
1118
|
let scopedItems = sanitizedItems;
|
|
1064
1119
|
if (this.tenantField) {
|
|
@@ -1086,7 +1141,10 @@ var BaseController = class {
|
|
|
1086
1141
|
}
|
|
1087
1142
|
}
|
|
1088
1143
|
}
|
|
1089
|
-
const created = await repo.createMany(scopedItems
|
|
1144
|
+
const created = await repo.createMany(scopedItems, {
|
|
1145
|
+
user,
|
|
1146
|
+
context: arcContext
|
|
1147
|
+
});
|
|
1090
1148
|
const requested = items.length;
|
|
1091
1149
|
const inserted = created.length;
|
|
1092
1150
|
const skipped = requested - inserted;
|
|
@@ -1157,13 +1215,23 @@ var BaseController = class {
|
|
|
1157
1215
|
*/
|
|
1158
1216
|
sanitizeBulkUpdateData(data, req, arcContext) {
|
|
1159
1217
|
const stripped = /* @__PURE__ */ new Set();
|
|
1160
|
-
|
|
1218
|
+
const keys = Object.keys(data);
|
|
1219
|
+
const operatorKeys = keys.filter((k) => k.startsWith("$"));
|
|
1220
|
+
const flatKeys = keys.filter((k) => !k.startsWith("$"));
|
|
1221
|
+
const isOperatorShape = operatorKeys.length > 0;
|
|
1222
|
+
if (isOperatorShape && flatKeys.length > 0) return {
|
|
1223
|
+
sanitized: {},
|
|
1224
|
+
stripped: [],
|
|
1225
|
+
mixedShape: true
|
|
1226
|
+
};
|
|
1227
|
+
if (!isOperatorShape) {
|
|
1161
1228
|
const before = new Set(Object.keys(data));
|
|
1162
1229
|
const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
|
|
1163
1230
|
for (const key of before) if (!(key in sanitized)) stripped.add(key);
|
|
1164
1231
|
return {
|
|
1165
1232
|
sanitized,
|
|
1166
|
-
stripped: [...stripped]
|
|
1233
|
+
stripped: [...stripped],
|
|
1234
|
+
mixedShape: false
|
|
1167
1235
|
};
|
|
1168
1236
|
}
|
|
1169
1237
|
const sanitized = {};
|
|
@@ -1180,7 +1248,8 @@ var BaseController = class {
|
|
|
1180
1248
|
}
|
|
1181
1249
|
return {
|
|
1182
1250
|
sanitized,
|
|
1183
|
-
stripped: [...stripped]
|
|
1251
|
+
stripped: [...stripped],
|
|
1252
|
+
mixedShape: false
|
|
1184
1253
|
};
|
|
1185
1254
|
}
|
|
1186
1255
|
async bulkUpdate(req) {
|
|
@@ -1209,7 +1278,14 @@ var BaseController = class {
|
|
|
1209
1278
|
status: 403
|
|
1210
1279
|
};
|
|
1211
1280
|
const arcContext = this.meta(req);
|
|
1212
|
-
const
|
|
1281
|
+
const user = req.user;
|
|
1282
|
+
const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
|
|
1283
|
+
if (mixedShape) return {
|
|
1284
|
+
success: false,
|
|
1285
|
+
error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
|
|
1286
|
+
details: { code: "MIXED_UPDATE_SHAPE" },
|
|
1287
|
+
status: 400
|
|
1288
|
+
};
|
|
1213
1289
|
if (Object.keys(sanitized).length === 0) return {
|
|
1214
1290
|
success: false,
|
|
1215
1291
|
error: "Bulk update payload contained only protected fields",
|
|
@@ -1221,7 +1297,10 @@ var BaseController = class {
|
|
|
1221
1297
|
};
|
|
1222
1298
|
return {
|
|
1223
1299
|
success: true,
|
|
1224
|
-
data: await repo.updateMany(scopedFilter, sanitized
|
|
1300
|
+
data: await repo.updateMany(scopedFilter, sanitized, {
|
|
1301
|
+
user,
|
|
1302
|
+
context: arcContext
|
|
1303
|
+
}),
|
|
1225
1304
|
status: 200,
|
|
1226
1305
|
...stripped.length > 0 && { meta: { stripped } }
|
|
1227
1306
|
};
|
|
@@ -1272,9 +1351,16 @@ var BaseController = class {
|
|
|
1272
1351
|
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1273
1352
|
status: 403
|
|
1274
1353
|
};
|
|
1354
|
+
const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
|
|
1355
|
+
const arcContext = this.meta(req);
|
|
1356
|
+
const options = {
|
|
1357
|
+
user: req.user,
|
|
1358
|
+
context: arcContext
|
|
1359
|
+
};
|
|
1360
|
+
if (hardHint) options.mode = "hard";
|
|
1275
1361
|
return {
|
|
1276
1362
|
success: true,
|
|
1277
|
-
data:
|
|
1363
|
+
data: await repo.deleteMany(scopedFilter, options),
|
|
1278
1364
|
status: 200
|
|
1279
1365
|
};
|
|
1280
1366
|
}
|