@classytic/arc 2.28.0 → 2.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BaseController-B65zbz9m.mjs → BaseController-B5DwufbB.mjs} +12 -10
- package/dist/EventTransport-C6YA8xY9.mjs +103 -0
- package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-WkIougo6.mjs} +15 -35
- package/dist/applyPermissionResult-Cf6zUCvb.mjs +257 -0
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/index.d.mts +2 -2
- package/dist/auth/index.mjs +1 -1
- package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-BYS3Vkfz.mjs} +32 -6
- package/dist/cleanup/index.d.mts +2 -2
- package/dist/cleanup/index.mjs +1 -1
- package/dist/cli/commands/describe.d.mts +1 -1
- package/dist/cli/commands/docs.mjs +1 -1
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +5 -4
- package/dist/{core-BGa8TXJR.mjs → core-poalJLvp.mjs} +2 -48
- package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-BxuywVRB.mjs} +2 -2
- package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-BiY-lGdY.mjs} +7 -20
- package/dist/{createApp-Du5G54TM.mjs → createApp-CXrKFciH.mjs} +24 -3
- package/dist/defineAggregation-ufWjUwml.mjs +47 -0
- package/dist/{defineResource-2zvVVpxg.mjs → defineResource-CxWqDCAy.mjs} +35 -42
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +1 -1
- package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-D2NNR4py.mjs} +2 -103
- package/dist/events/index.mjs +4 -339
- package/dist/factory/index.d.mts +4 -4
- package/dist/factory/index.mjs +1 -1
- package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-0WhPnUH7.d.mts} +3 -3
- package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CQ_7KQWw.mjs} +1 -107
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.mjs +1 -1
- package/dist/{index-CZY4EGga.d.mts → index-CCzVR-N3.d.mts} +1 -1
- package/dist/{index-BMnbzvtO.d.mts → index-Ch16aAah.d.mts} +201 -21
- package/dist/{index-CjVg-Epg.d.mts → index-Cx7bro4R.d.mts} +4 -4
- package/dist/{index-DPRH0Cme.d.mts → index-Q5gzEIWY.d.mts} +97 -66
- package/dist/index.d.mts +6 -6
- package/dist/index.mjs +8 -7
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +6 -6
- 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/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-nRzm5g2s.mjs} +36 -43
- package/dist/middleware/index.d.mts +1 -1
- package/dist/migrations/index.d.mts +0 -3
- package/dist/migrations/index.mjs +0 -3
- package/dist/{openapi-BLV4OMXb.mjs → openapi-B8Vkbxbn.mjs} +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +4 -3
- package/dist/{permissions-BrtiBKGs.mjs → permissions-Bfu_8y1U.mjs} +318 -289
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/{pipeline-BGRnV-To.mjs → pipeline-BJnpkY9i.mjs} +4 -18
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +2 -2
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-DCfa2egz.d.mts} +3 -3
- package/dist/presets/filesUpload.d.mts +2 -2
- package/dist/presets/filesUpload.mjs +1 -1
- package/dist/presets/index.d.mts +14 -2
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-DwFDVKP0.mjs → presets-CJOEXLuk.mjs} +25 -9
- package/dist/projection-UBDgfMwu.mjs +17 -0
- package/dist/{realtime-DEuDu9io.mjs → realtime-B9nmIY5V.mjs} +2 -1
- package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-JkxIEBJq.mjs} +3 -1
- package/dist/registry/index.d.mts +2 -2
- package/dist/registry/index.mjs +1 -1
- package/dist/{registry-9km_w1aP.mjs → registry-BYXUUg21.mjs} +1 -1
- package/dist/repositoryFilter-Dz8k3S4P.mjs +44 -0
- package/dist/subscribe-helpers-DfzsivM6.mjs +339 -0
- package/dist/testing/index.d.mts +34 -6
- package/dist/testing/index.mjs +314 -211
- package/dist/types/index.d.mts +3 -3
- package/dist/{types-QFGc3Cjv.d.mts → types-Bmmn-fO0.d.mts} +1 -1
- package/dist/{types-Dmw-6Qeo.d.mts → types-ChvvVNP8.d.mts} +93 -88
- package/dist/{types-Cu6vVJmh.d.mts → types-D_n8L6VY.d.mts} +32 -1
- package/dist/usage/index.d.mts +1 -1
- package/dist/usage/index.mjs +0 -0
- package/dist/utils/index.d.mts +2 -5
- package/package.json +539 -539
|
@@ -4,7 +4,9 @@ import { d as createDomainError, f as createError, i as NotFoundError, p as isAr
|
|
|
4
4
|
import { n as getUserRoles, t as getUserId } from "./userHelpers-Cg_y3fBz.mjs";
|
|
5
5
|
import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CSyCxl2V.mjs";
|
|
6
6
|
import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Cg05HPsn.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { t as toRepositoryFilter } from "./repositoryFilter-Dz8k3S4P.mjs";
|
|
8
|
+
import { o as conjoinPolicyFilters } from "./applyPermissionResult-Cf6zUCvb.mjs";
|
|
9
|
+
import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CQ_7KQWw.mjs";
|
|
8
10
|
import { g as ArcQueryParser, n as scheduleBackground } from "./utils-taUVD4p3.mjs";
|
|
9
11
|
import { t as buildQueryKey } from "./keys-Cd316u_y.mjs";
|
|
10
12
|
//#region src/core/AccessControl.ts
|
|
@@ -38,7 +40,7 @@ var AccessControl = class {
|
|
|
38
40
|
const scope = arcContext?._scope;
|
|
39
41
|
const orgId = scope ? getOrgId(scope) : void 0;
|
|
40
42
|
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
|
|
41
|
-
return filter;
|
|
43
|
+
return toRepositoryFilter(filter);
|
|
42
44
|
}
|
|
43
45
|
/**
|
|
44
46
|
* Check if a post-fetch item matches the request's `_policyFilters`.
|
|
@@ -110,9 +112,9 @@ var AccessControl = class {
|
|
|
110
112
|
checkOwnership(item, req) {
|
|
111
113
|
const ownershipCheck = this._meta(req)?._ownershipCheck;
|
|
112
114
|
if (!item || !ownershipCheck) return true;
|
|
113
|
-
const { field, userId } = ownershipCheck;
|
|
115
|
+
const { field, userId, missingOwner } = ownershipCheck;
|
|
114
116
|
const itemOwnerId = item[field];
|
|
115
|
-
if (!itemOwnerId) return
|
|
117
|
+
if (!itemOwnerId) return missingOwner === "allow";
|
|
116
118
|
return String(itemOwnerId) === String(userId);
|
|
117
119
|
}
|
|
118
120
|
/**
|
|
@@ -670,12 +672,12 @@ var QueryResolver = class {
|
|
|
670
672
|
const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
|
|
671
673
|
const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
|
|
672
674
|
const rawSelect = parsed.select ?? req.query?.select;
|
|
673
|
-
const filters = { ...parsed.filters };
|
|
674
675
|
const policyFilters = arcContext?._policyFilters;
|
|
675
|
-
|
|
676
|
+
let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
|
|
676
677
|
const scope = arcContext?._scope;
|
|
677
678
|
const orgId = scope ? getOrgId(scope) : void 0;
|
|
678
|
-
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField]
|
|
679
|
+
if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters = conjoinPolicyFilters(filters, { [this.tenantField]: orgId });
|
|
680
|
+
const portableFilters = toRepositoryFilter(filters);
|
|
679
681
|
return {
|
|
680
682
|
page,
|
|
681
683
|
limit,
|
|
@@ -684,7 +686,7 @@ var QueryResolver = class {
|
|
|
684
686
|
populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
|
|
685
687
|
populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
|
|
686
688
|
lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
|
|
687
|
-
filters,
|
|
689
|
+
filters: portableFilters,
|
|
688
690
|
search: parsed.search,
|
|
689
691
|
after: parsed.after,
|
|
690
692
|
user: req.user,
|
|
@@ -800,7 +802,7 @@ var BaseCrudController = class {
|
|
|
800
802
|
/**
|
|
801
803
|
* Composable access control (ID filtering, policy checks, org scope, ownership).
|
|
802
804
|
*
|
|
803
|
-
* Not `readonly`
|
|
805
|
+
* Not `readonly` — `configure()` rebuilds it when the host
|
|
804
806
|
* supplies tenant/idField/matchesFilter post-construction. Same model as
|
|
805
807
|
* `queryResolver` after `setQueryParser` shipped in 2.10.9.
|
|
806
808
|
*/
|
|
@@ -808,7 +810,7 @@ var BaseCrudController = class {
|
|
|
808
810
|
/**
|
|
809
811
|
* Composable body sanitization (field permissions, system fields).
|
|
810
812
|
*
|
|
811
|
-
* Not `readonly`
|
|
813
|
+
* Not `readonly` — `configure()` rebuilds it when the host
|
|
812
814
|
* supplies schemaOptions/onFieldWriteDenied post-construction.
|
|
813
815
|
*/
|
|
814
816
|
bodySanitizer;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createEvent, matchEventPattern } from "@classytic/primitives/events";
|
|
2
|
+
//#region src/events/EventTransport.ts
|
|
3
|
+
/**
|
|
4
|
+
* Internal arc events module — concrete in-memory transport.
|
|
5
|
+
*
|
|
6
|
+
* **Public type contracts moved out (arc 2.12).** `EventMeta`, `DomainEvent`,
|
|
7
|
+
* `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`,
|
|
8
|
+
* `PublishManyResult`, plus the `createEvent` / `createChildEvent` /
|
|
9
|
+
* `matchEventPattern` helpers, are now owned by `@classytic/primitives/events`.
|
|
10
|
+
* Two reasons:
|
|
11
|
+
*
|
|
12
|
+
* 1. The shapes ARE the cross-package contract. arc, arc-next, mongokit's
|
|
13
|
+
* audit-log plugin, future kits and downstream services all share them.
|
|
14
|
+
* Owning them in `primitives` (pure types, zero runtime, zero deps)
|
|
15
|
+
* eliminates the "manually mirror in two places" problem the previous
|
|
16
|
+
* `primitives/src/events.ts` header explicitly called out.
|
|
17
|
+
* 2. Inverting the dependency direction lets future packages (sqlitekit
|
|
18
|
+
* audit, billing services) consume the contract without depending on
|
|
19
|
+
* arc's HTTP-coupled stack.
|
|
20
|
+
*
|
|
21
|
+
* Hosts MUST import the types from primitives directly:
|
|
22
|
+
*
|
|
23
|
+
* ```typescript
|
|
24
|
+
* import type {
|
|
25
|
+
* EventMeta, DomainEvent, EventHandler, EventTransport,
|
|
26
|
+
* DeadLetteredEvent, PublishManyResult,
|
|
27
|
+
* } from '@classytic/primitives/events';
|
|
28
|
+
* import { createEvent, createChildEvent } from '@classytic/primitives/events';
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* arc's `events/index.ts` barrel does NOT re-export these — by design, so
|
|
32
|
+
* that consumer code is forced into the canonical import path and the org's
|
|
33
|
+
* "no re-exports" rule holds at every public surface.
|
|
34
|
+
*
|
|
35
|
+
* What stays here:
|
|
36
|
+
* - `MemoryEventTransport` — the in-memory `EventTransport` implementation
|
|
37
|
+
* used as arc's default transport. Its handler-set state is process-local;
|
|
38
|
+
* wrong layer for primitives.
|
|
39
|
+
* - `MemoryEventTransportOptions` — its options shape.
|
|
40
|
+
*
|
|
41
|
+
* Inside arc, files that previously imported types from `./EventTransport.js`
|
|
42
|
+
* keep working: this file re-exports them from primitives for arc's own
|
|
43
|
+
* call sites. That re-export is an internal refactor convenience — the
|
|
44
|
+
* public `events/index.ts` barrel stays clean.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* In-memory event transport (default).
|
|
48
|
+
*
|
|
49
|
+
* Events are delivered synchronously within the process. Not suitable for
|
|
50
|
+
* multi-instance deployments — pair with `RedisEventTransport` /
|
|
51
|
+
* `RedisStreamTransport` (subpath imports) for those.
|
|
52
|
+
*/
|
|
53
|
+
var MemoryEventTransport = class {
|
|
54
|
+
name = "memory";
|
|
55
|
+
handlers = /* @__PURE__ */ new Map();
|
|
56
|
+
logger;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.logger = options?.logger ?? console;
|
|
59
|
+
}
|
|
60
|
+
async publish(event) {
|
|
61
|
+
const allHandlers = /* @__PURE__ */ new Set();
|
|
62
|
+
for (const [pattern, handlers] of this.handlers) if (matchEventPattern(pattern, event.type)) for (const h of handlers) allHandlers.add(h);
|
|
63
|
+
for (const handler of allHandlers) try {
|
|
64
|
+
await handler(event);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
this.logger.error(`[EventTransport] Handler error for ${event.type}:`, err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reference `publishMany` implementation — delegates to `publish()` in order.
|
|
71
|
+
*
|
|
72
|
+
* Production transports (Kafka, Redis pipeline, SQS batch) should override
|
|
73
|
+
* this with a single batched network call. Memory transport has nothing to
|
|
74
|
+
* batch, so we just loop — the loop still returns a proper result map so
|
|
75
|
+
* `EventOutbox.relay` can exercise the batched code path in tests.
|
|
76
|
+
*/
|
|
77
|
+
async publishMany(events) {
|
|
78
|
+
const results = /* @__PURE__ */ new Map();
|
|
79
|
+
for (const event of events) try {
|
|
80
|
+
await this.publish(event);
|
|
81
|
+
results.set(event.meta.id, null);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
results.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
async subscribe(pattern, handler) {
|
|
88
|
+
if (!this.handlers.has(pattern)) this.handlers.set(pattern, /* @__PURE__ */ new Set());
|
|
89
|
+
this.handlers.get(pattern)?.add(handler);
|
|
90
|
+
return () => {
|
|
91
|
+
const set = this.handlers.get(pattern);
|
|
92
|
+
if (set) {
|
|
93
|
+
set.delete(handler);
|
|
94
|
+
if (set.size === 0) this.handlers.delete(pattern);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async close() {
|
|
99
|
+
this.handlers.clear();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
//#endregion
|
|
103
|
+
export { createEvent as n, MemoryEventTransport as t };
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
import { t as buildRequestScopeProjection } from "./projection-UBDgfMwu.mjs";
|
|
1
2
|
//#region src/hooks/HookSystem.ts
|
|
3
|
+
/**
|
|
4
|
+
* Hook System
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle hooks for resource operations.
|
|
7
|
+
* Allows intercepting and modifying data at various points.
|
|
8
|
+
*/
|
|
9
|
+
/** Lift the validated scope out of the request context (undefined when unscoped). */
|
|
10
|
+
function scopeOf(context) {
|
|
11
|
+
return buildRequestScopeProjection(context?._scope);
|
|
12
|
+
}
|
|
2
13
|
var HookSystem = class {
|
|
3
14
|
hooks;
|
|
4
15
|
logger;
|
|
@@ -9,16 +20,10 @@ var HookSystem = class {
|
|
|
9
20
|
this.logger = options?.logger ?? { error: noop };
|
|
10
21
|
this.warn = options?.logger?.warn ?? noop;
|
|
11
22
|
}
|
|
12
|
-
/**
|
|
13
|
-
* Generate hook key
|
|
14
|
-
*/
|
|
15
23
|
getKey(resource, operation, phase) {
|
|
16
24
|
return `${resource}:${operation}:${phase}`;
|
|
17
25
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Register a hook
|
|
20
|
-
* Supports both object parameter and positional arguments
|
|
21
|
-
*/
|
|
26
|
+
/** Supports both the object-parameter and positional argument forms. */
|
|
22
27
|
register(resourceOrOptions, operation, phase, handler, priority = 10) {
|
|
23
28
|
let hookName;
|
|
24
29
|
let resource;
|
|
@@ -63,15 +68,9 @@ var HookSystem = class {
|
|
|
63
68
|
if (idx !== -1) hooks.splice(idx, 1);
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Register before hook
|
|
68
|
-
*/
|
|
69
71
|
before(resource, operation, handler, priority = 10) {
|
|
70
72
|
return this.register(resource, operation, "before", handler, priority);
|
|
71
73
|
}
|
|
72
|
-
/**
|
|
73
|
-
* Register after hook
|
|
74
|
-
*/
|
|
75
74
|
after(resource, operation, handler, priority = 10) {
|
|
76
75
|
return this.register(resource, operation, "after", handler, priority);
|
|
77
76
|
}
|
|
@@ -105,6 +104,7 @@ var HookSystem = class {
|
|
|
105
104
|
data,
|
|
106
105
|
user: options?.user,
|
|
107
106
|
context: options?.context,
|
|
107
|
+
scope: scopeOf(options?.context),
|
|
108
108
|
meta: options?.meta
|
|
109
109
|
};
|
|
110
110
|
return hook.handler(ctx, next);
|
|
@@ -113,9 +113,6 @@ var HookSystem = class {
|
|
|
113
113
|
};
|
|
114
114
|
return next();
|
|
115
115
|
}
|
|
116
|
-
/**
|
|
117
|
-
* Execute hooks for a given context
|
|
118
|
-
*/
|
|
119
116
|
async execute(ctx) {
|
|
120
117
|
const key = this.getKey(ctx.resource, ctx.operation, ctx.phase);
|
|
121
118
|
const hooks = this.hooks.get(key) ?? [];
|
|
@@ -133,6 +130,7 @@ var HookSystem = class {
|
|
|
133
130
|
result: ctx.result,
|
|
134
131
|
user: ctx.user,
|
|
135
132
|
context: ctx.context,
|
|
133
|
+
scope: ctx.scope ?? scopeOf(ctx.context),
|
|
136
134
|
meta: ctx.meta
|
|
137
135
|
};
|
|
138
136
|
const hookResult = await hook.handler(handlerContext);
|
|
@@ -140,9 +138,6 @@ var HookSystem = class {
|
|
|
140
138
|
}
|
|
141
139
|
return result;
|
|
142
140
|
}
|
|
143
|
-
/**
|
|
144
|
-
* Execute before hooks
|
|
145
|
-
*/
|
|
146
141
|
async executeBefore(resource, operation, data, options) {
|
|
147
142
|
return await this.execute({
|
|
148
143
|
resource,
|
|
@@ -154,10 +149,7 @@ var HookSystem = class {
|
|
|
154
149
|
meta: options?.meta
|
|
155
150
|
}) ?? data;
|
|
156
151
|
}
|
|
157
|
-
/**
|
|
158
|
-
* Execute after hooks
|
|
159
|
-
* Errors in after hooks are logged but don't fail the request
|
|
160
|
-
*/
|
|
152
|
+
/** Errors in after hooks are logged but never fail the request. */
|
|
161
153
|
async executeAfter(resource, operation, result, options) {
|
|
162
154
|
try {
|
|
163
155
|
await this.execute({
|
|
@@ -226,17 +218,11 @@ var HookSystem = class {
|
|
|
226
218
|
}
|
|
227
219
|
return result;
|
|
228
220
|
}
|
|
229
|
-
/**
|
|
230
|
-
* Get all registered hooks
|
|
231
|
-
*/
|
|
232
221
|
getAll() {
|
|
233
222
|
const all = [];
|
|
234
223
|
for (const hooks of this.hooks.values()) all.push(...hooks);
|
|
235
224
|
return all;
|
|
236
225
|
}
|
|
237
|
-
/**
|
|
238
|
-
* Get hooks for a specific resource
|
|
239
|
-
*/
|
|
240
226
|
getForResource(resource) {
|
|
241
227
|
const all = [];
|
|
242
228
|
for (const [key, hooks] of this.hooks.entries()) if (key.startsWith(`${resource}:`)) all.push(...hooks);
|
|
@@ -298,15 +284,9 @@ var HookSystem = class {
|
|
|
298
284
|
const key = this.getKey(resource, operation, phase);
|
|
299
285
|
return (this.hooks.get(key)?.length ?? 0) > 0;
|
|
300
286
|
}
|
|
301
|
-
/**
|
|
302
|
-
* Clear all hooks
|
|
303
|
-
*/
|
|
304
287
|
clear() {
|
|
305
288
|
this.hooks.clear();
|
|
306
289
|
}
|
|
307
|
-
/**
|
|
308
|
-
* Clear hooks for a specific resource
|
|
309
|
-
*/
|
|
310
290
|
clearResource(resource) {
|
|
311
291
|
for (const key of this.hooks.keys()) if (key.startsWith(`${resource}:`)) this.hooks.delete(key);
|
|
312
292
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { arcLog } from "./logger/index.mjs";
|
|
2
|
+
import { t as ArcError } from "./errors-DkO8Nk6-.mjs";
|
|
3
|
+
//#region src/permissions/filter-merge.ts
|
|
4
|
+
/**
|
|
5
|
+
* Policy-filter composition — logical AND, never last-writer-wins.
|
|
6
|
+
*
|
|
7
|
+
* Row-level policy filters can arrive from several INDEPENDENT sources for one
|
|
8
|
+
* request: a tenant preset, an `ownedByUser` grant, an `allOf(...)` branch, a
|
|
9
|
+
* host permission check. Every one of them is a RESTRICTION the row must
|
|
10
|
+
* satisfy, so composing them is logical AND.
|
|
11
|
+
*
|
|
12
|
+
* The historical `{ ...base, ...incoming }` shallow spread did NOT implement AND:
|
|
13
|
+
* when two sources constrained the SAME key with different values, the later one
|
|
14
|
+
* silently REPLACED the earlier one — widening (or erasing) a restriction another
|
|
15
|
+
* layer deliberately imposed. That is a defense-in-depth defect, not a
|
|
16
|
+
* refinement. "Later can refine" is only sound when the refinement is provably
|
|
17
|
+
* narrower, and a bare overwrite proves nothing.
|
|
18
|
+
*
|
|
19
|
+
* {@link conjoinPolicyFilters} composes with AND semantics while preserving the
|
|
20
|
+
* common case byte-for-byte:
|
|
21
|
+
*
|
|
22
|
+
* - key present on only one side → carried through flat. Non-overlapping
|
|
23
|
+
* filters (the ~99% case) produce exactly the same object the old spread did,
|
|
24
|
+
* so there is ZERO behavior change for them.
|
|
25
|
+
* - same key, deep-equal value → kept once (idempotent — e.g. two checks
|
|
26
|
+
* both pinning the same `organizationId`).
|
|
27
|
+
* - same key, DIFFERENT value → BOTH constraints are preserved under an
|
|
28
|
+
* `$and`, so a row must satisfy both. For flat equality that conjunction is
|
|
29
|
+
* unsatisfiable, so the query returns nothing — the safe, correct outcome for
|
|
30
|
+
* contradictory policies (deny), and the earlier restriction is never dropped.
|
|
31
|
+
*
|
|
32
|
+
* Adapter safety: mongokit / sqlitekit compile `$and` natively at the DB layer,
|
|
33
|
+
* and arc's in-memory `simpleEqualityMatcher` (AccessControl.checkPolicyFilters)
|
|
34
|
+
* already fail-closes on operator-shaped filters. So a produced `$and` is either
|
|
35
|
+
* evaluated correctly by the DB or rejected fail-closed in memory — both safe.
|
|
36
|
+
*
|
|
37
|
+
* This is the pragmatic, adapter-neutral form of the AND semantics. A canonical
|
|
38
|
+
* filter IR in `repo-core` (nested and/or/not nodes each adapter compiles) is the
|
|
39
|
+
* fuller design, but this closes the security defect without one.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Validate + return a `$and` operand's fragment array. A security-policy
|
|
43
|
+
* normalizer must NEVER silently drop a malformed constraint — a non-array
|
|
44
|
+
* `$and` (e.g. `{ $and: { organizationId: "a" } }`) is a policy bug, so fail
|
|
45
|
+
* loud (fail-closed) rather than discard the restriction it was meant to carry.
|
|
46
|
+
*/
|
|
47
|
+
function coercePolicyAnd(value) {
|
|
48
|
+
if (!Array.isArray(value)) throw new Error(`conjoinPolicyFilters: malformed '$and' — expected an array of filter fragments, got ${value === null ? "null" : typeof value}. A policy filter constraint must not be silently dropped.`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
/** Are two flat policy-filter values equivalent for conjunction purposes? */
|
|
52
|
+
function filterValueEqual(a, b) {
|
|
53
|
+
if (Object.is(a, b)) return true;
|
|
54
|
+
if (a !== null && b !== null && typeof a === "object" && typeof b === "object") try {
|
|
55
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Conjoin two policy-filter maps with AND semantics. Never silently overwrites a
|
|
63
|
+
* same-key constraint with a different value — conflicting keys are preserved
|
|
64
|
+
* under `$and`. See the module doc for the full contract.
|
|
65
|
+
*/
|
|
66
|
+
function conjoinPolicyFilters(base, incoming) {
|
|
67
|
+
const baseEmpty = !base || Object.keys(base).length === 0;
|
|
68
|
+
const incomingEmpty = !incoming || Object.keys(incoming).length === 0;
|
|
69
|
+
if (baseEmpty) return incomingEmpty ? {} : { ...incoming };
|
|
70
|
+
if (incomingEmpty) return { ...base };
|
|
71
|
+
const result = {};
|
|
72
|
+
const andParts = [];
|
|
73
|
+
const baseAnd = base.$and;
|
|
74
|
+
if (baseAnd !== void 0) andParts.push(...coercePolicyAnd(baseAnd));
|
|
75
|
+
for (const [key, value] of Object.entries(base)) {
|
|
76
|
+
if (key === "$and") continue;
|
|
77
|
+
result[key] = value;
|
|
78
|
+
}
|
|
79
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
80
|
+
if (key === "$and") {
|
|
81
|
+
andParts.push(...coercePolicyAnd(value));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!(key in result)) {
|
|
85
|
+
result[key] = value;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (filterValueEqual(result[key], value)) continue;
|
|
89
|
+
andParts.push({ [key]: result[key] }, { [key]: value });
|
|
90
|
+
delete result[key];
|
|
91
|
+
}
|
|
92
|
+
if (andParts.length > 0) result.$and = andParts;
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/permissions/applyPermissionResult.ts
|
|
97
|
+
const log = arcLog("permissions:enforce");
|
|
98
|
+
/**
|
|
99
|
+
* Normalize a check return into the canonical {@link AuthorizationDecision}.
|
|
100
|
+
* A boolean promotes to `{ effect: "allow" | "deny" }`; a decision passes
|
|
101
|
+
* through. This is the ONE place booleans are promoted, so the rest of the
|
|
102
|
+
* pipeline only ever handles decisions.
|
|
103
|
+
*/
|
|
104
|
+
function normalizeToDecision(result) {
|
|
105
|
+
return typeof result === "boolean" ? { effect: result ? "allow" : "deny" } : result;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run a permission check once and classify the result. The single source of the
|
|
109
|
+
* try/normalize/catch sequence — see {@link PermissionOutcome}. Non-`ArcError`
|
|
110
|
+
* throws are logged once here through arc's namespaced logger (a deliberate
|
|
111
|
+
* `ArcError` denial is expected control-flow, not a warning). Pure w.r.t. the
|
|
112
|
+
* request — neither reads a reply nor mutates the request.
|
|
113
|
+
*/
|
|
114
|
+
async function evaluatePermissionOutcome(check, context) {
|
|
115
|
+
try {
|
|
116
|
+
return {
|
|
117
|
+
status: "decided",
|
|
118
|
+
decision: normalizeToDecision(await check(context))
|
|
119
|
+
};
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (err instanceof ArcError) return {
|
|
122
|
+
status: "structured",
|
|
123
|
+
error: err
|
|
124
|
+
};
|
|
125
|
+
log.warn({
|
|
126
|
+
err,
|
|
127
|
+
resource: context.resource,
|
|
128
|
+
action: context.action
|
|
129
|
+
}, "Permission check threw — denying (fail-closed)");
|
|
130
|
+
return {
|
|
131
|
+
status: "failed",
|
|
132
|
+
error: err
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Run a permission check and return a canonical {@link AuthorizationDecision} —
|
|
138
|
+
* the transport-neutral PDP (HTTP, aggregation, MCP, and any future transport).
|
|
139
|
+
* A thin mapper over {@link evaluatePermissionOutcome}:
|
|
140
|
+
*
|
|
141
|
+
* - a structured `ArcError` is RE-THROWN so the transport surfaces it verbatim
|
|
142
|
+
* (e.g. a tier gate throwing `arc.tier_required` with machine-readable meta);
|
|
143
|
+
* - any OTHER throw is fail-closed to a `deny` decision (a broken check must
|
|
144
|
+
* never fall open to a 500 that skips authorization).
|
|
145
|
+
*
|
|
146
|
+
* Pure w.r.t. the request — it neither reads a reply nor mutates the request.
|
|
147
|
+
* Callers translate the returned decision into their own response mechanism and
|
|
148
|
+
* apply side-effects via {@link applyAuthorizationDecision}. HTTP additionally
|
|
149
|
+
* hardens a *thrown* check to 403-even-when-anonymous — that transport-specific
|
|
150
|
+
* nuance lives in {@link evaluateAndApplyPermission}, not here.
|
|
151
|
+
*/
|
|
152
|
+
async function evaluatePermissionDecision(check, context) {
|
|
153
|
+
const outcome = await evaluatePermissionOutcome(check, context);
|
|
154
|
+
switch (outcome.status) {
|
|
155
|
+
case "decided": return outcome.decision;
|
|
156
|
+
case "structured": throw outcome.error;
|
|
157
|
+
case "failed": return { effect: "deny" };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Apply an allowed {@link AuthorizationDecision} to a Fastify request — conjoins
|
|
162
|
+
* the decision's row-level `policy` into `_policyFilters` and conditionally
|
|
163
|
+
* installs the `scope`.
|
|
164
|
+
*
|
|
165
|
+
* **Scope install rule:** only writes `scope` when the current request scope
|
|
166
|
+
* is absent or `public`. This prevents downgrading an already-authenticated
|
|
167
|
+
* request (e.g. Better Auth set `member`, then a permission check returns a
|
|
168
|
+
* narrower `service` scope — the original `member` wins because it came from
|
|
169
|
+
* a more authoritative source).
|
|
170
|
+
*
|
|
171
|
+
* Safe to call with a denied decision — it simply no-ops. Callers should still
|
|
172
|
+
* check `decision.effect` and send an error response before reaching here, but
|
|
173
|
+
* this function tolerates the misuse defensively.
|
|
174
|
+
*
|
|
175
|
+
* This is the enforcement point (PEP) — the ONE place scope + data policy are
|
|
176
|
+
* installed (and the obligation seam lives).
|
|
177
|
+
*/
|
|
178
|
+
function applyAuthorizationDecision(decision, request) {
|
|
179
|
+
if (decision.effect !== "allow") return;
|
|
180
|
+
if (decision.policy) request._policyFilters = conjoinPolicyFilters(request._policyFilters, decision.policy);
|
|
181
|
+
if (decision.scope) {
|
|
182
|
+
const current = request.scope;
|
|
183
|
+
if (!current || current.kind === "public") request.scope = decision.scope;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* @deprecated Renamed to {@link applyAuthorizationDecision} — the contract is an
|
|
188
|
+
* `AuthorizationDecision`, not a `PermissionResult`. This alias is kept for
|
|
189
|
+
* back-compat and will be removed in a future major.
|
|
190
|
+
*/
|
|
191
|
+
const applyPermissionResult = applyAuthorizationDecision;
|
|
192
|
+
/**
|
|
193
|
+
* Max length of a decision `reason` string before we fall back to the
|
|
194
|
+
* generic default message. Upstream checks can return arbitrary strings; we
|
|
195
|
+
* clamp to prevent accidental leakage of internal diagnostics or oversized
|
|
196
|
+
* payloads via the 4xx response body.
|
|
197
|
+
*/
|
|
198
|
+
const MAX_DENIAL_REASON_LENGTH = 100;
|
|
199
|
+
/**
|
|
200
|
+
* End-to-end HTTP PEP: evaluates the check through the shared
|
|
201
|
+
* {@link evaluatePermissionOutcome} core, sends a 401/403 response on denial,
|
|
202
|
+
* and applies side-effects on grant. Returns `true` if the caller should
|
|
203
|
+
* continue, `false` if a response has been sent and the caller should return.
|
|
204
|
+
*
|
|
205
|
+
* The HTTP specialization of the ONE evaluation core — it does NOT re-run or
|
|
206
|
+
* re-normalize the check; it only maps the classified outcome onto Fastify:
|
|
207
|
+
*
|
|
208
|
+
* - `structured` (thrown `ArcError`) → surface verbatim, byte-compatible with
|
|
209
|
+
* the global error handler (`code`/`message`/`status` + `meta`);
|
|
210
|
+
* - `failed` (other throw) → 403 EVEN WHEN ANONYMOUS (a broken check is a hard
|
|
211
|
+
* denial, not a "please authenticate");
|
|
212
|
+
* - `decided` → allow, or a returned deny → 401 (no user) / 403 (user) with a
|
|
213
|
+
* clamped `reason`.
|
|
214
|
+
*
|
|
215
|
+
* Context construction, pre-check auth gating, and success-path handler
|
|
216
|
+
* invocation stay at the callsite — those are genuinely different per router
|
|
217
|
+
* and don't belong here.
|
|
218
|
+
*
|
|
219
|
+
* @returns `true` if authorized (caller continues), `false` if a response was sent
|
|
220
|
+
*/
|
|
221
|
+
async function evaluateAndApplyPermission(check, context, request, reply, opts) {
|
|
222
|
+
const outcome = await evaluatePermissionOutcome(check, context);
|
|
223
|
+
if (outcome.status === "structured") {
|
|
224
|
+
const err = outcome.error;
|
|
225
|
+
reply.code(err.status).send({
|
|
226
|
+
code: err.code,
|
|
227
|
+
message: err.message,
|
|
228
|
+
status: err.status,
|
|
229
|
+
...err.meta ? { meta: err.meta } : {}
|
|
230
|
+
});
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (outcome.status === "failed") {
|
|
234
|
+
reply.code(403).send({
|
|
235
|
+
code: "arc.forbidden",
|
|
236
|
+
message: "Permission denied",
|
|
237
|
+
status: 403
|
|
238
|
+
});
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
const { decision } = outcome;
|
|
242
|
+
if (decision.effect !== "allow") {
|
|
243
|
+
const defaultMsg = opts?.defaultDenialMessage?.(context.user) ?? (context.user ? "Permission denied" : "Authentication required");
|
|
244
|
+
const reason = decision.reason && decision.reason.length <= MAX_DENIAL_REASON_LENGTH ? decision.reason : defaultMsg;
|
|
245
|
+
const status = context.user ? 403 : 401;
|
|
246
|
+
reply.code(status).send({
|
|
247
|
+
code: context.user ? "arc.forbidden" : "arc.unauthorized",
|
|
248
|
+
message: reason,
|
|
249
|
+
status
|
|
250
|
+
});
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
applyAuthorizationDecision(decision, request);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
export { normalizeToDecision as a, evaluatePermissionDecision as i, applyPermissionResult as n, conjoinPolicyFilters as o, evaluateAndApplyPermission as r, applyAuthorizationDecision as t };
|
package/dist/audit/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import "../index-
|
|
2
|
-
import {
|
|
1
|
+
import "../index-Q5gzEIWY.mjs";
|
|
2
|
+
import { a as UserBase } from "../types-ChvvVNP8.mjs";
|
|
3
3
|
import { FastifyPluginAsync } from "fastify";
|
|
4
4
|
import { RepositoryLike } from "@classytic/repo-core/adapter";
|
|
5
5
|
//#region src/audit/stores/interface.d.ts
|
package/dist/audit/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { t as transactionContext } from "../transactionContext-PNdoyLLm.mjs";
|
|
2
|
-
import fp from "fastify-plugin";
|
|
3
2
|
import { and, anyOf, eq, gte, lt, lte } from "@classytic/repo-core/filter";
|
|
3
|
+
import fp from "fastify-plugin";
|
|
4
4
|
//#region src/audit/repository-audit-adapter.ts
|
|
5
5
|
function repositoryAsAuditStore(repository, options = {}) {
|
|
6
6
|
const idField = repository.idField ?? "_id";
|
package/dist/auth/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Ht as AuthHelpers, Ut as AuthPluginOptions } from "../index-
|
|
2
|
-
import {
|
|
1
|
+
import { Ht as AuthHelpers, Ut as AuthPluginOptions } from "../index-Q5gzEIWY.mjs";
|
|
2
|
+
import { r as PermissionCheck } from "../types-ChvvVNP8.mjs";
|
|
3
3
|
import { t as ExternalOpenApiPaths } from "../externalPaths-BE6wFpYv.mjs";
|
|
4
4
|
import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-BHOM3N_0.mjs";
|
|
5
5
|
import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest } from "fastify";
|
package/dist/auth/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { d as createDomainError, l as UnauthorizedError, p as isArcError, t as A
|
|
|
2
2
|
import { n as requireSingleHeaderValue } from "../headers-BvlQSDMB.mjs";
|
|
3
3
|
import { n as getUserRoles, r as normalizeRoles } from "../userHelpers-Cg_y3fBz.mjs";
|
|
4
4
|
import { o as toJsonSchema } from "../schemaConverter-DJvodiHj.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { S as requireOrgRole, T as requireTeamMembership, x as requireOrgMembership } from "../permissions-Bfu_8y1U.mjs";
|
|
6
6
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import fp from "fastify-plugin";
|
|
8
8
|
//#region src/auth/authPlugin.ts
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { t as toRepositoryFilter } from "./repositoryFilter-Dz8k3S4P.mjs";
|
|
2
|
+
import { o as conjoinPolicyFilters } from "./applyPermissionResult-Cf6zUCvb.mjs";
|
|
1
3
|
import { i as compileAggRequest, t as adapterSupportsAggregate } from "./validate-Bk44F2yT.mjs";
|
|
2
4
|
//#region src/core/aggregation/buildHandler.ts
|
|
3
5
|
/**
|
|
@@ -18,13 +20,13 @@ async function executeAggregation(normalized, deps, ctx) {
|
|
|
18
20
|
const { repo } = deps;
|
|
19
21
|
const config = normalized.base;
|
|
20
22
|
const aggregationName = normalized.name;
|
|
21
|
-
const { query, tenantOptions } = ctx;
|
|
23
|
+
const { query, tenantOptions, policyFilter } = ctx;
|
|
22
24
|
const guardError = checkRequestGuards(query, config);
|
|
23
25
|
if (guardError) return {
|
|
24
26
|
status: 400,
|
|
25
27
|
body: guardError
|
|
26
28
|
};
|
|
27
|
-
const aggReq = compileAggRequest(normalized, extractCallerFilter(query), tenantOptions, query);
|
|
29
|
+
const aggReq = compileAggRequest(normalized, conjoinPolicyFilters(extractCallerFilter(query), policyFilter), tenantOptions, query);
|
|
28
30
|
if (config.materialized) {
|
|
29
31
|
const matCtx = {
|
|
30
32
|
filter: aggReq.filter,
|
|
@@ -51,9 +53,13 @@ async function executeAggregation(normalized, deps, ctx) {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
};
|
|
56
|
+
const aggReqForRepo = aggReq.filter && typeof aggReq.filter === "object" ? {
|
|
57
|
+
...aggReq,
|
|
58
|
+
filter: toRepositoryFilter(aggReq.filter)
|
|
59
|
+
} : aggReq;
|
|
54
60
|
let result;
|
|
55
61
|
try {
|
|
56
|
-
result = await repo.aggregate(
|
|
62
|
+
result = await repo.aggregate(aggReqForRepo, tenantOptions);
|
|
57
63
|
} catch (err) {
|
|
58
64
|
return mapAggregateError(err, aggregationName);
|
|
59
65
|
}
|
|
@@ -95,9 +101,13 @@ async function executeAggregation(normalized, deps, ctx) {
|
|
|
95
101
|
function buildAggregationHandler(normalized, deps) {
|
|
96
102
|
const { buildOptions } = deps;
|
|
97
103
|
return async (request, reply) => {
|
|
104
|
+
const query = request.query ?? {};
|
|
105
|
+
const tenantOptions = buildOptions(request);
|
|
106
|
+
const policyFilter = request._policyFilters;
|
|
98
107
|
const result = await executeAggregation(normalized, deps, {
|
|
99
|
-
query
|
|
100
|
-
tenantOptions
|
|
108
|
+
query,
|
|
109
|
+
tenantOptions,
|
|
110
|
+
policyFilter
|
|
101
111
|
});
|
|
102
112
|
reply.status(result.status);
|
|
103
113
|
if (result.headers) for (const [k, v] of Object.entries(result.headers)) reply.header(k, v);
|
|
@@ -199,6 +209,20 @@ const SHORTHAND_RANGE_OPS = /* @__PURE__ */ new Set([
|
|
|
199
209
|
"lt",
|
|
200
210
|
"lte"
|
|
201
211
|
]);
|
|
212
|
+
/**
|
|
213
|
+
* Operators whose operand MUST be a list. A URL carries `priority[in]=high,urgent`
|
|
214
|
+
* as ONE string, and `$in` against a non-array is invalid — MongoDB rejects it,
|
|
215
|
+
* and the portable Filter IR would spread the string into characters. Split it
|
|
216
|
+
* the same way `ArcQueryParser` does for the CRUD list route, which is the
|
|
217
|
+
* contract this expansion claims to match.
|
|
218
|
+
*/
|
|
219
|
+
const SHORTHAND_LIST_OPS = /* @__PURE__ */ new Set(["in", "nin"]);
|
|
220
|
+
/** Normalize a list-operator operand to an array (URL string → split on commas). */
|
|
221
|
+
function toOperandList(value) {
|
|
222
|
+
if (Array.isArray(value)) return value;
|
|
223
|
+
if (typeof value === "string") return value.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
|
|
224
|
+
return [value];
|
|
225
|
+
}
|
|
202
226
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
203
227
|
function tryCoerceDate(v) {
|
|
204
228
|
if (typeof v !== "string" || !ISO_DATE_RE.test(v)) return v;
|
|
@@ -222,7 +246,9 @@ function expandShorthandOperators(value) {
|
|
|
222
246
|
for (const [op, opVal] of Object.entries(nested)) {
|
|
223
247
|
const mongoOp = OPERATOR_SHORTHAND[op];
|
|
224
248
|
if (!mongoOp) continue;
|
|
225
|
-
|
|
249
|
+
if (SHORTHAND_RANGE_OPS.has(op)) expanded[mongoOp] = tryCoerceDate(opVal);
|
|
250
|
+
else if (SHORTHAND_LIST_OPS.has(op)) expanded[mongoOp] = toOperandList(opVal);
|
|
251
|
+
else expanded[mongoOp] = opVal;
|
|
226
252
|
}
|
|
227
253
|
return expanded;
|
|
228
254
|
}
|