@classytic/arc 2.9.1 → 2.10.8
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 +20 -91
- package/dist/{BaseController-Vu2yc56T.mjs → BaseController-DVNKvoX4.mjs} +154 -170
- package/dist/{ResourceRegistry-Dq3_zBQP.mjs → ResourceRegistry-CcN2LVrc.mjs} +1 -1
- package/dist/actionPermissions-TUVR3uiZ.mjs +22 -0
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.mjs +2 -2
- package/dist/{adapters-BBqAVvPK.mjs → adapters-BXY4i-hw.mjs} +210 -41
- package/dist/audit/index.d.mts +38 -3
- package/dist/audit/index.mjs +54 -22
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +3 -3
- package/dist/cache/index.d.mts +17 -15
- package/dist/cache/index.mjs +16 -15
- package/dist/{caching-CjybdRwx.mjs → caching-3h93rkJM.mjs} +8 -3
- package/dist/cli/commands/describe.mjs +1 -1
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/init.mjs +1 -1
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.d.mts +58 -0
- package/dist/context/index.mjs +2 -0
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +3 -4
- package/dist/{defineResource-C__jkwvs.mjs → core-3MWJosCH.mjs} +174 -94
- package/dist/{createActionRouter-DH1YFL9m.mjs → createActionRouter-C8UUB3Px.mjs} +1 -1
- package/dist/{createApp-CBJUJKGP.mjs → createApp-BwnEAO2h.mjs} +53 -19
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DxQ6ACbt.mjs → elevation-Dci0AYLT.mjs} +2 -2
- package/dist/errorHandler-2ii4RIYr.d.mts +114 -0
- package/dist/{errorHandler-CZDW4EXS.mjs → errorHandler-CSxe7KIM.mjs} +1 -1
- package/dist/{eventPlugin-Dl7MoVWH.mjs → eventPlugin-ByU4Cv0e.mjs} +1 -1
- package/dist/{eventPlugin-BxvaCIZF.d.mts → eventPlugin-D1ThQ1Pp.d.mts} +1 -1
- package/dist/events/index.d.mts +8 -5
- package/dist/events/index.mjs +87 -52
- 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 +1 -1
- package/dist/{types-DZi1aYhm.d.mts → fields-C8Y0XLAu.d.mts} +122 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/idempotency/index.d.mts +5 -2
- package/dist/idempotency/index.mjs +46 -37
- package/dist/{interface-YrWsmKqE.d.mts → index-BGbpGVyM.d.mts} +2107 -2756
- package/dist/{index-CtGKT0lf.d.mts → index-BziRPS4H.d.mts} +81 -7
- package/dist/{index-C-xjcA6F.d.mts → index-C_Noptz-.d.mts} +284 -409
- package/dist/{index-Cibkchnx.d.mts → index-EqQN6p0W.d.mts} +3 -3
- package/dist/index.d.mts +6 -219
- package/dist/index.mjs +10 -131
- 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 +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/interface-yhyb_pLY.d.mts +77 -0
- package/dist/logger/index.d.mts +81 -0
- package/dist/{logger-CDjpjySd.mjs → logger/index.mjs} +1 -6
- package/dist/{memory-BFAYkf8H.mjs → memory-DqI-449b.mjs} +23 -8
- package/dist/middleware/index.d.mts +109 -0
- package/dist/middleware/index.mjs +70 -0
- package/dist/multipartBody-CUQGVlM_.mjs +123 -0
- package/dist/{openapi-CXuTG1M9.mjs → openapi-DpNpqBmo.mjs} +9 -7
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +3 -4
- package/dist/permissions/index.mjs +5 -5
- package/dist/{permissions-oNZawnkR.mjs → permissions-wkqRwicB.mjs} +315 -397
- package/dist/pipe-CGJxqDGx.mjs +62 -0
- package/dist/pipeline/index.d.mts +62 -0
- package/dist/pipeline/index.mjs +53 -0
- package/dist/plugins/index.d.mts +23 -3
- package/dist/plugins/index.mjs +9 -11
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +3 -3
- package/dist/presets/filesUpload.mjs +255 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +43 -9
- package/dist/presets/search.d.mts +91 -4
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-hM4WhNWY.mjs → presets-CrwOvuXI.mjs} +1 -1
- package/dist/{queryCachePlugin-DbUVroUG.mjs → queryCachePlugin-ChLNZvFT.mjs} +9 -9
- package/dist/{queryCachePlugin-CnTZZTC5.d.mts → queryCachePlugin-Dumka73q.d.mts} +1 -1
- package/dist/{queryParser-Cs-6SHQK.mjs → queryParser-NR__Qiju.mjs} +69 -2
- package/dist/{redis-stream-Bz-4q96t.d.mts → redis-stream-bkO88VHx.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +1 -1
- package/dist/{requestContext-DYtmNpm5.mjs → requestContext-C38GskNt.mjs} +1 -1
- package/dist/{resourceToTools-C3cWymnW.mjs → resourceToTools-BhF3JV5p.mjs} +8 -3
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +2 -2
- package/dist/{sse-CJpt7LGI.mjs → sse-D8UeDwis.mjs} +1 -1
- package/dist/{store-helpers-DFiZl5TL.mjs → store-helpers-DYYUQbQN.mjs} +4 -0
- package/dist/testing/index.d.mts +6 -5
- package/dist/testing/index.mjs +17 -10
- package/dist/types/index.d.mts +5 -5
- package/dist/types/index.mjs +1 -31
- package/dist/types-CDnTEpga.mjs +27 -0
- package/dist/{types-CoSzA-s-.d.mts → types-CVKBssX5.d.mts} +1 -1
- package/dist/{types-CunEX4UX.d.mts → types-CVdgPXBW.d.mts} +20 -7
- package/dist/utils/index.d.mts +277 -3
- package/dist/utils/index.mjs +4 -5
- package/dist/{utils-B7FuRr9w.mjs → utils-LMwVidKy.mjs} +303 -2
- package/dist/{versioning-Cm8qoFDg.mjs → versioning-B6mimogM.mjs} +3 -5
- package/dist/versioning-CeUXHfjw.d.mts +117 -0
- package/package.json +31 -18
- package/skills/arc/SKILL.md +8 -12
- package/skills/arc/references/production.md +0 -41
- package/dist/circuitBreaker-CvXkjfrW.d.mts +0 -206
- package/dist/circuitBreaker-l18oRgL5.mjs +0 -284
- package/dist/core-DNncu0xF.mjs +0 -34
- package/dist/dynamic/index.d.mts +0 -93
- package/dist/dynamic/index.mjs +0 -122
- package/dist/errorHandler-DixGcttC.d.mts +0 -218
- package/dist/fields-BC7zcmI9.d.mts +0 -121
- package/dist/filesUpload-q8oHt--L.mjs +0 -377
- package/dist/interface-DplgQO2e.d.mts +0 -54
- package/dist/policies/index.d.mts +0 -425
- package/dist/policies/index.mjs +0 -318
- package/dist/rpc/index.d.mts +0 -90
- package/dist/rpc/index.mjs +0 -248
- /package/dist/{EventTransport-CqZ8FyM_.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
- /package/dist/{applyPermissionResult-bqGpo9ML.mjs → applyPermissionResult-QhV1Pa-g.mjs} +0 -0
- /package/dist/{constants-Cxde4rpC.mjs → constants-BhY1OHoH.mjs} +0 -0
- /package/dist/{elevation-B6S5csVA.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
- /package/dist/{errors-CqWnSqM-.mjs → errors-BqdUDja_.mjs} +0 -0
- /package/dist/{fields-CU6FlaDV.mjs → fields-CTMWOUDt.mjs} +0 -0
- /package/dist/{keys-qcD-TVJl.mjs → keys-nWQGUTu1.mjs} +0 -0
- /package/dist/{types-ZUu_h0jp.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +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.10** | Fastify 5+ | Node.js 22+ | ESM only
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -65,12 +65,14 @@ const app = await createApp({
|
|
|
65
65
|
Clean DX without growing exclude lists:
|
|
66
66
|
|
|
67
67
|
```typescript
|
|
68
|
-
import { Repository } from '@classytic/mongokit';
|
|
68
|
+
import { Repository, methodRegistryPlugin, batchOperationsPlugin } from '@classytic/mongokit';
|
|
69
69
|
|
|
70
70
|
// app.ts — pass any RepositoryLike (mongokit / prismakit / custom)
|
|
71
71
|
await fastify.register(auditPlugin, {
|
|
72
72
|
autoAudit: { perResource: true },
|
|
73
|
-
|
|
73
|
+
// batchOperationsPlugin enables deleteMany, required for purgeOlderThan()
|
|
74
|
+
repository: new Repository(AuditModel, [methodRegistryPlugin(), batchOperationsPlugin()]),
|
|
75
|
+
// or omit `repository` for in-memory dev
|
|
74
76
|
});
|
|
75
77
|
|
|
76
78
|
// order.resource.ts — opt in
|
|
@@ -600,7 +602,7 @@ await app.register(eventGatewayPlugin, {
|
|
|
600
602
|
Functional composition for cross-cutting concerns:
|
|
601
603
|
|
|
602
604
|
```typescript
|
|
603
|
-
import { pipe, guard, transform, intercept } from '@classytic/arc';
|
|
605
|
+
import { pipe, guard, transform, intercept } from '@classytic/arc/pipeline';
|
|
604
606
|
|
|
605
607
|
const isActive = guard('isActive', (ctx) => ctx.query?.filters?.isActive !== false);
|
|
606
608
|
const slugify = transform('slugify', (ctx) => ({ ...ctx, body: { ...ctx.body, slug: toSlug(ctx.body.name) } }));
|
|
@@ -737,7 +739,6 @@ Arc sets `"sideEffects": false` in [package.json](package.json), so modern bundl
|
|
|
737
739
|
| `@classytic/arc/presets` | Preset functions + interfaces |
|
|
738
740
|
| `@classytic/arc/audit` | Audit trail |
|
|
739
741
|
| `@classytic/arc/idempotency` | Idempotency |
|
|
740
|
-
| `@classytic/arc/policies` | Policy engine |
|
|
741
742
|
| `@classytic/arc/schemas` | TypeBox helpers |
|
|
742
743
|
| `@classytic/arc/utils` | Errors, circuit breaker, state machine, query parser |
|
|
743
744
|
| `@classytic/arc/testing` | Test utilities, mocks, in-memory DB |
|
|
@@ -750,101 +751,29 @@ Arc sets `"sideEffects": false` in [package.json](package.json), so modern bundl
|
|
|
750
751
|
| `@classytic/arc/docs` | OpenAPI generation |
|
|
751
752
|
| `@classytic/arc/cli` | CLI commands (programmatic) |
|
|
752
753
|
|
|
753
|
-
##
|
|
754
|
+
## Type imports
|
|
754
755
|
|
|
755
|
-
`
|
|
756
|
-
`repository: RepositoryLike` option — pass any `Repository` (mongokit /
|
|
757
|
-
prismakit / your own kit). Arc calls `create` / `getOne` / `findAll` /
|
|
758
|
-
`deleteMany` / `findOneAndUpdate` on it directly:
|
|
756
|
+
Arc owns framework types (`IController`, `IRequestContext`, `ResourceConfig`, `RepositoryLike`, `PaginationResult`). The repository contract lives in `@classytic/repo-core` — import those types directly:
|
|
759
757
|
|
|
760
758
|
```typescript
|
|
761
|
-
|
|
762
|
-
import {
|
|
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).
|
|
759
|
+
// Arc framework types
|
|
760
|
+
import type { IRequestContext, RepositoryLike, PaginationResult } from '@classytic/arc';
|
|
787
761
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
808
|
-
## v2.8.4 Highlights
|
|
809
|
-
|
|
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.
|
|
811
|
-
|
|
812
|
-
```typescript
|
|
813
|
-
import { bridgeToMcp, buildMcpToolsFromBridges, type McpBridge } from '@classytic/arc/mcp';
|
|
814
|
-
|
|
815
|
-
export const triggerJobBridge: McpBridge = {
|
|
816
|
-
name: 'trigger_job',
|
|
817
|
-
description: 'Start a job.',
|
|
818
|
-
inputSchema: { phase: z.enum(['investigate', 'fix']) },
|
|
819
|
-
annotations: { destructiveHint: true },
|
|
820
|
-
buildTool: (ctx) => buildTriggerJobTool(getUserId(ctx) ?? ''),
|
|
821
|
-
};
|
|
822
|
-
|
|
823
|
-
await app.register(mcpPlugin, {
|
|
824
|
-
resources,
|
|
825
|
-
extraTools: buildMcpToolsFromBridges([triggerJobBridge]),
|
|
826
|
-
});
|
|
762
|
+
// Repository contract (repo-core is the single source of truth)
|
|
763
|
+
import type { StandardRepo, WriteOptions, QueryOptions } from '@classytic/repo-core/repository';
|
|
764
|
+
import type { OffsetPaginationResult } from '@classytic/repo-core/pagination';
|
|
827
765
|
```
|
|
828
766
|
|
|
829
|
-
|
|
767
|
+
> Arc 2.10 dropped the legacy `CrudRepository`, `PaginatedResult`, and pass-through `WriteOptions`/`QueryOptions` re-exports. See [CHANGELOG.md](CHANGELOG.md#210) for the migration table.
|
|
830
768
|
|
|
831
|
-
|
|
832
|
-
- **Actions in OpenAPI** — `POST /:id/action` endpoint auto-generated from `ResourceDefinition.actions`, with per-action descriptions and the same discriminated body schema as the runtime router
|
|
833
|
-
- **Route/action metadata preserved** — `mcp: false`, `description`, `annotations` no longer dropped during `routes → additionalRoutes` normalization
|
|
834
|
-
- **Canonical source retained** — `ResourceDefinition.routes` and `ResourceDefinition.actions` now kept as declared, so OpenAPI/MCP/registry can read the original shape
|
|
835
|
-
- **Outbox hardening** — expanded `OutboxStore` contract (`claimPending`, `fail`, write options, dedupe, visibleAt), ownership-mismatch throws, onError reporting, safe multi-worker relay
|
|
836
|
-
- **`slugLookup` fallback** — works with MongoKit's default Repository (no custom `getBySlug` needed)
|
|
769
|
+
## v2.10 Highlights
|
|
837
770
|
|
|
838
|
-
|
|
771
|
+
- **Clean-break on repo-core types** — `CrudRepository` / `PaginatedResult` / pass-through repo options removed from arc's public surface. Import `StandardRepo`, `OffsetPaginationResult`, etc. directly from `@classytic/repo-core`. See [CHANGELOG.md](CHANGELOG.md) for the rewrite table.
|
|
772
|
+
- **Outbox bugfix** — `repositoryAsOutboxStore.fail()` now passes `updatePipeline: true` to `findOneAndUpdate`, so retry / DLQ transitions work on mongokit ≥3.10.
|
|
773
|
+
- **Plugin requirements documented** — audit / outbox / idempotency require mongokit's `methodRegistryPlugin` + `batchOperationsPlugin` for `deleteMany`; README snippets + production-ops docs now show the correct chain.
|
|
774
|
+
- **Removed** — `@classytic/arc/policies` (use `permissions/`), `@classytic/arc/rpc`, `@classytic/arc/dynamic` (use `factory/loadResources`).
|
|
839
775
|
|
|
840
|
-
|
|
841
|
-
- **Reply Helpers** — `reply.ok()`, `reply.fail()`, `reply.paginated()`, `reply.stream()` (opt-in)
|
|
842
|
-
- **Error Mappers** — class-based `instanceof` domain error → HTTP response mapping
|
|
843
|
-
- **Multipart Body** — `multipartBody()` middleware for file upload in CRUD routes
|
|
844
|
-
- **Service Scope** — `kind: "service"` RequestScope for machine-to-machine auth (MCP + WebSocket)
|
|
845
|
-
- **BigInt Serialization** — `serializeBigInt: true` auto-converts BigInt → Number
|
|
846
|
-
- **Event WAL** — skips internal `arc.*` events to prevent startup timeout with durable stores
|
|
847
|
-
- **Security** — `auth: false` produces null `ctx.user` (prevents anonymous bypass of `!!ctx.user` guards)
|
|
776
|
+
See [CHANGELOG.md](CHANGELOG.md) for the full v2.9 / v2.8 / v2.7 history.
|
|
848
777
|
|
|
849
778
|
## License
|
|
850
779
|
|
|
@@ -1,33 +1,26 @@
|
|
|
1
|
-
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-
|
|
1
|
+
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
|
|
2
|
+
import { arcLog } from "./logger/index.mjs";
|
|
2
3
|
import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
|
|
3
|
-
import { t as
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
4
|
+
import { r as simpleEqualityMatcher, t as ArcQueryParser } from "./queryParser-NR__Qiju.mjs";
|
|
5
|
+
import { t as buildQueryKey } from "./keys-nWQGUTu1.mjs";
|
|
6
|
+
import { n as getUserId } from "./types-CDnTEpga.mjs";
|
|
7
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CTMWOUDt.mjs";
|
|
8
|
+
import { t as getUserRoles } from "./types-D57iXYb8.mjs";
|
|
9
|
+
import { r as ForbiddenError } from "./errors-BqdUDja_.mjs";
|
|
9
10
|
//#region src/core/AccessControl.ts
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
* Handles ID filtering, policy filter checking, org/tenant scope validation,
|
|
14
|
-
* ownership verification, and fetch-with-access-control patterns.
|
|
15
|
-
*
|
|
16
|
-
* Designed to be used standalone or composed into controllers.
|
|
17
|
-
*/
|
|
18
|
-
var AccessControl = class AccessControl {
|
|
11
|
+
const log = arcLog("access-control");
|
|
12
|
+
var AccessControl = class {
|
|
19
13
|
tenantField;
|
|
20
14
|
idField;
|
|
21
15
|
_adapterMatchesFilter;
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
];
|
|
16
|
+
/**
|
|
17
|
+
* One-shot latch for the "adapter didn't supply matchesFilter, in-memory
|
|
18
|
+
* policy-filter re-check is skipped" warning. The primary fetch path
|
|
19
|
+
* (`getOne(compoundFilter)`) already applied filters at the DB layer;
|
|
20
|
+
* this warn only fires when `validateItemAccess` runs and the adapter
|
|
21
|
+
* hasn't provided a native matcher for the post-hoc re-check.
|
|
22
|
+
*/
|
|
23
|
+
_warnedNoMatcher = false;
|
|
31
24
|
constructor(config) {
|
|
32
25
|
this.tenantField = config.tenantField;
|
|
33
26
|
this.idField = config.idField;
|
|
@@ -48,17 +41,54 @@ var AccessControl = class AccessControl {
|
|
|
48
41
|
return filter;
|
|
49
42
|
}
|
|
50
43
|
/**
|
|
51
|
-
* Check if item matches
|
|
52
|
-
*
|
|
44
|
+
* Check if a post-fetch item matches the request's `_policyFilters`.
|
|
45
|
+
*
|
|
46
|
+
* **When this runs:** only on paths where the primary fetch path did NOT
|
|
47
|
+
* apply policy filters at the DB layer — notably `validateItemAccess`
|
|
48
|
+
* (used by `getBySlug` and cache revalidation). The main `fetchDetailed`
|
|
49
|
+
* path builds a compound filter (`buildIdFilter`) and passes it to
|
|
50
|
+
* `repository.getOne(compoundFilter)`, so the DB has already enforced
|
|
51
|
+
* the filter and an in-memory re-check would be redundant.
|
|
52
|
+
*
|
|
53
|
+
* **Evaluation order (fail-closed):**
|
|
54
|
+
* 1. No `_policyFilters` set → `true` (nothing to enforce).
|
|
55
|
+
* 2. Adapter supplied `matchesFilter` → delegate to it verbatim. Adapters
|
|
56
|
+
* are expected to handle every filter shape the host emits
|
|
57
|
+
* (mongokit/sqlitekit evaluate at the DB layer; Prisma/custom engines
|
|
58
|
+
* can wrap their own predicate engine).
|
|
59
|
+
* 3. No adapter matcher → fall back to `simpleEqualityMatcher` — arc's
|
|
60
|
+
* built-in flat-key equality helper. This is defense-in-depth for the
|
|
61
|
+
* common case: arc's own permission helpers emit flat filters
|
|
62
|
+
* (`{userId: …}`, `{organizationId: …}`), which this matcher evaluates
|
|
63
|
+
* correctly. Operator-shaped filters (`$in`, `$ne`, `$regex`, `$and`,
|
|
64
|
+
* `$or`) are **rejected** (the matcher returns `false`) — fail-closed
|
|
65
|
+
* rather than fail-open. A one-shot warn flags the gap so adapter
|
|
66
|
+
* authors can wire a richer matcher.
|
|
53
67
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
68
|
+
* Arc deliberately does NOT ship a full MongoDB-syntax matcher:
|
|
69
|
+
* re-implementing Mongo in JS was dead code for mongokit users (the DB
|
|
70
|
+
* did it) and silently wrong for non-Mongo adapters. The flat-equality
|
|
71
|
+
* fallback is small (~20 LOC), correct in both dialects, and closes the
|
|
72
|
+
* previous `getBySlug`-style policy-bypass path.
|
|
56
73
|
*/
|
|
57
74
|
checkPolicyFilters(item, req) {
|
|
58
75
|
const policyFilters = this._meta(req)?._policyFilters;
|
|
59
|
-
if (!policyFilters) return true;
|
|
76
|
+
if (!policyFilters || Object.keys(policyFilters).length === 0) return true;
|
|
60
77
|
if (this._adapterMatchesFilter) return this._adapterMatchesFilter(item, policyFilters);
|
|
61
|
-
|
|
78
|
+
if (Object.values(policyFilters).some((v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype && Object.keys(v).some((k) => k.startsWith("$")))) this._warnNoMatcher(policyFilters);
|
|
79
|
+
return simpleEqualityMatcher(item, policyFilters);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Emit a one-shot warn when policy filters contain operators (`$in`,
|
|
83
|
+
* `$ne`, `$regex`, etc.) and no `DataAdapter.matchesFilter` is wired —
|
|
84
|
+
* arc's flat-equality fallback fail-closes on operators, so the host
|
|
85
|
+
* sees 404s on docs that should match. Latched on `_warnedNoMatcher`
|
|
86
|
+
* so subsequent requests stay quiet.
|
|
87
|
+
*/
|
|
88
|
+
_warnNoMatcher(policyFilters) {
|
|
89
|
+
if (this._warnedNoMatcher) return;
|
|
90
|
+
this._warnedNoMatcher = true;
|
|
91
|
+
log.warn("`_policyFilters` contains operator-shaped entries (e.g. `$in`, `$ne`, `$regex`) but `DataAdapter.matchesFilter` is not set. Arc's flat-equality fallback cannot evaluate operators and will reject these items on non-compound fetches (`validateItemAccess`, `getBySlug`, cache revalidation). Wire up `matchesFilter` on your adapter — use `matchFilter` from `@classytic/repo-core/filter` for IR-based adapters, or your DB's native predicate engine.", { policyFilterKeys: Object.keys(policyFilters) });
|
|
62
92
|
}
|
|
63
93
|
/**
|
|
64
94
|
* Check org/tenant scope for a document — uses configurable tenantField.
|
|
@@ -72,7 +102,6 @@ var AccessControl = class AccessControl {
|
|
|
72
102
|
const scope = arcContext?._scope;
|
|
73
103
|
const orgId = scope ? getOrgId(scope) : void 0;
|
|
74
104
|
if (!item || !orgId) return true;
|
|
75
|
-
if (scope && isElevated(scope) && !orgId) return true;
|
|
76
105
|
const itemOrgId = item[this.tenantField];
|
|
77
106
|
if (!itemOrgId) return false;
|
|
78
107
|
return String(itemOrgId) === String(orgId);
|
|
@@ -130,7 +159,25 @@ var AccessControl = class AccessControl {
|
|
|
130
159
|
};
|
|
131
160
|
if (hasCompoundFilters) {
|
|
132
161
|
const idOnly = { [this.idField]: id };
|
|
133
|
-
const
|
|
162
|
+
const rawGetOne = repository.getOne.bind(repository);
|
|
163
|
+
let rawDoc = null;
|
|
164
|
+
try {
|
|
165
|
+
rawDoc = await rawGetOne(idOnly);
|
|
166
|
+
} catch (unscopedErr) {
|
|
167
|
+
if (translateStatus404(unscopedErr)) return {
|
|
168
|
+
doc: null,
|
|
169
|
+
reason: "NOT_FOUND"
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
rawDoc = await rawGetOne(idOnly, queryOptions);
|
|
173
|
+
} catch (scopedErr) {
|
|
174
|
+
if (translateStatus404(scopedErr)) return {
|
|
175
|
+
doc: null,
|
|
176
|
+
reason: "NOT_FOUND"
|
|
177
|
+
};
|
|
178
|
+
throw scopedErr;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
134
181
|
if (rawDoc) {
|
|
135
182
|
const arcContext = this._meta(req);
|
|
136
183
|
if (!this.checkOrgScope(rawDoc, arcContext)) return {
|
|
@@ -191,101 +238,6 @@ var AccessControl = class AccessControl {
|
|
|
191
238
|
_meta(req) {
|
|
192
239
|
return req.metadata;
|
|
193
240
|
}
|
|
194
|
-
/**
|
|
195
|
-
* Check if a value matches a MongoDB query operator
|
|
196
|
-
*/
|
|
197
|
-
matchesOperator(itemValue, operator, filterValue) {
|
|
198
|
-
const equalsByValue = (a, b) => String(a) === String(b);
|
|
199
|
-
switch (operator) {
|
|
200
|
-
case "$eq": return equalsByValue(itemValue, filterValue);
|
|
201
|
-
case "$ne": return !equalsByValue(itemValue, filterValue);
|
|
202
|
-
case "$gt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue > filterValue;
|
|
203
|
-
case "$gte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue >= filterValue;
|
|
204
|
-
case "$lt": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue < filterValue;
|
|
205
|
-
case "$lte": return typeof itemValue === "number" && typeof filterValue === "number" && itemValue <= filterValue;
|
|
206
|
-
case "$in":
|
|
207
|
-
if (!Array.isArray(filterValue)) return false;
|
|
208
|
-
if (Array.isArray(itemValue)) return itemValue.some((v) => filterValue.some((fv) => equalsByValue(v, fv)));
|
|
209
|
-
return filterValue.some((fv) => equalsByValue(itemValue, fv));
|
|
210
|
-
case "$nin":
|
|
211
|
-
if (!Array.isArray(filterValue)) return false;
|
|
212
|
-
if (Array.isArray(itemValue)) return itemValue.every((v) => filterValue.every((fv) => !equalsByValue(v, fv)));
|
|
213
|
-
return filterValue.every((fv) => !equalsByValue(itemValue, fv));
|
|
214
|
-
case "$exists": return filterValue ? itemValue !== void 0 : itemValue === void 0;
|
|
215
|
-
case "$regex":
|
|
216
|
-
if (typeof itemValue === "string" && (typeof filterValue === "string" || filterValue instanceof RegExp)) return (typeof filterValue === "string" ? AccessControl.safeRegex(filterValue) : filterValue)?.test(itemValue) ?? false;
|
|
217
|
-
return false;
|
|
218
|
-
default: return false;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Check if item matches a single filter condition
|
|
223
|
-
* Supports nested paths (e.g., "owner.id", "metadata.status")
|
|
224
|
-
*/
|
|
225
|
-
matchesFilter(item, key, filterValue) {
|
|
226
|
-
const itemValue = key.includes(".") ? this.getNestedValue(item, key) : item[key];
|
|
227
|
-
if (filterValue && typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
|
228
|
-
if (Object.keys(filterValue).some((op) => op.startsWith("$"))) {
|
|
229
|
-
for (const [operator, opValue] of Object.entries(filterValue)) if (!this.matchesOperator(itemValue, operator, opValue)) return false;
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (Array.isArray(itemValue)) return itemValue.some((v) => String(v) === String(filterValue));
|
|
234
|
-
return String(itemValue) === String(filterValue);
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Built-in MongoDB-style policy filter matching.
|
|
238
|
-
* Supports: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $and, $or
|
|
239
|
-
*/
|
|
240
|
-
defaultMatchesPolicyFilters(item, policyFilters) {
|
|
241
|
-
if (policyFilters.$and && Array.isArray(policyFilters.$and)) {
|
|
242
|
-
if (!policyFilters.$and.every((condition) => {
|
|
243
|
-
return Object.entries(condition).every(([key, value]) => {
|
|
244
|
-
return this.matchesFilter(item, key, value);
|
|
245
|
-
});
|
|
246
|
-
})) return false;
|
|
247
|
-
}
|
|
248
|
-
if (policyFilters.$or && Array.isArray(policyFilters.$or)) {
|
|
249
|
-
if (!policyFilters.$or.some((condition) => {
|
|
250
|
-
return Object.entries(condition).every(([key, value]) => {
|
|
251
|
-
return this.matchesFilter(item, key, value);
|
|
252
|
-
});
|
|
253
|
-
})) return false;
|
|
254
|
-
}
|
|
255
|
-
for (const [key, value] of Object.entries(policyFilters)) {
|
|
256
|
-
if (key.startsWith("$")) continue;
|
|
257
|
-
if (!this.matchesFilter(item, key, value)) return false;
|
|
258
|
-
}
|
|
259
|
-
return true;
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Get nested value from object using dot notation (e.g., "owner.id")
|
|
263
|
-
* Security: Validates path against forbidden patterns to prevent prototype pollution
|
|
264
|
-
*/
|
|
265
|
-
getNestedValue(obj, path) {
|
|
266
|
-
if (AccessControl.FORBIDDEN_PATHS.some((p) => path.toLowerCase().includes(p))) return;
|
|
267
|
-
const keys = path.split(".");
|
|
268
|
-
let value = obj;
|
|
269
|
-
for (const key of keys) {
|
|
270
|
-
if (value == null) return void 0;
|
|
271
|
-
if (AccessControl.FORBIDDEN_PATHS.includes(key.toLowerCase())) return;
|
|
272
|
-
value = value[key];
|
|
273
|
-
}
|
|
274
|
-
return value;
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Create a safe RegExp from a string, guarding against ReDoS.
|
|
278
|
-
* Returns null if the pattern is invalid or dangerous.
|
|
279
|
-
*/
|
|
280
|
-
static safeRegex(pattern) {
|
|
281
|
-
if (pattern.length > 200) return null;
|
|
282
|
-
if (AccessControl.DANGEROUS_REGEX.test(pattern)) return null;
|
|
283
|
-
try {
|
|
284
|
-
return new RegExp(pattern);
|
|
285
|
-
} catch {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
241
|
};
|
|
290
242
|
var BodySanitizer = class {
|
|
291
243
|
schemaOptions;
|
|
@@ -304,10 +256,13 @@ var BodySanitizer = class {
|
|
|
304
256
|
sanitize(body, _operation, req, meta) {
|
|
305
257
|
let sanitized = { ...body };
|
|
306
258
|
for (const field of SYSTEM_FIELDS) delete sanitized[field];
|
|
259
|
+
const scopeForRules = req ? (meta ?? req.metadata)?._scope ?? PUBLIC_SCOPE : void 0;
|
|
260
|
+
const scopeIsElevated = scopeForRules ? isElevated(scopeForRules) : false;
|
|
307
261
|
const fieldRules = this.schemaOptions.fieldRules ?? {};
|
|
308
262
|
for (const [field, rules] of Object.entries(fieldRules)) {
|
|
309
|
-
|
|
310
|
-
if (
|
|
263
|
+
const bypass = Boolean(rules.preserveForElevated) && scopeIsElevated;
|
|
264
|
+
if ((rules.systemManaged || rules.readonly) && !bypass) delete sanitized[field];
|
|
265
|
+
if (_operation === "update" && (rules.immutable || rules.immutableAfterCreate) && !bypass) delete sanitized[field];
|
|
311
266
|
}
|
|
312
267
|
if (req) {
|
|
313
268
|
const arcContext = meta ?? req.metadata;
|
|
@@ -344,6 +299,7 @@ var QueryResolver = class {
|
|
|
344
299
|
queryParser;
|
|
345
300
|
maxLimit;
|
|
346
301
|
defaultLimit;
|
|
302
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
347
303
|
defaultSort;
|
|
348
304
|
schemaOptions;
|
|
349
305
|
tenantField;
|
|
@@ -351,7 +307,7 @@ var QueryResolver = class {
|
|
|
351
307
|
this.queryParser = config.queryParser ?? getDefaultQueryParser();
|
|
352
308
|
this.maxLimit = config.maxLimit ?? 100;
|
|
353
309
|
this.defaultLimit = config.defaultLimit ?? 20;
|
|
354
|
-
this.defaultSort = config.defaultSort ?? "-createdAt";
|
|
310
|
+
this.defaultSort = config.defaultSort === false ? void 0 : config.defaultSort ?? "-createdAt";
|
|
355
311
|
this.schemaOptions = config.schemaOptions ?? {};
|
|
356
312
|
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
357
313
|
}
|
|
@@ -482,6 +438,7 @@ var BaseController = class {
|
|
|
482
438
|
queryParser;
|
|
483
439
|
maxLimit;
|
|
484
440
|
defaultLimit;
|
|
441
|
+
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
485
442
|
defaultSort;
|
|
486
443
|
resourceName;
|
|
487
444
|
tenantField;
|
|
@@ -501,7 +458,7 @@ var BaseController = class {
|
|
|
501
458
|
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
502
459
|
this.maxLimit = options.maxLimit ?? 100;
|
|
503
460
|
this.defaultLimit = options.defaultLimit ?? 20;
|
|
504
|
-
this.defaultSort = options.defaultSort ?? "-createdAt";
|
|
461
|
+
this.defaultSort = options.defaultSort === false ? void 0 : options.defaultSort ?? "-createdAt";
|
|
505
462
|
this.resourceName = options.resourceName;
|
|
506
463
|
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
507
464
|
this.idField = options.idField ?? repository?.idField ?? "_id";
|
|
@@ -521,7 +478,7 @@ var BaseController = class {
|
|
|
521
478
|
queryParser: this.queryParser,
|
|
522
479
|
maxLimit: this.maxLimit,
|
|
523
480
|
defaultLimit: this.defaultLimit,
|
|
524
|
-
defaultSort:
|
|
481
|
+
defaultSort: options.defaultSort,
|
|
525
482
|
schemaOptions: this.schemaOptions,
|
|
526
483
|
tenantField: this.tenantField
|
|
527
484
|
});
|
|
@@ -541,6 +498,48 @@ var BaseController = class {
|
|
|
541
498
|
getTenantField() {
|
|
542
499
|
return this.tenantField || void 0;
|
|
543
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Build top-level tenant options to thread into the repository call.
|
|
503
|
+
*
|
|
504
|
+
* **Why this exists:** repo plugins (e.g. `@classytic/mongokit`'s
|
|
505
|
+
* `multiTenantPlugin`) read tenant scope from the TOP of the repository
|
|
506
|
+
* operation context — `context.organizationId`, not `context.data.organizationId`
|
|
507
|
+
* or `context.context.organizationId`. Without this stamping, a tenant-scoped
|
|
508
|
+
* repository throws `Missing 'organizationId' in context for '<op>'` even
|
|
509
|
+
* though arc already injected the tenant into the request body.
|
|
510
|
+
*
|
|
511
|
+
* **What this returns:**
|
|
512
|
+
* - `{ [tenantField]: orgId }` when the resource is tenant-scoped and the
|
|
513
|
+
* caller's scope carries an org ID (member, service key bound to an org,
|
|
514
|
+
* elevated admin impersonating an org).
|
|
515
|
+
* - `{}` otherwise — platform-universal resources (`tenantField: false`),
|
|
516
|
+
* public/anonymous reads, elevated admins without an org target.
|
|
517
|
+
*
|
|
518
|
+
* **Call sites:** every `this.repository.*` CRUD entry — `create`, `update`,
|
|
519
|
+
* `delete`, `getAll` (via list), plus merged into `QueryOptions` for the
|
|
520
|
+
* access-controlled read path (`accessControl.fetchDetailed` → `getById`/`getOne`).
|
|
521
|
+
*
|
|
522
|
+
* **Name of the field:** uses the instance's own `tenantField` configuration
|
|
523
|
+
* (default `organizationId`). Matches mongokit's `multiTenantPlugin` default
|
|
524
|
+
* `contextKey` so host apps don't need to override either side.
|
|
525
|
+
*
|
|
526
|
+
* Multi-field tenancy (via `multiTenantPreset({ tenantFields: [...] })`)
|
|
527
|
+
* resolves additional fields at middleware time and stashes them on
|
|
528
|
+
* `_tenantFields` — {@link tenantRepoOptions} merges those in too.
|
|
529
|
+
*/
|
|
530
|
+
tenantRepoOptions(req) {
|
|
531
|
+
const out = {};
|
|
532
|
+
if (this.tenantField) {
|
|
533
|
+
const scope = this.meta(req)?._scope;
|
|
534
|
+
const orgId = scope ? getOrgId(scope) : void 0;
|
|
535
|
+
if (orgId) out[this.tenantField] = orgId;
|
|
536
|
+
}
|
|
537
|
+
const presetFields = req._tenantFields;
|
|
538
|
+
if (presetFields && typeof presetFields === "object") {
|
|
539
|
+
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
544
543
|
/** Extract typed Arc internal metadata from request */
|
|
545
544
|
meta(req) {
|
|
546
545
|
return req.metadata;
|
|
@@ -663,21 +662,15 @@ var BaseController = class {
|
|
|
663
662
|
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
664
663
|
async executeListQuery(options, req) {
|
|
665
664
|
const hooks = this.getHooks(req);
|
|
666
|
-
const
|
|
667
|
-
|
|
665
|
+
const getAllParams = {
|
|
666
|
+
...options,
|
|
667
|
+
...this.tenantRepoOptions(req)
|
|
668
|
+
};
|
|
669
|
+
const repoGetAll = async () => this.repository.getAll(getAllParams);
|
|
670
|
+
return hooks && this.resourceName ? await hooks.executeAround(this.resourceName, "list", options, repoGetAll, {
|
|
668
671
|
user: req.user,
|
|
669
672
|
context: this.meta(req)
|
|
670
673
|
}) : await repoGetAll();
|
|
671
|
-
if (Array.isArray(result)) return {
|
|
672
|
-
docs: result,
|
|
673
|
-
page: 1,
|
|
674
|
-
limit: result.length,
|
|
675
|
-
total: result.length,
|
|
676
|
-
pages: 1,
|
|
677
|
-
hasNext: false,
|
|
678
|
-
hasPrev: false
|
|
679
|
-
};
|
|
680
|
-
return result;
|
|
681
674
|
}
|
|
682
675
|
async get(req) {
|
|
683
676
|
const id = req.params.id;
|
|
@@ -686,7 +679,10 @@ var BaseController = class {
|
|
|
686
679
|
error: "ID parameter is required",
|
|
687
680
|
status: 400
|
|
688
681
|
};
|
|
689
|
-
const options =
|
|
682
|
+
const options = {
|
|
683
|
+
...this.queryResolver.resolve(req, this.meta(req)),
|
|
684
|
+
...this.tenantRepoOptions(req)
|
|
685
|
+
};
|
|
690
686
|
const cacheConfig = this.resolveCacheConfig("byId");
|
|
691
687
|
const qc = req.server?.queryCache;
|
|
692
688
|
if (cacheConfig && qc) {
|
|
@@ -782,7 +778,8 @@ var BaseController = class {
|
|
|
782
778
|
}
|
|
783
779
|
const repoCreate = async () => this.repository.create(processedData, {
|
|
784
780
|
user,
|
|
785
|
-
context: arcContext
|
|
781
|
+
context: arcContext,
|
|
782
|
+
...this.tenantRepoOptions(req)
|
|
786
783
|
});
|
|
787
784
|
let item;
|
|
788
785
|
if (hooks && this.resourceName) {
|
|
@@ -814,7 +811,7 @@ var BaseController = class {
|
|
|
814
811
|
const user = req.user;
|
|
815
812
|
const userId = getUserId(user);
|
|
816
813
|
if (userId) data.updatedBy = userId;
|
|
817
|
-
const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
814
|
+
const { doc: existing, reason: updateReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
|
|
818
815
|
if (!existing) return this.notFoundResponse(updateReason);
|
|
819
816
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
820
817
|
success: false,
|
|
@@ -847,7 +844,8 @@ var BaseController = class {
|
|
|
847
844
|
}
|
|
848
845
|
const repoUpdate = async () => this.repository.update(repoId, processedData, {
|
|
849
846
|
user,
|
|
850
|
-
context: arcContext
|
|
847
|
+
context: arcContext,
|
|
848
|
+
...this.tenantRepoOptions(req)
|
|
851
849
|
});
|
|
852
850
|
let item;
|
|
853
851
|
if (hooks && this.resourceName) {
|
|
@@ -885,7 +883,7 @@ var BaseController = class {
|
|
|
885
883
|
};
|
|
886
884
|
const arcContext = this.meta(req);
|
|
887
885
|
const user = req.user;
|
|
888
|
-
const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository);
|
|
886
|
+
const { doc: existing, reason: deleteReason } = await this.accessControl.fetchDetailed(id, req, this.repository, this.tenantRepoOptions(req));
|
|
889
887
|
if (!existing) return this.notFoundResponse(deleteReason);
|
|
890
888
|
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
891
889
|
success: false,
|
|
@@ -916,6 +914,7 @@ var BaseController = class {
|
|
|
916
914
|
const repoDelete = async () => this.repository.delete(repoId, {
|
|
917
915
|
user,
|
|
918
916
|
context: arcContext,
|
|
917
|
+
...this.tenantRepoOptions(req),
|
|
919
918
|
...deleteMode ? { mode: deleteMode } : {}
|
|
920
919
|
});
|
|
921
920
|
let result;
|
|
@@ -951,7 +950,10 @@ var BaseController = class {
|
|
|
951
950
|
async getBySlug(req) {
|
|
952
951
|
const slugField = this._presetFields.slugField ?? "slug";
|
|
953
952
|
const slug = req.params[slugField] ?? req.params.slug;
|
|
954
|
-
const options =
|
|
953
|
+
const options = {
|
|
954
|
+
...this.queryResolver.resolve(req, this.meta(req)),
|
|
955
|
+
...this.tenantRepoOptions(req)
|
|
956
|
+
};
|
|
955
957
|
const repo = this.repository;
|
|
956
958
|
let item = null;
|
|
957
959
|
if (repo.getBySlug) item = await repo.getBySlug(slug, options);
|
|
@@ -981,27 +983,9 @@ var BaseController = class {
|
|
|
981
983
|
status: 501
|
|
982
984
|
};
|
|
983
985
|
const parsed = this.queryResolver.resolve(req, this.meta(req));
|
|
984
|
-
const result = await repo.getDeleted(parsed, parsed);
|
|
985
|
-
if (Array.isArray(result)) {
|
|
986
|
-
const docs = result;
|
|
987
|
-
return {
|
|
988
|
-
success: true,
|
|
989
|
-
data: {
|
|
990
|
-
method: "offset",
|
|
991
|
-
docs,
|
|
992
|
-
page: 1,
|
|
993
|
-
limit: docs.length,
|
|
994
|
-
total: docs.length,
|
|
995
|
-
pages: 1,
|
|
996
|
-
hasNext: false,
|
|
997
|
-
hasPrev: false
|
|
998
|
-
},
|
|
999
|
-
status: 200
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
986
|
return {
|
|
1003
987
|
success: true,
|
|
1004
|
-
data:
|
|
988
|
+
data: await repo.getDeleted(parsed, parsed),
|
|
1005
989
|
status: 200
|
|
1006
990
|
};
|
|
1007
991
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/core/actionPermissions.ts
|
|
2
|
+
/**
|
|
3
|
+
* Return the effective `PermissionCheck` for a single action, or `undefined`
|
|
4
|
+
* when the resource declares no gate at any level.
|
|
5
|
+
*
|
|
6
|
+
* Callers decide what "no gate" means:
|
|
7
|
+
* - HTTP: boot-time throw in `normalizeActionsToRouterConfig`.
|
|
8
|
+
* - MCP: treated as allow (legacy) — but the HTTP fallback now fills the
|
|
9
|
+
* gap when `permissions.update` is set, so the MCP hole closes too.
|
|
10
|
+
* - OpenAPI: docs advertise the endpoint as unauthenticated.
|
|
11
|
+
*/
|
|
12
|
+
function resolveActionPermission(input) {
|
|
13
|
+
const { action, resourcePermissions, resourceActionPermissions, globalAuth } = input;
|
|
14
|
+
const explicit = typeof action !== "function" && action.permissions ? action.permissions : void 0;
|
|
15
|
+
if (explicit) return explicit;
|
|
16
|
+
if (resourceActionPermissions) return resourceActionPermissions;
|
|
17
|
+
if (globalAuth) return globalAuth;
|
|
18
|
+
const updateFallback = resourcePermissions?.update;
|
|
19
|
+
if (updateFallback) return updateFallback;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { resolveActionPermission as t };
|