@objectstack/plugin-approvals 17.1.0 → 17.2.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/CHANGELOG.md +397 -0
- package/dist/index.d.mts +365 -3
- package/dist/index.d.ts +365 -3
- package/dist/index.js +525 -17
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +525 -17
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -12
package/dist/index.js
CHANGED
|
@@ -2235,6 +2235,38 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
|
2235
2235
|
return userIds.filter((u) => canRead.has(u));
|
|
2236
2236
|
}
|
|
2237
2237
|
|
|
2238
|
+
// src/payload-redaction.ts
|
|
2239
|
+
function redactSnapshot(payload, readable) {
|
|
2240
|
+
if (readable === void 0) return { payload, redactedKeys: [] };
|
|
2241
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2242
|
+
return { payload, redactedKeys: [] };
|
|
2243
|
+
}
|
|
2244
|
+
const allowed = new Set(readable.map((f) => String(f)));
|
|
2245
|
+
const source = payload;
|
|
2246
|
+
const redactedKeys = [];
|
|
2247
|
+
const kept = {};
|
|
2248
|
+
for (const key of Object.keys(source)) {
|
|
2249
|
+
if (allowed.has(key)) kept[key] = source[key];
|
|
2250
|
+
else redactedKeys.push(key);
|
|
2251
|
+
}
|
|
2252
|
+
if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
|
|
2253
|
+
return { payload: kept, redactedKeys: redactedKeys.sort() };
|
|
2254
|
+
}
|
|
2255
|
+
async function resolveReadableSnapshotFields(security, objectName, context, logger) {
|
|
2256
|
+
if (!security || typeof security.getReadableFields !== "function") return void 0;
|
|
2257
|
+
const object = String(objectName ?? "").trim();
|
|
2258
|
+
if (!object) return void 0;
|
|
2259
|
+
try {
|
|
2260
|
+
return await security.getReadableFields(object, context);
|
|
2261
|
+
} catch (err) {
|
|
2262
|
+
logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
|
|
2263
|
+
object,
|
|
2264
|
+
error: err?.message ?? String(err)
|
|
2265
|
+
});
|
|
2266
|
+
return void 0;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2238
2270
|
// src/approval-service.ts
|
|
2239
2271
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
2240
2272
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
@@ -2255,6 +2287,7 @@ function actingUserId(context) {
|
|
|
2255
2287
|
return typeof userId === "string" && userId ? userId : null;
|
|
2256
2288
|
}
|
|
2257
2289
|
var OOO_MAX_CHAIN = 8;
|
|
2290
|
+
var MEMBER_SCREEN_READ_LIMIT = 5e4;
|
|
2258
2291
|
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2259
2292
|
"team",
|
|
2260
2293
|
"department",
|
|
@@ -2407,6 +2440,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2407
2440
|
this.messaging = opts.messaging;
|
|
2408
2441
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2409
2442
|
this.tenancyPosture = opts.tenancyPosture;
|
|
2443
|
+
this.fieldVisibility = opts.fieldVisibility;
|
|
2410
2444
|
this.recordReaderVisibleObjects = new Set(
|
|
2411
2445
|
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
|
|
2412
2446
|
);
|
|
@@ -2415,6 +2449,57 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2415
2449
|
attachTenancyPosture(provider) {
|
|
2416
2450
|
this.tenancyPosture = provider;
|
|
2417
2451
|
}
|
|
2452
|
+
/**
|
|
2453
|
+
* [#10749] Attach (or replace) the field-visibility authority the payload
|
|
2454
|
+
* redaction seam reads. Late-bound: plugin load order does not guarantee the
|
|
2455
|
+
* security service exists when this one is constructed.
|
|
2456
|
+
*/
|
|
2457
|
+
attachFieldVisibility(source) {
|
|
2458
|
+
this.fieldVisibility = source;
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* [#10749] Redact each row's payload snapshot down to the fields the READING
|
|
2462
|
+
* caller may see on that row's subject object.
|
|
2463
|
+
*
|
|
2464
|
+
* Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
|
|
2465
|
+
* load-bearing rather than incidental: `enrichRows` derives `payload_display`
|
|
2466
|
+
* (lookup foreign keys inside the snapshot resolved to referenced record
|
|
2467
|
+
* titles) and `payload_labels` (a label per snapshot key) by WALKING THE
|
|
2468
|
+
* SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
|
|
2469
|
+
* redact after and a restricted field's name, its authored label and the
|
|
2470
|
+
* title of the record it points at all still ship — the value would be gone
|
|
2471
|
+
* and the disclosure would not.
|
|
2472
|
+
*
|
|
2473
|
+
* Rows are grouped by subject object so one `getReadableFields` call covers a
|
|
2474
|
+
* whole page of same-object requests.
|
|
2475
|
+
*/
|
|
2476
|
+
async redactPayloads(rows, context) {
|
|
2477
|
+
const withPayload = rows.filter((r) => r?.payload != null);
|
|
2478
|
+
if (withPayload.length === 0) return;
|
|
2479
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
2480
|
+
for (const r of withPayload) {
|
|
2481
|
+
const key = String(r.object_name ?? "");
|
|
2482
|
+
let list = byObject.get(key);
|
|
2483
|
+
if (!list) {
|
|
2484
|
+
list = [];
|
|
2485
|
+
byObject.set(key, list);
|
|
2486
|
+
}
|
|
2487
|
+
list.push(r);
|
|
2488
|
+
}
|
|
2489
|
+
for (const [object, group] of byObject) {
|
|
2490
|
+
const readable = await resolveReadableSnapshotFields(
|
|
2491
|
+
this.fieldVisibility,
|
|
2492
|
+
object,
|
|
2493
|
+
context,
|
|
2494
|
+
this.logger
|
|
2495
|
+
);
|
|
2496
|
+
if (readable === void 0) continue;
|
|
2497
|
+
for (const r of group) {
|
|
2498
|
+
const { payload } = redactSnapshot(r.payload, readable);
|
|
2499
|
+
r.payload = payload;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2418
2503
|
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2419
2504
|
get orgScopeDeps() {
|
|
2420
2505
|
return {
|
|
@@ -2681,7 +2766,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2681
2766
|
}) : users;
|
|
2682
2767
|
try {
|
|
2683
2768
|
if (type === "team") {
|
|
2684
|
-
const users = await this.expandTeamUsers(String(a.value));
|
|
2769
|
+
const users = await this.expandTeamUsers(String(a.value), organizationId);
|
|
2685
2770
|
if (users.length) return users;
|
|
2686
2771
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
2687
2772
|
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
@@ -2695,7 +2780,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2695
2780
|
} else if (type === "manager" && record) {
|
|
2696
2781
|
const subject = record[a.value] ?? record.owner_id;
|
|
2697
2782
|
if (subject) {
|
|
2698
|
-
const mgr = await this.lookupManager(String(subject));
|
|
2783
|
+
const mgr = await this.lookupManager(String(subject), organizationId);
|
|
2699
2784
|
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
2700
2785
|
}
|
|
2701
2786
|
}
|
|
@@ -2792,7 +2877,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2792
2877
|
try {
|
|
2793
2878
|
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2794
2879
|
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2795
|
-
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2880
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
|
|
2796
2881
|
else {
|
|
2797
2882
|
throw new Error(
|
|
2798
2883
|
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
@@ -2817,9 +2902,50 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2817
2902
|
}
|
|
2818
2903
|
return { slots, raw };
|
|
2819
2904
|
}
|
|
2820
|
-
/**
|
|
2821
|
-
|
|
2905
|
+
/**
|
|
2906
|
+
* Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
|
|
2907
|
+
*
|
|
2908
|
+
* Takes an organization for the reason every sibling expansion does
|
|
2909
|
+
* ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
|
|
2910
|
+
* {@link expandMembershipTierUsers}): an approver expansion answers "who, in
|
|
2911
|
+
* THIS organization". Before #10230 this one did not ask, and it was the last
|
|
2912
|
+
* expansion that did not — a `team` approver naming ANOTHER organization's
|
|
2913
|
+
* team routed that organization's people an approval over a record they are
|
|
2914
|
+
* not a tenant of.
|
|
2915
|
+
*
|
|
2916
|
+
* TWO screens run here, and they assert different things (#10230, #10547):
|
|
2917
|
+
*
|
|
2918
|
+
* 1. the TEAM must not provably belong to another organization
|
|
2919
|
+
* ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
|
|
2920
|
+
* `organization_id` outright
|
|
2921
|
+
* (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
|
|
2922
|
+
* team id transitively names exactly one organization and ONE row
|
|
2923
|
+
* answers the question;
|
|
2924
|
+
* 2. each expanded MEMBER must not provably hold membership only in other
|
|
2925
|
+
* organizations ({@link dropMembersProvablyOutsideOrg}) —
|
|
2926
|
+
* `sys_team_member` carries `team_id` and `user_id` and NO tenancy
|
|
2927
|
+
* column at all, so passing (1) says nothing whatever about the people
|
|
2928
|
+
* it lists.
|
|
2929
|
+
*
|
|
2930
|
+
* #10230 landed (1) alone and deferred (2) on purpose. What closed the
|
|
2931
|
+
* deferral is that (1) does not imply (2) even a little: a member removed
|
|
2932
|
+
* from the organization but left on the team, a team re-parented across
|
|
2933
|
+
* organizations (`/organization/update-team` accepts `organizationId` in its
|
|
2934
|
+
* partial body), or a `sys_team_member` row written by a seed rather than
|
|
2935
|
+
* through better-auth all produce a team that passes (1) carrying a user who
|
|
2936
|
+
* is provably a tenant of somewhere else. Measured on this tree, not read off
|
|
2937
|
+
* the schema — the probe is quoted in `team-member-org-screen.test.ts`.
|
|
2938
|
+
*
|
|
2939
|
+
* (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
|
|
2940
|
+
* further out, and it is asserted the same way: `sys_user` carries no tenancy
|
|
2941
|
+
* fact, so `sys_member` rows are the only evidence that a person is placed
|
|
2942
|
+
* anywhere. Like that screen, this one grants no reads and applies no read
|
|
2943
|
+
* screen to any approver type that lacks one today, so it decides nothing
|
|
2944
|
+
* #7497 (does approver routing imply record read visibility?) asks.
|
|
2945
|
+
*/
|
|
2946
|
+
async expandTeamUsers(teamId, organizationId) {
|
|
2822
2947
|
if (!teamId) return [];
|
|
2948
|
+
if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
|
|
2823
2949
|
let rows = [];
|
|
2824
2950
|
try {
|
|
2825
2951
|
rows = await this.engine.find("sys_team_member", {
|
|
@@ -2831,7 +2957,149 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2831
2957
|
} catch {
|
|
2832
2958
|
rows = [];
|
|
2833
2959
|
}
|
|
2834
|
-
|
|
2960
|
+
const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
2961
|
+
return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
|
|
2965
|
+
*
|
|
2966
|
+
* "Provably" carries the same posture the sibling screen states at length in
|
|
2967
|
+
* {@link managerIsProvablyOutsideOrg}, for the same reasons:
|
|
2968
|
+
*
|
|
2969
|
+
* - the team row carries an `organization_id` and it is not the request's
|
|
2970
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
|
|
2971
|
+
* - the row carries no `organization_id`, does not exist, or the read failed
|
|
2972
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
2973
|
+
*
|
|
2974
|
+
* The `organization_id = null` limb is not timidity — it is the reading
|
|
2975
|
+
* {@link businessUnitOrgScope} settled on one screen below, for the identical
|
|
2976
|
+
* shape: null on a platform object means "owned by no organization", which is
|
|
2977
|
+
* what a seed writes because a seed cannot know the organization id the
|
|
2978
|
+
* runtime mints at boot. Treating null as "not mine" would delete every
|
|
2979
|
+
* seeded team approver at once — a larger behaviour change than the hole
|
|
2980
|
+
* being closed. Measured, and not hypothetically: this package's own
|
|
2981
|
+
* `team_ok` expansion fixture is exactly such a stack (it has
|
|
2982
|
+
* `sys_team_member` rows, a request carrying an organization, and no
|
|
2983
|
+
* `sys_team` row at all).
|
|
2984
|
+
*
|
|
2985
|
+
* Screening the TEAM before reading its members is also what keeps the cost
|
|
2986
|
+
* at one row: a team that fails the screen never fans out.
|
|
2987
|
+
*/
|
|
2988
|
+
async teamIsProvablyOutsideOrg(teamId, organizationId) {
|
|
2989
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
2990
|
+
if (!requestOrg) return false;
|
|
2991
|
+
let rows = [];
|
|
2992
|
+
try {
|
|
2993
|
+
rows = await this.engine.find("sys_team", {
|
|
2994
|
+
where: { id: teamId },
|
|
2995
|
+
fields: ["id", "organization_id"],
|
|
2996
|
+
limit: 1,
|
|
2997
|
+
context: SYSTEM_CTX2
|
|
2998
|
+
});
|
|
2999
|
+
} catch {
|
|
3000
|
+
return false;
|
|
3001
|
+
}
|
|
3002
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
3003
|
+
const teamOrg = row?.organization_id ? String(row.organization_id) : "";
|
|
3004
|
+
if (!teamOrg) return false;
|
|
3005
|
+
if (teamOrg === requestOrg) return false;
|
|
3006
|
+
this.logger?.warn?.(
|
|
3007
|
+
`[approvals] #10230: team '${teamId}' was dropped from the approver slate \u2014 'sys_team.organization_id' is '${teamOrg}', not the request's organization '${requestOrg}', so routing this approval to its members would put approval authority over the record outside its tenant. Point the approver at a team in this organization, or route this step with an approver type that names someone in it.`,
|
|
3008
|
+
{ teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
|
|
3009
|
+
);
|
|
3010
|
+
return true;
|
|
3011
|
+
}
|
|
3012
|
+
/**
|
|
3013
|
+
* Drop the expanded team members who are PROVABLY tenants of other
|
|
3014
|
+
* organizations and not of `organizationId`. (#10547)
|
|
3015
|
+
*
|
|
3016
|
+
* Returns the survivors, in the order they were expanded.
|
|
3017
|
+
*
|
|
3018
|
+
* Posture — identical to {@link managerIsProvablyOutsideOrg} and
|
|
3019
|
+
* {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
|
|
3020
|
+
* assertion about the same table:
|
|
3021
|
+
*
|
|
3022
|
+
* - membership rows exist for this user, none in `organizationId`
|
|
3023
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
|
|
3024
|
+
* - no membership rows at all for him, the read failed, or the request
|
|
3025
|
+
* carries no organization
|
|
3026
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3027
|
+
*
|
|
3028
|
+
* The absent limb is load-bearing rather than timid, and #3807 is the recorded
|
|
3029
|
+
* cost of getting it wrong: a stack that stamps an organization on requests
|
|
3030
|
+
* but never materializes `sys_member` rows would otherwise lose EVERY team
|
|
3031
|
+
* approver at once. This package's own `team_ok` expansion fixture and
|
|
3032
|
+
* #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
|
|
3033
|
+
* a request organization and no `sys_member` table at all — so the absent
|
|
3034
|
+
* limb is exercised by neighbours on every run of this suite.
|
|
3035
|
+
*
|
|
3036
|
+
* ONE read for the whole slate, never one per person: the expansion is capped
|
|
3037
|
+
* at 10000 members and a per-user query would turn a single team approver
|
|
3038
|
+
* into 10000 round trips.
|
|
3039
|
+
*
|
|
3040
|
+
* ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
|
|
3041
|
+
* the only evidence that a member IS a tenant here, so a result cut off at
|
|
3042
|
+
* the limit could be missing the very row that keeps a legitimate approver on
|
|
3043
|
+
* the slate — screening him out on missing evidence, which inverts the
|
|
3044
|
+
* posture into fail-CLOSED precisely where it must not. When the read comes
|
|
3045
|
+
* back at the cap it is treated as no evidence at all.
|
|
3046
|
+
*/
|
|
3047
|
+
async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
|
|
3048
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3049
|
+
if (!requestOrg || !userIds.length) return userIds;
|
|
3050
|
+
let rows = [];
|
|
3051
|
+
try {
|
|
3052
|
+
rows = await this.engine.find("sys_member", {
|
|
3053
|
+
where: { user_id: { $in: userIds } },
|
|
3054
|
+
fields: ["user_id", "organization_id"],
|
|
3055
|
+
limit: MEMBER_SCREEN_READ_LIMIT,
|
|
3056
|
+
context: SYSTEM_CTX2
|
|
3057
|
+
});
|
|
3058
|
+
} catch {
|
|
3059
|
+
return userIds;
|
|
3060
|
+
}
|
|
3061
|
+
if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
|
|
3062
|
+
this.logger?.warn?.(
|
|
3063
|
+
`[approvals] #10547: the membership screen for team '${teamId}' read ${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row cap, so the result may be truncated. Routing is left unchanged rather than risk dropping a member whose proof of membership fell outside the read.`,
|
|
3064
|
+
{ teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
|
|
3065
|
+
);
|
|
3066
|
+
return userIds;
|
|
3067
|
+
}
|
|
3068
|
+
const orgsByUser = /* @__PURE__ */ new Map();
|
|
3069
|
+
for (const r of rows ?? []) {
|
|
3070
|
+
const uid2 = String(r?.user_id ?? "");
|
|
3071
|
+
const org = String(r?.organization_id ?? "");
|
|
3072
|
+
if (!uid2 || !org) continue;
|
|
3073
|
+
const seen = orgsByUser.get(uid2);
|
|
3074
|
+
if (seen) seen.push(org);
|
|
3075
|
+
else orgsByUser.set(uid2, [org]);
|
|
3076
|
+
}
|
|
3077
|
+
const kept = [];
|
|
3078
|
+
const dropped = [];
|
|
3079
|
+
for (const uid2 of userIds) {
|
|
3080
|
+
const orgs = orgsByUser.get(uid2);
|
|
3081
|
+
if (!orgs?.length) {
|
|
3082
|
+
kept.push(uid2);
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
if (orgs.includes(requestOrg)) {
|
|
3086
|
+
kept.push(uid2);
|
|
3087
|
+
continue;
|
|
3088
|
+
}
|
|
3089
|
+
dropped.push({ userId: uid2, organizationIds: orgs });
|
|
3090
|
+
}
|
|
3091
|
+
if (dropped.length) {
|
|
3092
|
+
this.logger?.warn?.(
|
|
3093
|
+
`[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from the approver slate \u2014 ${dropped.map((d) => `'${d.userId}'`).join(", ")} hold membership in other organization(s), none of them the request's organization '${requestOrg}', so routing this approval to them would put approval authority over the record outside its tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry no organization of their own. Remove them from the team, grant them a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3094
|
+
{
|
|
3095
|
+
teamId,
|
|
3096
|
+
requestOrganizationId: requestOrg,
|
|
3097
|
+
droppedUserIds: dropped.map((d) => d.userId),
|
|
3098
|
+
droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
|
|
3099
|
+
}
|
|
3100
|
+
);
|
|
3101
|
+
}
|
|
3102
|
+
return kept;
|
|
2835
3103
|
}
|
|
2836
3104
|
/**
|
|
2837
3105
|
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
@@ -3000,7 +3268,36 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3000
3268
|
}
|
|
3001
3269
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
3002
3270
|
}
|
|
3003
|
-
|
|
3271
|
+
/**
|
|
3272
|
+
* `sys_user.manager_id`, screened to the request's organization (#10153).
|
|
3273
|
+
*
|
|
3274
|
+
* Takes an organization argument for the same reason its siblings do
|
|
3275
|
+
* ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
|
|
3276
|
+
* approver expansion answers "who, in THIS organization". Before #10153 this
|
|
3277
|
+
* one did not ask, and it was the only expansion that did not — a
|
|
3278
|
+
* `manager_id` pointing at a person in another organization routed that
|
|
3279
|
+
* person an approval over a record they are not a tenant of.
|
|
3280
|
+
*
|
|
3281
|
+
* ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
|
|
3282
|
+
* filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
|
|
3283
|
+
* comment exists so the next reader does not conclude that #7497 (does
|
|
3284
|
+
* approver routing imply record read visibility?) was settled here. It was
|
|
3285
|
+
* not. Two facts make this the SIBLING treatment rather than a
|
|
3286
|
+
* read-visibility ruling:
|
|
3287
|
+
*
|
|
3288
|
+
* 1. Two of the three org-scoped expansions already screen on exactly this
|
|
3289
|
+
* column — `expandMembershipTierUsers` filters `sys_member.organization_id`
|
|
3290
|
+
* outright, and it is also the second limb of `expandPositionUsers`. So
|
|
3291
|
+
* `sys_member.organization_id` is already this file's answer to "which
|
|
3292
|
+
* organization is this person in", independent of what they may read.
|
|
3293
|
+
* 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
|
|
3294
|
+
* table, so a membership row is the only tenancy fact that exists for a
|
|
3295
|
+
* user — there is no other read this screen could have been written with.
|
|
3296
|
+
*
|
|
3297
|
+
* This change grants no reads and applies no read screen to any type that
|
|
3298
|
+
* lacks one today, so it decides nothing #7497 asks.
|
|
3299
|
+
*/
|
|
3300
|
+
async lookupManager(userId, organizationId) {
|
|
3004
3301
|
try {
|
|
3005
3302
|
const rows = await this.engine.find("sys_user", {
|
|
3006
3303
|
where: { id: userId },
|
|
@@ -3009,11 +3306,64 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3009
3306
|
context: SYSTEM_CTX2
|
|
3010
3307
|
});
|
|
3011
3308
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
3012
|
-
|
|
3309
|
+
const managerId = row?.manager_id ? String(row.manager_id) : null;
|
|
3310
|
+
if (!managerId) return null;
|
|
3311
|
+
if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
|
|
3312
|
+
return managerId;
|
|
3013
3313
|
} catch {
|
|
3014
3314
|
return null;
|
|
3015
3315
|
}
|
|
3016
3316
|
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Is `managerId` PROVABLY a member of other organizations and not of
|
|
3319
|
+
* `organizationId`? (#10153)
|
|
3320
|
+
*
|
|
3321
|
+
* "Provably" is the whole shape of this screen, and it is deliberate rather
|
|
3322
|
+
* than a weaker version of "must prove membership":
|
|
3323
|
+
*
|
|
3324
|
+
* - membership rows exist for this user, none in the request's org
|
|
3325
|
+
* ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
|
|
3326
|
+
* - no membership rows at all, or the read failed
|
|
3327
|
+
* ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
|
|
3328
|
+
*
|
|
3329
|
+
* The fail-open half is not timidity, it is this file's ruled posture on
|
|
3330
|
+
* addressing paths, stated twice already: {@link filterApproversWhoCanRead}
|
|
3331
|
+
* refuses to empty a live slate on an infrastructure hiccup, and
|
|
3332
|
+
* {@link expandPositionUsers} carries "a step routing to nobody is worse than
|
|
3333
|
+
* one routing to a lapsed holder". It is also load-bearing in practice — a
|
|
3334
|
+
* stack that stamps an organization on its requests but does not materialize
|
|
3335
|
+
* `sys_member` rows would otherwise lose every manager approver at once,
|
|
3336
|
+
* which is a bigger behaviour change than the hole being closed. Measured:
|
|
3337
|
+
* this repo's own `type:manager` out-of-office fixture is such a stack.
|
|
3338
|
+
*
|
|
3339
|
+
* Screening the MANAGER only, before OOO delegation, is deliberate too: the
|
|
3340
|
+
* delegate arrives from `sys_approval_delegation`, whose rows already carry
|
|
3341
|
+
* (and are already filtered by) an `organization_id` in
|
|
3342
|
+
* {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
|
|
3343
|
+
*/
|
|
3344
|
+
async managerIsProvablyOutsideOrg(managerId, organizationId) {
|
|
3345
|
+
const requestOrg = organizationId ? String(organizationId) : "";
|
|
3346
|
+
if (!requestOrg) return false;
|
|
3347
|
+
let rows = [];
|
|
3348
|
+
try {
|
|
3349
|
+
rows = await this.engine.find("sys_member", {
|
|
3350
|
+
where: { user_id: managerId },
|
|
3351
|
+
fields: ["user_id", "organization_id"],
|
|
3352
|
+
limit: 1e3,
|
|
3353
|
+
context: SYSTEM_CTX2
|
|
3354
|
+
});
|
|
3355
|
+
} catch {
|
|
3356
|
+
return false;
|
|
3357
|
+
}
|
|
3358
|
+
const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
|
|
3359
|
+
if (!orgs.length) return false;
|
|
3360
|
+
if (orgs.includes(requestOrg)) return false;
|
|
3361
|
+
this.logger?.warn?.(
|
|
3362
|
+
`[approvals] #10153: manager '${managerId}' was dropped from the approver slate \u2014 'sys_user.manager_id' points across an organization boundary. He holds membership in ${orgs.length} organization(s), none of them the request's organization '${requestOrg}', so routing this approval to him would put approval authority over the record outside its tenant. Fix the 'manager_id' link, grant him a membership in this organization, or route this step with an approver type that names someone in it.`,
|
|
3363
|
+
{ managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
|
|
3364
|
+
);
|
|
3365
|
+
return true;
|
|
3366
|
+
}
|
|
3017
3367
|
/**
|
|
3018
3368
|
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
3019
3369
|
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
@@ -5139,8 +5489,70 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5139
5489
|
return { requests: desired.size, inserted, deleted };
|
|
5140
5490
|
}
|
|
5141
5491
|
// ── Read API ─────────────────────────────────────────────────
|
|
5492
|
+
/**
|
|
5493
|
+
* [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
|
|
5494
|
+
* for THIS caller over THIS query's scope?
|
|
5495
|
+
*
|
|
5496
|
+
* `payload_json` is the one searched column whose contents the serve path
|
|
5497
|
+
* masks per reader (`redactPayloads`, #10749). A predicate over it is
|
|
5498
|
+
* evaluated by the driver against the column AT REST — unmasked, before
|
|
5499
|
+
* anything is served — so for a caller whose view of the snapshot is masked,
|
|
5500
|
+
* row membership answers questions about contents that caller may not read.
|
|
5501
|
+
* The other four arms are columns of `sys_approval_request` itself, which
|
|
5502
|
+
* every caller who can see the row reads whole; they are untouched.
|
|
5503
|
+
*
|
|
5504
|
+
* ## The invariant this method exists to hold
|
|
5505
|
+
*
|
|
5506
|
+
* "This caller's view is masked" is read from **the same authority and the
|
|
5507
|
+
* same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
|
|
5508
|
+
* asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
|
|
5509
|
+
* notion of "redacted" — comparing the readable set against the object's
|
|
5510
|
+
* schema, say — would be a fresh source of drift, and drift between the
|
|
5511
|
+
* serve rule and the filter rule IS the defect this closes, reconstituted one
|
|
5512
|
+
* layer down. So the only "not masked" answer accepted here is the one serve
|
|
5513
|
+
* itself acts on: `undefined`, the seam's documented do-not-narrow branch.
|
|
5514
|
+
* When the seam holds a concrete list the mask is IN FORCE, whether or not it
|
|
5515
|
+
* happens to remove a key from any particular row — a row-dependent question
|
|
5516
|
+
* no predicate can answer before rows exist.
|
|
5517
|
+
*
|
|
5518
|
+
* ## The two predicate-time cases
|
|
5519
|
+
*
|
|
5520
|
+
* Redaction is decided per ROW (each row names its own subject object, hence
|
|
5521
|
+
* its own readable set), but a filter is built before any row exists:
|
|
5522
|
+
*
|
|
5523
|
+
* - **authority absent** — `resolveReadableSnapshotFields` answers
|
|
5524
|
+
* `undefined` for EVERY object, so serve hands over every snapshot whole.
|
|
5525
|
+
* Keeping the arm leaks nothing serve does not already hand over, and this
|
|
5526
|
+
* is the shape every deployment that has not wired the security plugin
|
|
5527
|
+
* gets: search is byte-for-byte unchanged. Checked first, and that order
|
|
5528
|
+
* is load-bearing — see the object-scope note below.
|
|
5529
|
+
* - **authority wired** — the readable set is per object, so the arm is
|
|
5530
|
+
* admissible only for a scope of exactly one KNOWN object. With
|
|
5531
|
+
* `filter.object` present that object is known at predicate time and the
|
|
5532
|
+
* seam is asked about it directly. Absent, the query spans every object
|
|
5533
|
+
* and there is nothing sound to ask, so the arm is dropped.
|
|
5534
|
+
*
|
|
5535
|
+
* Dropping an arm is strictly NARROWING: it never refuses a query and never
|
|
5536
|
+
* widens what comes back, so the fail-closed direction is cheap here and is
|
|
5537
|
+
* taken rather than reaching for a per-object predicate machine to avoid it.
|
|
5538
|
+
* Refusing the query outright would be the louder behaviour change, and is
|
|
5539
|
+
* deliberately not what this does.
|
|
5540
|
+
*/
|
|
5541
|
+
async freeTextMayMatchSnapshot(objectInScope, context) {
|
|
5542
|
+
const authority = this.fieldVisibility;
|
|
5543
|
+
if (!authority || typeof authority.getReadableFields !== "function") return true;
|
|
5544
|
+
const object = String(objectInScope ?? "").trim();
|
|
5545
|
+
if (!object) return false;
|
|
5546
|
+
const readable = await resolveReadableSnapshotFields(
|
|
5547
|
+
authority,
|
|
5548
|
+
object,
|
|
5549
|
+
context,
|
|
5550
|
+
this.logger
|
|
5551
|
+
);
|
|
5552
|
+
return readable === void 0;
|
|
5553
|
+
}
|
|
5142
5554
|
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
5143
|
-
buildRequestWhere(filter, context) {
|
|
5555
|
+
async buildRequestWhere(filter, context) {
|
|
5144
5556
|
const f = {};
|
|
5145
5557
|
if (filter?.object) f.object_name = filter.object;
|
|
5146
5558
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
@@ -5149,13 +5561,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5149
5561
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
5150
5562
|
const q = filter?.q?.trim();
|
|
5151
5563
|
if (q) {
|
|
5152
|
-
|
|
5564
|
+
const arms = [
|
|
5153
5565
|
{ process_name: { $contains: q } },
|
|
5154
5566
|
{ object_name: { $contains: q } },
|
|
5155
5567
|
{ record_id: { $contains: q } },
|
|
5156
|
-
{ submitter_id: { $contains: q } }
|
|
5157
|
-
{ payload_json: { $contains: q } }
|
|
5568
|
+
{ submitter_id: { $contains: q } }
|
|
5158
5569
|
];
|
|
5570
|
+
if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
|
|
5571
|
+
arms.push({ payload_json: { $contains: q } });
|
|
5572
|
+
}
|
|
5573
|
+
f.$or = arms;
|
|
5159
5574
|
}
|
|
5160
5575
|
if (Array.isArray(filter?.status)) {
|
|
5161
5576
|
const statuses = filter.status.filter(Boolean);
|
|
@@ -5358,7 +5773,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5358
5773
|
return true;
|
|
5359
5774
|
}
|
|
5360
5775
|
async listRequests(filter, context) {
|
|
5361
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
5776
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5362
5777
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5363
5778
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5364
5779
|
if (ids) {
|
|
@@ -5382,12 +5797,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5382
5797
|
}
|
|
5383
5798
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
5384
5799
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
5800
|
+
await this.redactPayloads(list, context);
|
|
5385
5801
|
await this.enrichRows(list);
|
|
5386
5802
|
this.attachViewers(list, context);
|
|
5387
5803
|
return list;
|
|
5388
5804
|
}
|
|
5389
5805
|
async countRequests(filter, context) {
|
|
5390
|
-
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
5806
|
+
const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
|
|
5391
5807
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
5392
5808
|
const ids = await this.approverRequestIds(approverTargets, tenantOrg);
|
|
5393
5809
|
if (ids) {
|
|
@@ -5450,6 +5866,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
5450
5866
|
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
5451
5867
|
}
|
|
5452
5868
|
const row = rowFromRequest(rows[0]);
|
|
5869
|
+
await this.redactPayloads([row], context);
|
|
5453
5870
|
await this.enrichRows([row]);
|
|
5454
5871
|
await this.attachFlowSteps(row);
|
|
5455
5872
|
await this.attachDecisionProgress(row, rows[0]);
|
|
@@ -5953,6 +6370,52 @@ function unbindAllHooks(engine) {
|
|
|
5953
6370
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
5954
6371
|
}
|
|
5955
6372
|
|
|
6373
|
+
// src/payload-redaction-middleware.ts
|
|
6374
|
+
var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
|
|
6375
|
+
function parseSnapshot(raw) {
|
|
6376
|
+
if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
|
|
6377
|
+
try {
|
|
6378
|
+
return { ok: true, value: JSON.parse(raw) };
|
|
6379
|
+
} catch {
|
|
6380
|
+
return { ok: false, value: void 0 };
|
|
6381
|
+
}
|
|
6382
|
+
}
|
|
6383
|
+
async function redactRowsInPlace(rows, security, context, logger) {
|
|
6384
|
+
const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
|
|
6385
|
+
if (list.length === 0) return;
|
|
6386
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6387
|
+
for (const row of list) {
|
|
6388
|
+
if (!row || typeof row !== "object") continue;
|
|
6389
|
+
const raw = row.payload_json;
|
|
6390
|
+
const parsed = parseSnapshot(raw);
|
|
6391
|
+
if (!parsed.ok) continue;
|
|
6392
|
+
const object = String(row.object_name ?? "").trim();
|
|
6393
|
+
if (!object) continue;
|
|
6394
|
+
if (!cache.has(object)) {
|
|
6395
|
+
cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
|
|
6396
|
+
}
|
|
6397
|
+
const readable = cache.get(object);
|
|
6398
|
+
if (readable === void 0) continue;
|
|
6399
|
+
const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
|
|
6400
|
+
if (redactedKeys.length === 0) continue;
|
|
6401
|
+
row.payload_json = JSON.stringify(payload);
|
|
6402
|
+
}
|
|
6403
|
+
}
|
|
6404
|
+
function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
|
|
6405
|
+
engine.registerMiddleware(async (opCtx, next) => {
|
|
6406
|
+
await next();
|
|
6407
|
+
if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
|
|
6408
|
+
if (opCtx?.context?.isSystem) return;
|
|
6409
|
+
try {
|
|
6410
|
+
await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
|
|
6411
|
+
} catch (err) {
|
|
6412
|
+
logger?.warn?.("[approvals] snapshot redaction middleware failed", {
|
|
6413
|
+
error: err?.message ?? String(err)
|
|
6414
|
+
});
|
|
6415
|
+
}
|
|
6416
|
+
}, { object: APPROVAL_REQUEST_OBJECT });
|
|
6417
|
+
}
|
|
6418
|
+
|
|
5956
6419
|
// src/approval-node.ts
|
|
5957
6420
|
var import_automation3 = require("@objectstack/spec/automation");
|
|
5958
6421
|
|
|
@@ -6216,11 +6679,26 @@ var ApprovalsServicePlugin = class {
|
|
|
6216
6679
|
}
|
|
6217
6680
|
}
|
|
6218
6681
|
});
|
|
6682
|
+
const fieldVisibility = () => {
|
|
6683
|
+
try {
|
|
6684
|
+
const sec = ctx.getService("security");
|
|
6685
|
+
return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
|
|
6686
|
+
} catch {
|
|
6687
|
+
return void 0;
|
|
6688
|
+
}
|
|
6689
|
+
};
|
|
6690
|
+
this.service.attachFieldVisibility({
|
|
6691
|
+
getReadableFields: (object, context) => {
|
|
6692
|
+
const sec = fieldVisibility();
|
|
6693
|
+
return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
|
|
6694
|
+
}
|
|
6695
|
+
});
|
|
6219
6696
|
if (!this.options.disableAutoHooks) {
|
|
6220
6697
|
try {
|
|
6221
6698
|
unbindAllHooks(engine);
|
|
6222
6699
|
bindApprovalLockHook(engine, ctx.logger);
|
|
6223
6700
|
bindDelegationWriteGuard(engine, ctx.logger);
|
|
6701
|
+
bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
|
|
6224
6702
|
} catch (err) {
|
|
6225
6703
|
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
6226
6704
|
}
|
|
@@ -6260,6 +6738,7 @@ var ApprovalsServicePlugin = class {
|
|
|
6260
6738
|
}
|
|
6261
6739
|
};
|
|
6262
6740
|
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
6741
|
+
this.jobService = jobs;
|
|
6263
6742
|
this.escalationJobScheduled = true;
|
|
6264
6743
|
void sweep().catch((err) => {
|
|
6265
6744
|
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
@@ -6346,14 +6825,32 @@ var ApprovalsServicePlugin = class {
|
|
|
6346
6825
|
);
|
|
6347
6826
|
}
|
|
6348
6827
|
}
|
|
6349
|
-
|
|
6828
|
+
/**
|
|
6829
|
+
* The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
|
|
6830
|
+
* ONLY teardown entry point `ObjectKernel.performShutdown()` and
|
|
6831
|
+
* `LiteKernel.destroy()` invoke.
|
|
6832
|
+
*
|
|
6833
|
+
* [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
|
|
6834
|
+
* `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
|
|
6835
|
+
* past this plugin at shutdown: the SLA escalation job stayed scheduled and
|
|
6836
|
+
* this plugin's ObjectQL hooks stayed bound to an engine the kernel had
|
|
6837
|
+
* finished with. `start()` IS on the interface, so the pair read as symmetric
|
|
6838
|
+
* in review — that asymmetry is what let the same shape survive in six
|
|
6839
|
+
* packages at once.
|
|
6840
|
+
*
|
|
6841
|
+
* This member owns no timer of its own (the escalation clock belongs to
|
|
6842
|
+
* `service-job`), so it never cost a merge-queue eviction the way the
|
|
6843
|
+
* `plugin-reports` / `service-messaging` members did (#9371). The class is
|
|
6844
|
+
* the same one either way: a teardown the kernel does not reach.
|
|
6845
|
+
*/
|
|
6846
|
+
async destroy() {
|
|
6350
6847
|
if (this.escalationJobScheduled) {
|
|
6351
6848
|
try {
|
|
6352
|
-
|
|
6353
|
-
await jobs?.cancel?.(ESCALATION_JOB_NAME);
|
|
6849
|
+
await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
|
|
6354
6850
|
} catch {
|
|
6355
6851
|
}
|
|
6356
6852
|
this.escalationJobScheduled = false;
|
|
6853
|
+
this.jobService = void 0;
|
|
6357
6854
|
}
|
|
6358
6855
|
if (this.engine) {
|
|
6359
6856
|
try {
|
|
@@ -6362,6 +6859,17 @@ var ApprovalsServicePlugin = class {
|
|
|
6362
6859
|
}
|
|
6363
6860
|
}
|
|
6364
6861
|
}
|
|
6862
|
+
/**
|
|
6863
|
+
* Retained alias for {@link destroy}. Kept because it is public API of an
|
|
6864
|
+
* exported class, and removing it would break an embedder who learned to call
|
|
6865
|
+
* it directly precisely BECAUSE the kernel never did. The parameter is now
|
|
6866
|
+
* optional and ignored: `destroy()` takes no context, so teardown uses the
|
|
6867
|
+
* job service captured when the escalation clock was wired. Prefer kernel
|
|
6868
|
+
* shutdown; direct callers keep working unchanged.
|
|
6869
|
+
*/
|
|
6870
|
+
async stop(_ctx) {
|
|
6871
|
+
await this.destroy();
|
|
6872
|
+
}
|
|
6365
6873
|
};
|
|
6366
6874
|
// Annotate the CommonJS export names for ESM import in node:
|
|
6367
6875
|
0 && (module.exports = {
|